gunnar_wire/negotiate.rs
1//! Negotiation: deciding what the client already has.
2//!
3//! # Why this is the hard part
4//!
5//! A `have`/`want` loop that terminates on the happy path but never on a
6//! divergent history is the classic git-server failure, and it does
7//! not show up against a three-commit fixture: with a linear history the very
8//! first `have` is common and the loop ends on round one. It only appears when
9//! the client's branch has genuinely diverged, so that round after round of
10//! `have`s are all unknown to the server.
11//!
12//! Two things make that safe here, and both are load-bearing:
13//!
14//! * **Termination does not depend on the server saying `ready`.** The client
15//! stops when it runs out of its own commits and sends `done`; the server's
16//! `ready` is only an early exit. So the worst case of a total divergence is
17//! a longer negotiation, never a hang, provided the server keeps answering.
18//! That is why an empty round answers `NAK` rather than nothing.
19//! * **The walk is bounded twice**: by the oldest `have`'s commit date, and by
20//! an object budget. Without the date bound, every round of a divergent fetch
21//! walks to the root of the repository.
22//!
23//! The criterion for `ready` is git's own: *every want can reach at least one
24//! commit the client claims to have*. Not "some want", which stops too early
25//! and sends a pack the client cannot use, and not "the client has a want",
26//! which is a different question entirely.
27//!
28//! # What a *round* costs, which is a separate question from what a walk costs
29//!
30//! Everything above is about one traversal. The thing that actually cost 154
31//! seconds on the `negotiate_divergent` bench arm was none of it.
32//!
33//! A protocol-v2 client re-sends its **whole** `have` list on every
34//! `command=fetch`. So if a round is answered purely from its own arguments,
35//! round *n* redoes the work of rounds 1..n and a negotiation is `O(rounds²)`.
36//! That is invisible in a three-round test and invisible on a wall clock at
37//! fixture size, and it hid three separate redundancies at once: a linear
38//! `Vec` scan to de-duplicate `common`, a reachability walk that read every
39//! commit twice, and a date cutoff re-derived by re-reading every common commit
40//! every round. MEASURED 2026-08-03: **197 s → 17.9 s**, and object reads
41//! **18.9 M → 6.4 M**, with no change to what any round decides.
42//!
43//! [`HaveMemo`] is the answer to the third of those, and its doc explains why a
44//! cache here is not negotiation state. The remaining `O(rounds²)` term is the
45//! walk itself ([`Reachability`] is rebuilt per round), and the measurement says so
46//! rather than pretending the shape is gone.
47
48use std::collections::{HashMap, HashSet};
49use std::sync::Mutex;
50
51use gix_hash::ObjectId;
52use gunnar_store::object;
53
54use crate::graph::{Graph, Reachability};
55use crate::Result;
56
57/// What one round of negotiation concluded.
58#[derive(Debug, Clone, Default, PartialEq, Eq)]
59pub struct Negotiation {
60 /// The `have`s this repository actually holds, in the order they were sent.
61 ///
62 /// **What the pack is computed against**, and therefore complete: this is
63 /// git's `have_obj`, not its `common`.
64 pub common: Vec<ObjectId>,
65 /// Of those, the ones the server did not already know the client held —
66 /// **what goes on the wire as `ACK`**.
67 ///
68 /// # The difference, and what it is worth
69 ///
70 /// A `have` that is common tells the server about that commit *and its
71 /// whole ancestry*: a client does not hold a commit without holding its
72 /// parents. So when the next `have` in the batch is the previous one's
73 /// parent — which is exactly what a client walking its own history back
74 /// from a tip sends — the server already knew, and `ACK`ing it says
75 /// nothing the client did not just say itself.
76 ///
77 /// git has always done this. `upload-pack.c`'s `process_haves` marks every
78 /// common `have`'s parents `THEY_HAVE` and appends to the ACK array only
79 /// when `!we_knew_they_have`. gunnar `ACK`ed the whole of `common`.
80 ///
81 /// MEASURED 2026-08-04, `bench/examples/fetchbytes-probe.rs`, one
82 /// negotiated fetch across the 500-commit `h2h-divergent-2000b-500d`
83 /// divergence, both servers reaching `ready` on the same round with the
84 /// same 1 008 `have`s: stock git sent **1** `ACK`, gunnar sent **508**, and
85 /// gunnar's 508 were a straight parent chain whose head was git's one.
86 /// At 45 payload bytes and 5 of pkt-line header that is **25 400 bytes** —
87 /// against a total server→client stream of 366 818, and against a
88 /// whole-fetch deficit to stock git of 73 875 on that fixture. **A third of
89 /// gunnar's fetch wire-byte loss was `ACK` lines.**
90 ///
91 /// Fewer `ACK`s cannot make a negotiation worse: the client is told nothing
92 /// it did not derive, `ready` is decided from [`Negotiation::common`] which
93 /// is untouched, and the pack is computed from the same set as before.
94 pub ack: Vec<ObjectId>,
95 /// Whether the server has found a good enough cut point to send a pack.
96 pub ready: bool,
97}
98
99/// What one `have` turned out to be.
100#[derive(Debug, Clone, Copy)]
101enum HaveFact {
102 /// This repository does not hold it.
103 Absent,
104 /// It is here. `Some` carries its committer date; `None` means it is not a
105 /// readable commit, which is not an error (a `have` may name a tag, and a
106 /// damaged one names nothing useful) but has no date to bound a walk by.
107 Present(Option<i64>),
108}
109
110/// Everything this connection has already learned about the `have`s offered to
111/// it.
112///
113/// # Why this exists, and why it is only a memo
114///
115/// A protocol-v2 `command=fetch` carries the client's **whole** `have` list
116/// every round, and [`negotiate`] is a pure function of one round's arguments.
117/// So without a memo, round *n* re-asks the object store the same two questions
118/// about every `have` from rounds 1..n: *do you have this?* and *when was it
119/// committed?* That is `O(rounds²)` store work over a negotiation, and it is
120/// paid entirely on `have`s whose answer was settled long ago.
121///
122/// git does not do this. `upload-pack`'s `got_oid()` looks each `have` up once
123/// as it arrives and folds its date into a running `oldest_have`; the value
124/// this memo computes is the same `oldest_have`, kept the same way.
125///
126/// **It is a cache, not negotiation state.** Every entry is a fact about the
127/// object store, which does not change under a connection, so the answers are
128/// identical with the memo cold or warm; there is no round-to-round *decision*
129/// remembered here, and `negotiate` still sees only the `have`s of the round it
130/// was handed. The one race worth naming: if a concurrent push adds an object a
131/// client already offered and was told nothing about, the memo keeps saying
132/// `Absent`, and the server sends that object again. Sending more than
133/// necessary is slow; sending less would be a bug. The failure direction is the
134/// safe one.
135///
136/// **Memory** is one entry per *distinct* `have`, which is client-sized rather
137/// than repository-sized — but it is client-sized **across the whole
138/// connection**, not per round, and that is the part that used to be missing.
139/// `crate::limits` bounds one round; a client that sends a fresh page of
140/// invented `have`s every round stays under that ceiling every time and still
141/// grows this map without end. So the map is capped as well, by
142/// [`HaveMemo::with_capacity`].
143///
144/// A **full memo stops recording, and answers exactly the same**. Every entry
145/// is a fact about the object store, so a miss is re-derived from the store
146/// rather than guessed; the only thing lost past the cap is the speed-up. That
147/// is what makes a cap safe here and would not make it safe on anything holding
148/// a decision.
149#[derive(Debug, Default)]
150pub(crate) struct HaveMemo {
151 state: Mutex<MemoState>,
152 /// How many facts may be remembered, or `None` for no ceiling.
153 capacity: Option<usize>,
154}
155
156/// The two things one lock guards.
157///
158/// One mutex rather than two, because [`HaveMemo::resolve`] needs both in the
159/// same pass and two locks in one function is a lock order waiting to be got
160/// wrong by the next person to add a third.
161#[derive(Debug, Default)]
162struct MemoState {
163 facts: HashMap<ObjectId, HaveFact>,
164 /// Every commit this connection has been told the client holds, **plus
165 /// every parent of one**.
166 ///
167 /// git's `THEY_HAVE` object flag, kept in a set because gunnar reads
168 /// objects rather than owning a struct per object to hang a bit on. It is
169 /// what makes [`Negotiation::ack`] git's ACK list rather than the whole of
170 /// `common`; see that field for what it is worth.
171 ///
172 /// **Not negotiation state, for the same reason `facts` is not.** It only
173 /// ever suppresses an `ACK` for a commit the client itself just named or
174 /// implied, so a cold set produces more `ACK` lines and the identical pack.
175 /// That is why sharing the [`HaveMemo::capacity`] ceiling is safe: past the
176 /// cap the set stops growing, some redundant `ACK`s come back, and nothing
177 /// else changes.
178 implied: HashSet<ObjectId>,
179}
180
181impl HaveMemo {
182 /// A memo that remembers at most `capacity` distinct `have`s.
183 pub(crate) fn with_capacity(capacity: Option<usize>) -> Self {
184 HaveMemo {
185 state: Mutex::new(MemoState::default()),
186 capacity,
187 }
188 }
189}
190
191impl HaveMemo {
192 /// The `have`s this repository holds, deduplicated, in the order sent, plus
193 /// the oldest committer date among them, which bounds the reachability
194 /// walk.
195 ///
196 /// Returns `i64::MIN` for the cutoff when not one common `have` had a
197 /// readable commit date, which is the "do not prune at all" value.
198 ///
199 /// The date is looked up even on a round that will not walk (a `done` or
200 /// `wait-for-done` request returns before [`Reachability`] is built). That
201 /// is one extra read per *distinct* common `have` per connection, and it is
202 /// accepted rather than gated on a flag: `select_objects` peels every one of
203 /// those same commits moments later anyway, so on any fetch that sends a
204 /// pack the read is not extra at all, and a third `HaveFact` state to
205 /// distinguish "not a commit" from "not looked up yet" would buy only the
206 /// fetch that errors out before packing.
207 fn resolve(&self, graph: &Graph<'_>, haves: &[ObjectId]) -> Resolved {
208 let mut guard = self.state.lock().unwrap_or_else(|e| e.into_inner());
209 let state = &mut *guard;
210 let mut common = Vec::new();
211 let mut ack = Vec::new();
212 let mut cutoff = i64::MAX;
213 let mut round: HashSet<ObjectId> = HashSet::with_capacity(haves.len());
214 // One object buffer and one parent list for the whole round, not one
215 // pair per `have`. `Graph::commit_facts_into` records what this was
216 // measured to be worth, which is **not** the 7.6 ms it was reached for:
217 // the parent decode costs that, the allocation behind it does not.
218 let mut buf: Vec<u8> = Vec::new();
219 let mut parents: Vec<ObjectId> = Vec::new();
220 for have in haves {
221 // A `have` repeated inside one round is answered once. The memo
222 // stops the store being re-asked across rounds; this stops `common`
223 // carrying the same id twice within one.
224 if !round.insert(*have) {
225 continue;
226 }
227 // The parents come out of the read the date already needed, so
228 // git's `THEY_HAVE` marking costs this loop nothing it was not
229 // paying. A memo **hit** yields none, and must not: this connection
230 // marked that `have`'s parents when it first read it, and re-reading
231 // the commit to mark them again is the `O(rounds²)` store traffic
232 // the memo exists to remove.
233 // `fresh` says whether `parents` was filled by this iteration. A
234 // memo **hit** fills nothing, and must not: this connection marked
235 // that `have`'s parents when it first read it, and re-reading the
236 // commit to mark them again is the `O(rounds²)` store traffic the
237 // memo exists to remove.
238 let (fact, fresh) = match state.facts.get(have) {
239 Some(fact) => (*fact, false),
240 None => {
241 // `contains_have`, not `contains`: a `have` the server does
242 // not hold is the client naming its OWN commits, which is
243 // the majority outcome here and not a sign that this store's
244 // snapshot has gone stale. See
245 // `ObjectStore::contains_without_rescan` for why a stale
246 // `false` widens a pack and can never invalidate one — the
247 // same safe direction this memo's own doc already records
248 // for a concurrent push.
249 let (fact, fresh) = if graph.contains_have(have) {
250 match graph.commit_into(have, &mut buf, &mut parents) {
251 Ok(at) => (HaveFact::Present(Some(at)), true),
252 // Present but not a readable commit: a tag, or a
253 // damaged object. No date and no ancestry, and git
254 // `ACK`s such a `have` every time for the same
255 // reason — `we_knew_they_have` can only be set by
256 // the commit branch.
257 Err(_) => (HaveFact::Present(None), false),
258 }
259 } else {
260 (HaveFact::Absent, false)
261 };
262 // Past the cap the answer is still computed and still
263 // correct; only the remembering stops.
264 if self.capacity.is_none_or(|cap| state.facts.len() < cap) {
265 state.facts.insert(*have, fact);
266 }
267 (fact, fresh)
268 }
269 };
270 if let HaveFact::Present(committed_at) = fact {
271 common.push(*have);
272 if let Some(at) = committed_at {
273 cutoff = cutoff.min(at);
274 }
275 // `we_knew` is read **before** this `have`'s own parents go in.
276 // A client walking its history back from a tip sends a chain,
277 // and the order is what turns 508 `ACK`s into 1: each link is
278 // implied by the one before it, and only the head is news.
279 let we_knew = state.implied.contains(have);
280 if self.capacity.is_none_or(|cap| state.implied.len() < cap) {
281 state.implied.insert(*have);
282 if fresh {
283 state.implied.extend(parents.iter().copied());
284 }
285 }
286 if !we_knew {
287 ack.push(*have);
288 }
289 }
290 }
291 if cutoff == i64::MAX {
292 cutoff = i64::MIN;
293 }
294 Resolved {
295 common,
296 ack,
297 cutoff,
298 }
299 }
300}
301
302/// What one pass over a round's `have`s worked out.
303struct Resolved {
304 /// Every `have` this repository holds. What the pack is computed against.
305 common: Vec<ObjectId>,
306 /// The subset that was news. What goes on the wire. See
307 /// [`Negotiation::ack`].
308 ack: Vec<ObjectId>,
309 /// The oldest committer date among `common`, or `i64::MIN`.
310 cutoff: i64,
311}
312
313/// Run one round.
314///
315/// `wait_for_done` suppresses `ready` entirely: the client has asked to drive
316/// the negotiation to its own end, and a server that stops early anyway has
317/// accepted a capability it does not honour.
318///
319/// `memo` lives for the connection; see [`HaveMemo`] for why, and for why it
320/// cannot change what this function decides.
321pub(crate) fn negotiate(
322 graph: &Graph<'_>,
323 wants: &[ObjectId],
324 haves: &[ObjectId],
325 wait_for_done: bool,
326 memo: &HaveMemo,
327) -> Result<Negotiation> {
328 // Which `have`s are common, and the oldest commit the client claims, which
329 // bounds how far back a useful cut point can be. Without that bound
330 // a divergent fetch re-walks the whole repository on every round, the exact
331 // shape that turns a slow negotiation into an apparent hang.
332 //
333 // Both answers come from one pass and one memo. The shape this replaced
334 // built `common` with a linear `common.contains(have)` scan per `have` and
335 // then re-read every common commit from the store to re-derive the cutoff,
336 // both of which grow with the round number. MEASURED on the
337 // `negotiate_divergent` arm (2026-08-03, worktree `87524d6`): the
338 // linear scan alone was **81%** of a 197.2-second negotiation, and it cost
339 // that without touching the object store once.
340 let Resolved {
341 common,
342 ack,
343 cutoff,
344 } = memo.resolve(graph, haves);
345
346 if common.is_empty() || wait_for_done {
347 return Ok(Negotiation {
348 common,
349 ack,
350 ready: false,
351 });
352 }
353
354 let mut reach = Reachability::new(graph, &common, cutoff);
355 let mut ready = true;
356 for want in wants {
357 // Only commits take part: a `want` naming a tag is judged by what it
358 // peels to, and one naming a tree or a blob has no ancestry to cut at.
359 let start = match graph.peel(want) {
360 Ok(peeled) if peeled.kind == object::Kind::Commit => peeled.id,
361 Ok(_) => continue,
362 Err(err) => return Err(err),
363 };
364 if !reach.reaches_common(start)? {
365 ready = false;
366 break;
367 }
368 }
369
370 Ok(Negotiation { common, ack, ready })
371}