A sync request carries a sample of commands the requester holds. The responder treats each sample command it holds, and all of that command’s ancestors, as held by the requester, and sends the lowest commands not covered.
This document specifies a sample that spreads a fixed budget across
the graph instead of packing it at the tip. It replaces the phase 3
walk in SyncRequester::get_commands.
The sample is a fixed-capacity list of COMMAND_SAMPLE_MAX addresses
(100, or 20 with low-mem-usage), filled in three phases:
PEER_HEAD_MAX (10) committed commands
the responder is known to hold.The responder needs to locate, on each of its branches, the highest command the requester holds. A dense window at the tip only does that when the responder’s frontier falls inside the window.
When the requester is ahead or has diverged by more than 100 segments,
no sample command above the cache exists in the responder’s graph. The
responder sends everything above the highest cache head, at most
COMMAND_RESPONSE_MAX (100) commands per reply. Each reply’s commands
are ones the requester already holds. They enter the requester’s peer
cache, so the next round resumes 100 commands higher. Convergence
takes one round per 100 commands of the responder’s graph.
The design is chosen for its worst case. Most syncs are between peers that synced recently, and any sample does well there.
This section describes what each part does and why, without the data structures used to compute it. Implementation covers those.
The responder treats a command as held by the requester when it is in the sample or peer cache, or is an ancestor of one. The responder adds each sample command it holds to its peer cache for the requester, except session heads. It never adds commands it sent. It learns only from samples it receives, since it cannot know whether a reply arrived.
Session heads are commands the requester has not committed, and it loses them if its transaction ends without committing. The responder uses them for the request that carries them but does not keep them. The requester sends them again in every request of the transaction, ahead of any other head, so keeping them would save nothing.
Keeping earlier knowledge is what lets a sample that cannot hold every head still converge. Each request sends a different random subset of heads, and the responder’s picture of the requester grows until it covers everything.
A peer cache holds at most PEER_HEAD_MAX (100) heads. When a new
head would exceed that, a random existing head is evicted. Eviction
only loses knowledge, so the responder may resend that head’s branch.
With more than 100 live branches the cache can thrash, evicting heads
nearly as fast as it learns them. Sync still converges, since the
random choices mean each round has some chance of progress, but it
can take many more rounds.
Reset. That knowledge is wrong if the requester loses commands,
for example by losing its storage or restoring a backup. The
requester then sets
reset in its request, and the responder clears its peer cache for
the requester before resolving the sample.
The requester sets reset when any of these holds:
A reset that was not needed only costs one round without the responder’s earlier knowledge. A reset that is lost in transit is sent again, since the requester clears the condition only when a reply arrives.
The sample is filled from four sources:
Session, cache and graph heads together get at most half the slots, in that order. When a source does not fit in the slots left for heads, add a random subset of it that fills them. Random commands get the rest.
The reserved half keeps sync making progress. If the requester has more heads than slots and the responder holds none of them, a sample of only heads resolves nothing, and the responder resends from the root. The random subset means heads left out of one request are sent in a later one.
Random commands are placed at evenly spaced max cuts between the floor and the tallest head, starting at a random offset.
Floor. The responder holds the floor and every command below it, so random commands only go above it. With an empty cache the floor is the root. Otherwise it is the highest command, at or below the lowest cache head, that every graph head passes through on the way to the root. Every cache head descends from it, so the responder holds it, and every lower command is one of its ancestors.
Gap. The span is the tallest graph head’s max cut minus the
floor’s. The gap g is the larger of COMMAND_RESPONSE_MAX and the
span divided by the slots left after the heads. Spacing tighter than
one reply saves less than a reply.
Even spacing keeps every gap at most span / slots, wherever the responder’s frontier is. Geometric spacing from the tip leaves wide gaps deep in the graph, and when the shared history lies below the last random command, nothing resolves and the responder resends all of it.
Offset. Pick r uniformly at random between g/2 and g. Starting at least g/2 below the tallest head avoids a random command that nearly duplicates the head. A random offset means repeated rounds pick different commands.
Positions. The positions are tallest head - r, then down by g, while above the floor. For each position t, pick a random head with max cut at least t and walk back from it to the command at max cut t. At a merge, step to a random parent with max cut at least t. Add the command found, unless already added.
The random choices mean a branch skipped in one round is sampled in a later one.
The requester’s random commands can all miss. Each follows a random branch, and on a graph with long unmerged branches they can all land on branches the responder does not hold. The responder then sends from the root, and every command below the real overlap is one the requester already has.
So the responder also samples. A full reply means the responder stopped at the reply limit, so the requester will send another request. The responder attaches a sample of its own graph, built the same way as the requester’s. A reply that is not full ends the sync, so it carries no sample. A full reply that happens to hold everything carries a sample it did not need, which costs only its size. Random commands that land on commands in the reply are dropped, since the requester will hold them. The sample counts against the reply’s size limit.
The requester processes the reply’s commands, then looks up each sample address in its own graph and adds the ones it holds to its peer cache. They go out as cache heads in its next request, so the next reply starts nearer the overlap.
A send that needs more than one reply already takes several rounds, so the responder’s sample adds no round trip. With both sides sampling, each round trip searches both graphs for the overlap instead of one.
The head set stores the LCA of its heads. For a single head it is the head. Committing more than one head braids them to rebuild the fact cache, and the braid computes their LCA, so the commit saves it with the head set.
The floor is found by walking down from the graph heads’ LCA using skip lists. Let D be the lowest max cut among the cache heads.
fn floor(lca: Command, cache: &PeerCache) -> Command {
let Some(d) = cache.heads().map(max_cut).min() else {
return root();
};
let mut c = lca;
while max_cut(c) > d {
c = match lowest_skip_at_or_above(c, d) {
Some(s) => s,
None if is_merge(c) => merge_lca(c),
None => prior(c),
};
}
c
}
Every command this walk visits is one that every head passes through on the way to the root. The heads’ LCA is one, a merge’s LCA is one for the merge, and skip-list entries are built only from these and single priors. Such a command is the only command at its max cut, so each jump lands where the graph is one command wide, and the walk never needs to search side branches.
Coloring ancestors of the cache heads would find a higher floor in some graphs, but it can walk the whole graph when any uncovered path reaches the root.
fn random_command(
heads: &[Command],
t: MaxCut,
rng: &mut impl Csprng,
) -> Command {
let mut c = random(rng, heads.iter().filter(|h| max_cut(h) >= t));
while max_cut(c) > t {
c = match lowest_skip_at_or_above(c, t) {
Some(s) => s,
None => random(rng, parents(c).filter(|p| max_cut(p) >= t)),
};
}
c
}
Both walks operate on segments. A skip or prior step moves to a segment, and the walk ends in the segment whose range contains the target, taking the command there.
Building a sample reads the head set and the peer cache, walks from
the graph heads’ LCA to the floor in a few skip-list jumps, then takes
a few skip-list jumps per random command, at most
COMMAND_SAMPLE_MAX of them. Random commands span only the region
above the floor, so a responder one or two commands behind gets a
sample of heads only.
skip_jump in responder.rs so the requester can
walk a path to a target max cut. At a merge with no usable skip it
picks a random parent with max cut at least the target, and it
returns the command at the target rather than a segment at or above
it.PEER_HEAD_MAX to 100. When the peer cache is full, evict a
random head instead of dropping the new one.COMMAND_SAMPLE_MAX, session heads, then
cache heads, then graph heads, choosing a random subset when they
exceed it.HeadSet, saved from the braid on a
multi-head commit and set to the head for a single head.get_commands with the random commands.find_needed_segments.reset flag to the sync request. The requester sets it when
its peer cache for the responder is new, or after a reply from the
responder fails with ClientError::NoSuchParent, and clears the
condition when a reply from that peer arrives. The responder clears
its peer cache for the requester before resolving the sample.