Skip to main content

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}