Show More
@@ -0,0 +1,69 | |||||
|
1 | # This software may be used and distributed according to the terms of the | |||
|
2 | # GNU General Public License version 2 or any later version. | |||
|
3 | ||||
|
4 | """server side extension to advertise pre-generated bundles to seed clones. | |||
|
5 | ||||
|
6 | The extension essentially serves the content of a .hg/clonebundles.manifest | |||
|
7 | file to clients that request it. | |||
|
8 | ||||
|
9 | The clonebundles.manifest file contains a list of URLs and attributes. URLs | |||
|
10 | hold pre-generated bundles that a client fetches and applies. After applying | |||
|
11 | the pre-generated bundle, the client will connect back to the original server | |||
|
12 | and pull data not in the pre-generated bundle. | |||
|
13 | ||||
|
14 | Manifest File Format: | |||
|
15 | ||||
|
16 | The manifest file contains a newline (\n) delimited list of entries. | |||
|
17 | ||||
|
18 | Each line in this file defines an available bundle. Lines have the format: | |||
|
19 | ||||
|
20 | <URL> [<key>=<value] | |||
|
21 | ||||
|
22 | That is, a URL followed by extra metadata describing it. Metadata keys and | |||
|
23 | values should be URL encoded. | |||
|
24 | ||||
|
25 | This metadata is optional. It is up to server operators to populate this | |||
|
26 | metadata. | |||
|
27 | ||||
|
28 | Keys in UPPERCASE are reserved for use by Mercurial. All non-uppercase keys | |||
|
29 | can be used by site installations. | |||
|
30 | ||||
|
31 | The server operator is responsible for generating the bundle manifest file. | |||
|
32 | ||||
|
33 | Metadata Attributes: | |||
|
34 | ||||
|
35 | TBD | |||
|
36 | """ | |||
|
37 | ||||
|
38 | from mercurial import ( | |||
|
39 | extensions, | |||
|
40 | wireproto, | |||
|
41 | ) | |||
|
42 | ||||
|
43 | testedwith = 'internal' | |||
|
44 | ||||
|
45 | def capabilities(orig, repo, proto): | |||
|
46 | caps = orig(repo, proto) | |||
|
47 | ||||
|
48 | # Only advertise if a manifest exists. This does add some I/O to requests. | |||
|
49 | # But this should be cheaper than a wasted network round trip due to | |||
|
50 | # missing file. | |||
|
51 | if repo.opener.exists('clonebundles.manifest'): | |||
|
52 | caps.append('clonebundles') | |||
|
53 | ||||
|
54 | return caps | |||
|
55 | ||||
|
56 | @wireproto.wireprotocommand('clonebundles', '') | |||
|
57 | def bundles(repo, proto): | |||
|
58 | """Server command for returning info for available bundles to seed clones. | |||
|
59 | ||||
|
60 | Clients will parse this response and determine what bundle to fetch. | |||
|
61 | ||||
|
62 | Other extensions may wrap this command to filter or dynamically emit | |||
|
63 | data depending on the request. e.g. you could advertise URLs for | |||
|
64 | the closest data center given the client's IP address. | |||
|
65 | """ | |||
|
66 | return repo.opener.tryread('clonebundles.manifest') | |||
|
67 | ||||
|
68 | def extsetup(ui): | |||
|
69 | extensions.wrapfunction(wireproto, '_capabilities', capabilities) |
@@ -0,0 +1,143 | |||||
|
1 | Set up a server | |||
|
2 | ||||
|
3 | $ hg init server | |||
|
4 | $ cd server | |||
|
5 | $ cat >> .hg/hgrc << EOF | |||
|
6 | > [extensions] | |||
|
7 | > clonebundles = | |||
|
8 | > EOF | |||
|
9 | ||||
|
10 | $ touch foo | |||
|
11 | $ hg -q commit -A -m 'add foo' | |||
|
12 | $ touch bar | |||
|
13 | $ hg -q commit -A -m 'add bar' | |||
|
14 | ||||
|
15 | $ hg serve -d -p $HGPORT --pid-file hg.pid --accesslog access.log | |||
|
16 | $ cat hg.pid >> $DAEMON_PIDS | |||
|
17 | $ cd .. | |||
|
18 | ||||
|
19 | Feature disabled by default | |||
|
20 | (client should not request manifest) | |||
|
21 | ||||
|
22 | $ hg clone -U http://localhost:$HGPORT feature-disabled | |||
|
23 | requesting all changes | |||
|
24 | adding changesets | |||
|
25 | adding manifests | |||
|
26 | adding file changes | |||
|
27 | added 2 changesets with 2 changes to 2 files | |||
|
28 | ||||
|
29 | $ cat server/access.log | |||
|
30 | * - - [*] "GET /?cmd=capabilities HTTP/1.1" 200 - (glob) | |||
|
31 | * - - [*] "GET /?cmd=batch HTTP/1.1" 200 - x-hgarg-1:cmds=heads+%3Bknown+nodes%3D (glob) | |||
|
32 | * - - [*] "GET /?cmd=getbundle HTTP/1.1" 200 - x-hgarg-1:bundlecaps=HG20%2Cbundle2%3DHG20%250Achangegroup%253D01%252C02%250Adigests%253Dmd5%252Csha1%252Csha512%250Aerror%253Dabort%252Cunsupportedcontent%252Cpushraced%252Cpushkey%250Ahgtagsfnodes%250Alistkeys%250Apushkey%250Aremote-changegroup%253Dhttp%252Chttps&cg=1&common=0000000000000000000000000000000000000000&heads=aaff8d2ffbbf07a46dd1f05d8ae7877e3f56e2a2&listkeys=phase%2Cbookmarks (glob) | |||
|
33 | * - - [*] "GET /?cmd=listkeys HTTP/1.1" 200 - x-hgarg-1:namespace=phases (glob) | |||
|
34 | ||||
|
35 | $ cat >> $HGRCPATH << EOF | |||
|
36 | > [experimental] | |||
|
37 | > clonebundles = true | |||
|
38 | > EOF | |||
|
39 | ||||
|
40 | Missing manifest should not result in server lookup | |||
|
41 | ||||
|
42 | $ hg --verbose clone -U http://localhost:$HGPORT no-manifest | |||
|
43 | requesting all changes | |||
|
44 | adding changesets | |||
|
45 | adding manifests | |||
|
46 | adding file changes | |||
|
47 | added 2 changesets with 2 changes to 2 files | |||
|
48 | ||||
|
49 | $ tail -4 server/access.log | |||
|
50 | * - - [*] "GET /?cmd=capabilities HTTP/1.1" 200 - (glob) | |||
|
51 | * - - [*] "GET /?cmd=batch HTTP/1.1" 200 - x-hgarg-1:cmds=heads+%3Bknown+nodes%3D (glob) | |||
|
52 | * - - [*] "GET /?cmd=getbundle HTTP/1.1" 200 - x-hgarg-1:bundlecaps=HG20%2Cbundle2%3DHG20%250Achangegroup%253D01%252C02%250Adigests%253Dmd5%252Csha1%252Csha512%250Aerror%253Dabort%252Cunsupportedcontent%252Cpushraced%252Cpushkey%250Ahgtagsfnodes%250Alistkeys%250Apushkey%250Aremote-changegroup%253Dhttp%252Chttps&cg=1&common=0000000000000000000000000000000000000000&heads=aaff8d2ffbbf07a46dd1f05d8ae7877e3f56e2a2&listkeys=phase%2Cbookmarks (glob) | |||
|
53 | * - - [*] "GET /?cmd=listkeys HTTP/1.1" 200 - x-hgarg-1:namespace=phases (glob) | |||
|
54 | ||||
|
55 | Empty manifest file results in retrieval | |||
|
56 | (the extension only checks if the manifest file exists) | |||
|
57 | ||||
|
58 | $ touch server/.hg/clonebundles.manifest | |||
|
59 | $ hg --verbose clone -U http://localhost:$HGPORT empty-manifest | |||
|
60 | no clone bundles available on remote; falling back to regular clone | |||
|
61 | requesting all changes | |||
|
62 | adding changesets | |||
|
63 | adding manifests | |||
|
64 | adding file changes | |||
|
65 | added 2 changesets with 2 changes to 2 files | |||
|
66 | ||||
|
67 | Manifest file with invalid URL aborts | |||
|
68 | ||||
|
69 | $ echo 'http://does.not.exist/bundle.hg' > server/.hg/clonebundles.manifest | |||
|
70 | $ hg clone http://localhost:$HGPORT 404-url | |||
|
71 | applying clone bundle from http://does.not.exist/bundle.hg | |||
|
72 | error fetching bundle: [Errno -2] Name or service not known | |||
|
73 | abort: error applying bundle | |||
|
74 | (consider contacting the server operator if this error persists) | |||
|
75 | [255] | |||
|
76 | ||||
|
77 | Server is not running aborts | |||
|
78 | ||||
|
79 | $ echo "http://localhost:$HGPORT1/bundle.hg" > server/.hg/clonebundles.manifest | |||
|
80 | $ hg clone http://localhost:$HGPORT server-not-runner | |||
|
81 | applying clone bundle from http://localhost:$HGPORT1/bundle.hg | |||
|
82 | error fetching bundle: [Errno 111] Connection refused | |||
|
83 | abort: error applying bundle | |||
|
84 | (consider contacting the server operator if this error persists) | |||
|
85 | [255] | |||
|
86 | ||||
|
87 | Server returns 404 | |||
|
88 | ||||
|
89 | $ python $TESTDIR/dumbhttp.py -p $HGPORT1 --pid http.pid | |||
|
90 | $ cat http.pid >> $DAEMON_PIDS | |||
|
91 | $ hg clone http://localhost:$HGPORT running-404 | |||
|
92 | applying clone bundle from http://localhost:$HGPORT1/bundle.hg | |||
|
93 | HTTP error fetching bundle: HTTP Error 404: File not found | |||
|
94 | abort: error applying bundle | |||
|
95 | (consider contacting the server operator if this error persists) | |||
|
96 | [255] | |||
|
97 | ||||
|
98 | We can override failure to fall back to regular clone | |||
|
99 | ||||
|
100 | $ hg --config ui.clonebundlefallback=true clone -U http://localhost:$HGPORT 404-fallback | |||
|
101 | applying clone bundle from http://localhost:$HGPORT1/bundle.hg | |||
|
102 | HTTP error fetching bundle: HTTP Error 404: File not found | |||
|
103 | falling back to normal clone | |||
|
104 | requesting all changes | |||
|
105 | adding changesets | |||
|
106 | adding manifests | |||
|
107 | adding file changes | |||
|
108 | added 2 changesets with 2 changes to 2 files | |||
|
109 | ||||
|
110 | Bundle with partial content works | |||
|
111 | ||||
|
112 | $ hg -R server bundle --type gzip --base null -r 53245c60e682 partial.hg | |||
|
113 | 1 changesets found | |||
|
114 | ||||
|
115 | $ echo "http://localhost:$HGPORT1/partial.hg" > server/.hg/clonebundles.manifest | |||
|
116 | $ hg clone -U http://localhost:$HGPORT partial-bundle | |||
|
117 | applying clone bundle from http://localhost:$HGPORT1/partial.hg | |||
|
118 | adding changesets | |||
|
119 | adding manifests | |||
|
120 | adding file changes | |||
|
121 | added 1 changesets with 1 changes to 1 files | |||
|
122 | finished applying clone bundle | |||
|
123 | searching for changes | |||
|
124 | adding changesets | |||
|
125 | adding manifests | |||
|
126 | adding file changes | |||
|
127 | added 1 changesets with 1 changes to 1 files | |||
|
128 | ||||
|
129 | Bundle with full content works | |||
|
130 | ||||
|
131 | $ hg -R server bundle --type gzip --base null -r tip full.hg | |||
|
132 | 2 changesets found | |||
|
133 | ||||
|
134 | $ echo "http://localhost:$HGPORT1/full.hg" > server/.hg/clonebundles.manifest | |||
|
135 | $ hg clone -U http://localhost:$HGPORT full-bundle | |||
|
136 | applying clone bundle from http://localhost:$HGPORT1/full.hg | |||
|
137 | adding changesets | |||
|
138 | adding manifests | |||
|
139 | adding file changes | |||
|
140 | added 2 changesets with 2 changes to 2 files | |||
|
141 | finished applying clone bundle | |||
|
142 | searching for changes | |||
|
143 | no changes found |
@@ -7,12 +7,13 | |||||
7 |
|
7 | |||
8 | from i18n import _ |
|
8 | from i18n import _ | |
9 | from node import hex, nullid |
|
9 | from node import hex, nullid | |
10 | import errno, urllib |
|
10 | import errno, urllib, urllib2 | |
11 | import util, scmutil, changegroup, base85, error |
|
11 | import util, scmutil, changegroup, base85, error | |
12 | import discovery, phases, obsolete, bookmarks as bookmod, bundle2, pushkey |
|
12 | import discovery, phases, obsolete, bookmarks as bookmod, bundle2, pushkey | |
13 | import lock as lockmod |
|
13 | import lock as lockmod | |
14 | import streamclone |
|
14 | import streamclone | |
15 | import tags |
|
15 | import tags | |
|
16 | import url as urlmod | |||
16 |
|
17 | |||
17 | def readbundle(ui, fh, fname, vfs=None): |
|
18 | def readbundle(ui, fh, fname, vfs=None): | |
18 | header = changegroup.readexactly(fh, 4) |
|
19 | header = changegroup.readexactly(fh, 4) | |
@@ -973,6 +974,9 def pull(repo, remote, heads=None, force | |||||
973 | try: |
|
974 | try: | |
974 | pullop.trmanager = transactionmanager(repo, 'pull', remote.url()) |
|
975 | pullop.trmanager = transactionmanager(repo, 'pull', remote.url()) | |
975 | streamclone.maybeperformlegacystreamclone(pullop) |
|
976 | streamclone.maybeperformlegacystreamclone(pullop) | |
|
977 | # This should ideally be in _pullbundle2(). However, it needs to run | |||
|
978 | # before discovery to avoid extra work. | |||
|
979 | _maybeapplyclonebundle(pullop) | |||
976 | _pulldiscovery(pullop) |
|
980 | _pulldiscovery(pullop) | |
977 | if pullop.canusebundle2: |
|
981 | if pullop.canusebundle2: | |
978 | _pullbundle2(pullop) |
|
982 | _pullbundle2(pullop) | |
@@ -1499,3 +1503,88 def unbundle(repo, cg, heads, source, ur | |||||
1499 | if recordout is not None: |
|
1503 | if recordout is not None: | |
1500 | recordout(repo.ui.popbuffer()) |
|
1504 | recordout(repo.ui.popbuffer()) | |
1501 | return r |
|
1505 | return r | |
|
1506 | ||||
|
1507 | def _maybeapplyclonebundle(pullop): | |||
|
1508 | """Apply a clone bundle from a remote, if possible.""" | |||
|
1509 | ||||
|
1510 | repo = pullop.repo | |||
|
1511 | remote = pullop.remote | |||
|
1512 | ||||
|
1513 | if not repo.ui.configbool('experimental', 'clonebundles', False): | |||
|
1514 | return | |||
|
1515 | ||||
|
1516 | if pullop.heads: | |||
|
1517 | return | |||
|
1518 | ||||
|
1519 | if not remote.capable('clonebundles'): | |||
|
1520 | return | |||
|
1521 | ||||
|
1522 | res = remote._call('clonebundles') | |||
|
1523 | entries = parseclonebundlesmanifest(res) | |||
|
1524 | ||||
|
1525 | # TODO filter entries by supported features. | |||
|
1526 | # TODO sort entries by user preferences. | |||
|
1527 | ||||
|
1528 | if not entries: | |||
|
1529 | repo.ui.note(_('no clone bundles available on remote; ' | |||
|
1530 | 'falling back to regular clone\n')) | |||
|
1531 | return | |||
|
1532 | ||||
|
1533 | url = entries[0]['URL'] | |||
|
1534 | repo.ui.status(_('applying clone bundle from %s\n') % url) | |||
|
1535 | if trypullbundlefromurl(repo.ui, repo, url): | |||
|
1536 | repo.ui.status(_('finished applying clone bundle\n')) | |||
|
1537 | # Bundle failed. | |||
|
1538 | # | |||
|
1539 | # We abort by default to avoid the thundering herd of | |||
|
1540 | # clients flooding a server that was expecting expensive | |||
|
1541 | # clone load to be offloaded. | |||
|
1542 | elif repo.ui.configbool('ui', 'clonebundlefallback', False): | |||
|
1543 | repo.ui.warn(_('falling back to normal clone\n')) | |||
|
1544 | else: | |||
|
1545 | raise error.Abort(_('error applying bundle'), | |||
|
1546 | hint=_('consider contacting the server ' | |||
|
1547 | 'operator if this error persists')) | |||
|
1548 | ||||
|
1549 | def parseclonebundlesmanifest(s): | |||
|
1550 | """Parses the raw text of a clone bundles manifest. | |||
|
1551 | ||||
|
1552 | Returns a list of dicts. The dicts have a ``URL`` key corresponding | |||
|
1553 | to the URL and other keys are the attributes for the entry. | |||
|
1554 | """ | |||
|
1555 | m = [] | |||
|
1556 | for line in s.splitlines(): | |||
|
1557 | fields = line.split() | |||
|
1558 | if not fields: | |||
|
1559 | continue | |||
|
1560 | attrs = {'URL': fields[0]} | |||
|
1561 | for rawattr in fields[1:]: | |||
|
1562 | key, value = rawattr.split('=', 1) | |||
|
1563 | attrs[urllib.unquote(key)] = urllib.unquote(value) | |||
|
1564 | ||||
|
1565 | m.append(attrs) | |||
|
1566 | ||||
|
1567 | return m | |||
|
1568 | ||||
|
1569 | def trypullbundlefromurl(ui, repo, url): | |||
|
1570 | """Attempt to apply a bundle from a URL.""" | |||
|
1571 | lock = repo.lock() | |||
|
1572 | try: | |||
|
1573 | tr = repo.transaction('bundleurl') | |||
|
1574 | try: | |||
|
1575 | try: | |||
|
1576 | fh = urlmod.open(ui, url) | |||
|
1577 | cg = readbundle(ui, fh, 'stream') | |||
|
1578 | changegroup.addchangegroup(repo, cg, 'clonebundles', url) | |||
|
1579 | tr.close() | |||
|
1580 | return True | |||
|
1581 | except urllib2.HTTPError as e: | |||
|
1582 | ui.warn(_('HTTP error fetching bundle: %s\n') % str(e)) | |||
|
1583 | except urllib2.URLError as e: | |||
|
1584 | ui.warn(_('error fetching bundle: %s\n') % e.reason) | |||
|
1585 | ||||
|
1586 | return False | |||
|
1587 | finally: | |||
|
1588 | tr.release() | |||
|
1589 | finally: | |||
|
1590 | lock.release() |
@@ -1412,6 +1412,21 User interface controls. | |||||
1412 | default ``USER@HOST`` is used instead. |
|
1412 | default ``USER@HOST`` is used instead. | |
1413 | (default: False) |
|
1413 | (default: False) | |
1414 |
|
1414 | |||
|
1415 | ``clonebundlefallback`` | |||
|
1416 | Whether failure to apply an advertised "clone bundle" from a server | |||
|
1417 | should result in fallback to a regular clone. | |||
|
1418 | ||||
|
1419 | This is disabled by default because servers advertising "clone | |||
|
1420 | bundles" often do so to reduce server load. If advertised bundles | |||
|
1421 | start mass failing and clients automatically fall back to a regular | |||
|
1422 | clone, this would add significant and unexpected load to the server | |||
|
1423 | since the server is expecting clone operations to be offloaded to | |||
|
1424 | pre-generated bundles. Failing fast (the default behavior) ensures | |||
|
1425 | clients don't overwhelm the server when "clone bundle" application | |||
|
1426 | fails. | |||
|
1427 | ||||
|
1428 | (default: False) | |||
|
1429 | ||||
1415 | ``commitsubrepos`` |
|
1430 | ``commitsubrepos`` | |
1416 | Whether to commit modified subrepositories when committing the |
|
1431 | Whether to commit modified subrepositories when committing the | |
1417 | parent repository. If False and one subrepository has uncommitted |
|
1432 | parent repository. If False and one subrepository has uncommitted |
@@ -249,6 +249,8 Test extension help: | |||||
249 | bugzilla hooks for integrating with the Bugzilla bug tracker |
|
249 | bugzilla hooks for integrating with the Bugzilla bug tracker | |
250 | censor erase file content at a given revision |
|
250 | censor erase file content at a given revision | |
251 | churn command to display statistics about repository history |
|
251 | churn command to display statistics about repository history | |
|
252 | clonebundles server side extension to advertise pre-generated bundles to | |||
|
253 | seed clones. | |||
252 | color colorize output from some commands |
|
254 | color colorize output from some commands | |
253 | convert import revisions from foreign VCS repositories into |
|
255 | convert import revisions from foreign VCS repositories into | |
254 | Mercurial |
|
256 | Mercurial | |
@@ -1069,6 +1071,8 Test keyword search help | |||||
1069 |
|
1071 | |||
1070 | Extensions: |
|
1072 | Extensions: | |
1071 |
|
1073 | |||
|
1074 | clonebundles server side extension to advertise pre-generated bundles to seed | |||
|
1075 | clones. | |||
1072 | prefixedname matched against word "clone" |
|
1076 | prefixedname matched against word "clone" | |
1073 | relink recreates hardlinks between repository clones |
|
1077 | relink recreates hardlinks between repository clones | |
1074 |
|
1078 |
General Comments 0
You need to be logged in to leave comments.
Login now