setdiscovery.py
257 lines
| 9.5 KiB
| text/x-python
|
PythonLexer
/ mercurial / setdiscovery.py
|
r14164 | # setdiscovery.py - improved discovery of common nodeset for mercurial | ||
# | ||||
# Copyright 2010 Benoit Boissinot <bboissin@gmail.com> | ||||
# and Peter Arrenbrecht <peter@arrenbrecht.ch> | ||||
# | ||||
# This software may be used and distributed according to the terms of the | ||||
# GNU General Public License version 2 or any later version. | ||||
|
r20656 | """ | ||
Algorithm works in the following way. You have two repository: local and | ||||
remote. They both contains a DAG of changelists. | ||||
The goal of the discovery protocol is to find one set of node *common*, | ||||
the set of nodes shared by local and remote. | ||||
One of the issue with the original protocol was latency, it could | ||||
potentially require lots of roundtrips to discover that the local repo was a | ||||
subset of remote (which is a very common case, you usually have few changes | ||||
compared to upstream, while upstream probably had lots of development). | ||||
The new protocol only requires one interface for the remote repo: `known()`, | ||||
which given a set of changelists tells you if they are present in the DAG. | ||||
The algorithm then works as follow: | ||||
- We will be using three sets, `common`, `missing`, `unknown`. Originally | ||||
all nodes are in `unknown`. | ||||
- Take a sample from `unknown`, call `remote.known(sample)` | ||||
- For each node that remote knows, move it and all its ancestors to `common` | ||||
- For each node that remote doesn't know, move it and all its descendants | ||||
to `missing` | ||||
- Iterate until `unknown` is empty | ||||
There are a couple optimizations, first is instead of starting with a random | ||||
sample of missing, start by sending all heads, in the case where the local | ||||
repo is a subset, you computed the answer in one round trip. | ||||
Then you can do something similar to the bisecting strategy used when | ||||
finding faulty changesets. Instead of random samples, you can try picking | ||||
nodes that will maximize the number of nodes that will be | ||||
classified with it (since all ancestors or descendants will be marked as well). | ||||
""" | ||||
|
r14164 | |||
|
r23343 | from node import nullid, nullrev | ||
|
r14164 | from i18n import _ | ||
|
r20034 | import random | ||
import util, dagutil | ||||
|
r14164 | |||
def _updatesample(dag, nodes, sample, always, quicksamplesize=0): | ||||
|
r23809 | """update an existing sample to match the expected size | ||
The sample is updated with nodes exponentially distant from each head of the | ||||
<nodes> set. (H~1, H~2, H~4, H~8, etc). | ||||
If a target size is specified, the sampling will stop once this size is | ||||
reached. Otherwise sampling will happen until roots of the <nodes> set are | ||||
reached. | ||||
:dag: a dag object from dagutil | ||||
:nodes: set of nodes we want to discover (if None, assume the whole dag) | ||||
:sample: a sample to update | ||||
:always: set of notable nodes that will be part of the sample anyway | ||||
:quicksamplesize: optional target size of the sample""" | ||||
|
r14164 | # if nodes is empty we scan the entire graph | ||
if nodes: | ||||
heads = dag.headsetofconnecteds(nodes) | ||||
else: | ||||
heads = dag.heads() | ||||
dist = {} | ||||
|
r16834 | visit = util.deque(heads) | ||
|
r14164 | seen = set() | ||
factor = 1 | ||||
while visit: | ||||
curr = visit.popleft() | ||||
if curr in seen: | ||||
continue | ||||
d = dist.setdefault(curr, 1) | ||||
if d > factor: | ||||
factor *= 2 | ||||
if d == factor: | ||||
if curr not in always: # need this check for the early exit below | ||||
sample.add(curr) | ||||
if quicksamplesize and (len(sample) >= quicksamplesize): | ||||
return | ||||
seen.add(curr) | ||||
for p in dag.parents(curr): | ||||
if not nodes or p in nodes: | ||||
dist.setdefault(p, d + 1) | ||||
visit.append(p) | ||||
def _setupsample(dag, nodes, size): | ||||
|
r15063 | always = dag.headsetofconnecteds(nodes) | ||
|
r14164 | desiredlen = size - len(always) | ||
if desiredlen <= 0: | ||||
# This could be bad if there are very many heads, all unknown to the | ||||
# server. We're counting on long request support here. | ||||
return always, None, desiredlen | ||||
return always, set(), desiredlen | ||||
|
r23806 | def _takequicksample(dag, nodes, size): | ||
|
r14164 | always, sample, desiredlen = _setupsample(dag, nodes, size) | ||
if sample is None: | ||||
return always | ||||
|
r23806 | _updatesample(dag, None, sample, always, quicksamplesize=desiredlen) | ||
|
r14164 | sample.update(always) | ||
return sample | ||||
def _takefullsample(dag, nodes, size): | ||||
always, sample, desiredlen = _setupsample(dag, nodes, size) | ||||
if sample is None: | ||||
return always | ||||
# update from heads | ||||
_updatesample(dag, nodes, sample, always) | ||||
# update from roots | ||||
_updatesample(dag.inverse(), nodes, sample, always) | ||||
assert sample | ||||
|
r23083 | sample = _limitsample(sample, desiredlen) | ||
if len(sample) < desiredlen: | ||||
|
r14164 | more = desiredlen - len(sample) | ||
sample.update(random.sample(list(nodes - sample - always), more)) | ||||
sample.update(always) | ||||
return sample | ||||
|
r23083 | def _limitsample(sample, desiredlen): | ||
"""return a random subset of sample of at most desiredlen item""" | ||||
if len(sample) > desiredlen: | ||||
sample = set(random.sample(sample, desiredlen)) | ||||
return sample | ||||
|
r14164 | def findcommonheads(ui, local, remote, | ||
initialsamplesize=100, | ||||
fullsamplesize=200, | ||||
abortwhenunrelated=True): | ||||
|
r14206 | '''Return a tuple (common, anyincoming, remoteheads) used to identify | ||
missing nodes from or in remote. | ||||
|
r14164 | ''' | ||
roundtrips = 0 | ||||
cl = local.changelog | ||||
dag = dagutil.revlogdag(cl) | ||||
|
r14624 | # early exit if we know all the specified remote heads already | ||
|
r14164 | ui.debug("query 1; heads\n") | ||
roundtrips += 1 | ||||
|
r14624 | ownheads = dag.heads() | ||
|
r23084 | sample = _limitsample(ownheads, initialsamplesize) | ||
|
r23192 | # indices between sample and externalized version must match | ||
sample = list(sample) | ||||
|
r14624 | if remote.local(): | ||
# stopgap until we have a proper localpeer that supports batch() | ||||
|
r17204 | srvheadhashes = remote.heads() | ||
|
r14624 | yesno = remote.known(dag.externalizeall(sample)) | ||
elif remote.capable('batch'): | ||||
batch = remote.batch() | ||||
srvheadhashesref = batch.heads() | ||||
yesnoref = batch.known(dag.externalizeall(sample)) | ||||
batch.submit() | ||||
srvheadhashes = srvheadhashesref.value | ||||
yesno = yesnoref.value | ||||
else: | ||||
|
r17424 | # compatibility with pre-batch, but post-known remotes during 1.9 | ||
# development | ||||
|
r14624 | srvheadhashes = remote.heads() | ||
sample = [] | ||||
|
r14164 | |||
if cl.tip() == nullid: | ||||
if srvheadhashes != [nullid]: | ||||
return [nullid], True, srvheadhashes | ||||
return [nullid], False, [] | ||||
|
r14206 | # start actual discovery (we note this before the next "if" for | ||
# compatibility reasons) | ||||
|
r14164 | ui.status(_("searching for changes\n")) | ||
srvheads = dag.internalizeall(srvheadhashes, filterunknown=True) | ||||
if len(srvheads) == len(srvheadhashes): | ||||
|
r14833 | ui.debug("all remote heads known locally\n") | ||
|
r14164 | return (srvheadhashes, False, srvheadhashes,) | ||
|
r23191 | if sample and len(ownheads) <= initialsamplesize and util.all(yesno): | ||
|
r15497 | ui.note(_("all local heads known remotely\n")) | ||
|
r14624 | ownheadhashes = dag.externalizeall(ownheads) | ||
return (ownheadhashes, True, srvheadhashes,) | ||||
|
r14164 | # full blown discovery | ||
|
r16683 | # own nodes I know we both know | ||
|
r23343 | # treat remote heads (and maybe own heads) as a first implicit sample | ||
# response | ||||
common = cl.incrementalmissingrevs(srvheads) | ||||
commoninsample = set(n for i, n in enumerate(sample) if yesno[i]) | ||||
common.addbases(commoninsample) | ||||
|
r23746 | # own nodes where I don't know if remote knows them | ||
|
r23343 | undecided = set(common.missingancestors(ownheads)) | ||
|
r16683 | # own nodes I know remote lacks | ||
missing = set() | ||||
|
r14624 | full = False | ||
while undecided: | ||||
|
r14164 | |||
|
r14624 | if sample: | ||
missinginsample = [n for i, n in enumerate(sample) if not yesno[i]] | ||||
missing.update(dag.descendantset(missinginsample, missing)) | ||||
|
r14164 | |||
|
r14624 | undecided.difference_update(missing) | ||
|
r14164 | |||
if not undecided: | ||||
break | ||||
|
r23747 | if full or common.hasbases(): | ||
if full: | ||||
ui.note(_("sampling from both directions\n")) | ||||
else: | ||||
ui.debug("taking initial sample\n") | ||||
|
r23807 | samplefunc = _takefullsample | ||
|
r23130 | targetsize = fullsamplesize | ||
|
r14624 | else: | ||
# use even cheaper initial sample | ||||
ui.debug("taking quick initial sample\n") | ||||
|
r23807 | samplefunc = _takequicksample | ||
|
r23130 | targetsize = initialsamplesize | ||
|
r23808 | if len(undecided) < targetsize: | ||
sample = list(undecided) | ||||
else: | ||||
sample = samplefunc(dag, undecided, targetsize) | ||||
sample = _limitsample(sample, targetsize) | ||||
|
r14164 | |||
roundtrips += 1 | ||||
ui.progress(_('searching'), roundtrips, unit=_('queries')) | ||||
ui.debug("query %i; still undecided: %i, sample size is: %i\n" | ||||
% (roundtrips, len(undecided), len(sample))) | ||||
# indices between sample and externalized version must match | ||||
sample = list(sample) | ||||
yesno = remote.known(dag.externalizeall(sample)) | ||||
|
r14624 | full = True | ||
|
r14164 | |||
|
r23343 | if sample: | ||
commoninsample = set(n for i, n in enumerate(sample) if yesno[i]) | ||||
common.addbases(commoninsample) | ||||
common.removeancestorsfrom(undecided) | ||||
# heads(common) == heads(common.bases) since common represents common.bases | ||||
# and all its ancestors | ||||
result = dag.headsetofconnecteds(common.bases) | ||||
# common.bases can include nullrev, but our contract requires us to not | ||||
# return any heads in that case, so discard that | ||||
result.discard(nullrev) | ||||
|
r14164 | ui.progress(_('searching'), None) | ||
ui.debug("%d total queries\n" % roundtrips) | ||||
if not result and srvheadhashes != [nullid]: | ||||
if abortwhenunrelated: | ||||
raise util.Abort(_("repository is unrelated")) | ||||
else: | ||||
ui.warn(_("warning: repository is unrelated\n")) | ||||
return (set([nullid]), True, srvheadhashes,) | ||||
|
r14981 | anyincoming = (srvheadhashes != [nullid]) | ||
return dag.externalizeall(result), anyincoming, srvheadhashes | ||||