Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

bench/src/have_lookup.rs

//! The **`have` membership test**, which is the one workload shape the
//! oid-index measurements singled out as structurally different.
//!
//! # Why this shape and not the general one
//!
//! Those measured oid → pack offset and recommended against the ragnar
//! `stree`, because a 16-bit fanout is at least as good on hits and a good deal
//! cheaper. The one place the tree was genuinely different was **misses**: it
//! refutes at the 32-bit prefix without touching the oid table at all — 26.8 ns
//! against a 38 MiB table it never reads — where git's binary search runs to
//! exhaustion either way.
//!
//! A fetch's negotiation is exactly that: a batch of oids the server must test
//! for membership, of which a divergent client's are mostly absent. So this
//! module asks the narrow question the wide one left open.
//!
//! # The shape, MEASURED off real fetches rather than assumed
//!
//! 18 of the 19 mirrored `workspace_nordisk` repositories, real `git fetch`es
//! over `file://`, `have`s taken off the wire with `GIT_TRACE_PACKET` and each
//! one asked of the **server** with `git cat-file --batch-check` (2026-08-04):
//!
//! | client shape | rounds | haves | hit rate |
//! |---|---:|---:|---:|
//! | 1 / 10 / 100 / 400 commits behind, no local work | 1 | 16 | **100.0 %** |
//! | 10 behind + 1 local commit | 1 | 16 | 93.8 % |
//! | 10 behind + 10 local | 1 | 16 | 37.5 % |
//! | 10 behind + 40 local | 2 | 16, 32 | 16.7 % |
//! | 10 behind + 200 local | 4 | 16, 32, 64, ~127 | 16.2 % |
//!
//! Two corrections to the sizing this arm was commissioned with, both of which
//! make the ceiling **lower**, not higher:
//!
//!  * **The window is 16 and doubles**, not 32. `fetch-pack.c`'s `INITIAL_FLUSH`
//!    is 16 and `next_flush` doubles it; the measured sequence is 16, 32, 64,
//!    128. The 256 is `MAX_IN_VAIN` — how many *unproductive* haves git tolerates
//!    before giving up — not a window size, and no round of 256 was observed.
//!  * **A protocol-v2 client re-sends its whole `have` list every round**, and
//!    [`gunnar_wire`]'s `HaveMemo` answers each *distinct* have from the object
//!    store exactly **once per connection**. So the number of `ObjectStore::
//!    contains` calls a negotiation costs is the number of *distinct* haves:
//!    **16 in the common case and ~128 at a 200-commit divergence**, not the
//!    4 298 `have` lines that crossed the wire.
//!
//! And the case that dominates real traffic — a client merely *behind* — is
//! **100 % hits in one round of 16**, which is the mix the tree is worst at.
//!
//! # What that bounds before a line of it is measured
//!
//! 16 lookups at the ~310 ns this repository's `.idx` probe costs is **5 µs**,
//! against a fetch `gunnar.stage_fetch` measures in milliseconds. Even 128
//! all-miss lookups is ~40 µs. **A 10× on this probe cannot move a fetch by a
//! part in a thousand**, and the arm is built to show that rather than to hide
//! it: it reports the probe ratio *and* the end-to-end share, and the second one
//! being unmeasurable is the expected, useful result.

use std::sync::atomic::{AtomicU64, Ordering};

use gix_hash::ObjectId;
use gunnar_store::git_storage_trait::{
    Caps, Extent, GcReport, GitOps, GitServe, ObjType, Oid, PackStats, ReachSet, RefCas, RefRow,
    RefUpdate, Stored, TxId,
};

use crate::oid_index::{Fanout16, OidStree32, PackIdx};

/// Which structure answers `contains`.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Answerer {
    /// **gunnar as it ships**: forwarded straight to `gix-odb`, whose own
    /// `.idx` fanout + binary search this decorator does not touch. The only
    /// arm that answers the question "what would changing this be worth in
    /// production"; the other three isolate the *structure* by reading the
    /// `.idx` directly, which is a different and narrower comparison.
    Passthrough,
    /// git's algorithm, reimplemented over the mapped `.idx`: 256-entry fanout
    /// then binary search. The structural baseline.
    Baseline,
    /// A 16-bit fanout over the same sorted oid table, 256 KiB.
    Fanout16,
    /// `znippy_zoomies::stree32` over the top 4 bytes, plus the full-oid check.
    Stree32,
}

impl Answerer {
    pub fn name(self) -> &'static str {
        match self {
            Answerer::Passthrough => "passthrough",
            Answerer::Baseline => "baseline",
            Answerer::Fanout16 => "fanout16",
            Answerer::Stree32 => "stree32",
        }
    }
}

/// Which store method the **negotiation's** have lookups reach.
///
/// # 🔴 THIS SWITCH NO LONGER HAS TWO SIDES, and [`HavePath::Rescan`] is refused
///
/// It was the BEFORE/AFTER switch for the refresh-mode change: `ObjectStore`
/// had **two** membership methods, `contains` (a miss first asks the filesystem
/// whether the store's snapshot has gone stale) and `contains_without_rescan`
/// (a miss is the answer), and the difference between them was one syscall on a
/// path whose answers, wire bytes and call counts are all *defined* not to move.
///
/// The 2026-08-10 demolition replaced `ObjectStore` with [`GitOps`], which has
/// **one** membership method, [`GitOps::has`]. The BEFORE path is not slower
/// here, it is *absent*: there is no second method to call and no filesystem
/// promised to re-scan. `Rescan` is therefore unrepresentable rather than
/// unmeasured.
///
/// So [`IndexedContains::open_with`] **refuses** `Rescan` with an error instead
/// of quietly routing it to the same call as `NoRescan`. That refusal is the
/// whole point. This arm's own design notes describe the failure mode it is
/// avoiding — a switch that "keeps the wire bytes identical, so both obvious
/// checks stay green" — and silently collapsing the two variants would publish a
/// two-column A/B table in which both columns are byte-for-byte the same code
/// path, labelled as if they were not. A missing column is a gap; a duplicated
/// column presented as a comparison is a false finding.
///
/// The variant is kept rather than deleted because the question is still real
/// for an engine with a filesystem under it (the gix arm), and when a
/// stale-snapshot probe re-enters the contract this is where it reattaches.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum HavePath {
    /// [`GitOps::has`]: a miss is the answer. **gunnar as it ships**, and now the
    /// only path the contract can express.
    NoRescan,
    /// The re-scanning miss. **Refused** — see the type header.
    Rescan,
}

impl HavePath {
    pub fn name(self) -> &'static str {
        match self {
            HavePath::NoRescan => "norescan",
            HavePath::Rescan => "rescan",
        }
    }
}

/// An [`ObjectStore`] that answers **`contains` only** from a prebuilt index and
/// forwards everything else.
///
/// This is the end-to-end intervention, and it is deliberately the narrowest one
/// that can exist: the negotiation's membership test is `Graph::contains` ->
/// `ObjectStore::contains` (`graph.rs:286`), so replacing that one method
/// replaces the whole thing under measurement and nothing else. Reads, headers
/// and the pack writer are untouched, which is what makes a difference in the
/// `negotiate` stage attributable.
///
/// # The two preconditions, asserted rather than assumed
///
/// The index is built from the repository's pack `.idx` files, so it can only
/// answer for **packed** objects in **one** pack. A loose object, or a second
/// pack, would make this decorator answer `false` to an object the repository
/// holds — a wrong answer that looks exactly like a fast one. [`open`] refuses
/// both cases rather than measuring them.
pub struct IndexedContains<'a> {
    inner: &'a dyn GitServe,
    idx: PackIdx,
    which: Answerer,
    have_path: HavePath,
    fanout: Option<Fanout16>,
    stree: Option<OidStree32>,
    /// How many [`GitOps::has`] calls **this decorator** answered. The arm asserts
    /// this is non-zero and at least the have count, because a fidelity check
    /// that only compares output is blind to a switch that never fired.
    answered: AtomicU64,
    /// How many of those were present.
    present: AtomicU64,
    /// Calls that arrived on the membership method.
    ///
    /// # This counter is now **equal to `answered` by construction**
    ///
    /// It used to separate the negotiation's lookups (which arrived on
    /// `ObjectStore::contains_without_rescan`) from want-validation and walk
    /// lookups (which arrived on `contains`), and the arm asserted it was at
    /// least the have count so that a decorator which failed to forward the new
    /// method could not report the BEFORE path under the AFTER label.
    ///
    /// [`GitOps`] has one membership method, so the caller's *intent* is no
    /// longer on the wire and the two populations cannot be told apart. The
    /// counter is kept and ticks on every `has`, which keeps the arm's
    /// `as_have >= haves.len()` assertion true — but **the assertion is now
    /// vacuous**, because it is implied by `answered >= haves.len()` rather than
    /// checking anything independent. It is left standing, and said out loud
    /// here, rather than deleted: it costs nothing, and the day the contract
    /// distinguishes the two again it becomes load-bearing without anyone having
    /// to remember to restore it.
    as_have: AtomicU64,
    /// How many were answered **by the named structure itself**, counted inside
    /// its own match arm.
    ///
    /// The plain `answered` counter proves a decorator was installed; it does
    /// not prove the decorator used the structure it is named after. A switch
    /// that silently routes the tree's answers back through the binary search
    /// keeps `answered` correct AND keeps the wire bytes identical — both
    /// obvious checks stay green — so the arm asserts this counter separately.
    /// That is the fidelity failure the stage work hit on 2026-08-04: a check
    /// compared wire bytes across a switch that by design does not change them.
    by_structure: AtomicU64,
}

impl<'a> IndexedContains<'a> {
    /// Build over a repository's single pack index, with the negotiation on the
    /// shipping have path.
    pub fn open(
        inner: &'a dyn GitServe,
        git_dir: &std::path::Path,
        which: Answerer,
    ) -> anyhow::Result<Self> {
        Self::open_with(inner, git_dir, which, HavePath::NoRescan)
    }

    /// [`IndexedContains::open`], naming which store method the negotiation's
    /// have lookups reach. See [`HavePath`].
    ///
    /// Refuses [`HavePath::Rescan`]: the contract has one membership method, so
    /// that column would be the `NoRescan` column under a different label.
    pub fn open_with(
        inner: &'a dyn GitServe,
        git_dir: &std::path::Path,
        which: Answerer,
        have_path: HavePath,
    ) -> anyhow::Result<Self> {
        anyhow::ensure!(
            have_path != HavePath::Rescan,
            "HavePath::Rescan cannot be measured: `GitOps` has ONE membership method \
             (`has`), so the re-scanning miss `ObjectStore::contains` performed is absent \
             from the contract rather than merely slower. Routing it here would publish a \
             two-column A/B in which both columns are the same call, which is worse than \
             publishing one column. See `have_lookup::HavePath`."
        );
        let pack_dir = git_dir.join("objects/pack");
        let mut idxs: Vec<std::path::PathBuf> = std::fs::read_dir(&pack_dir)?
            .filter_map(|e| e.ok().map(|e| e.path()))
            .filter(|p| p.extension().is_some_and(|x| x == "idx"))
            .collect();
        idxs.sort();
        anyhow::ensure!(
            idxs.len() == 1,
            "{} holds {} pack indexes. This decorator answers `contains` out of ONE index, so a \
             second pack would make it answer `false` for objects the repository holds — which is \
             a wrong answer shaped exactly like a fast one.",
            git_dir.display(),
            idxs.len()
        );
        let loose = loose_object_count(git_dir);
        anyhow::ensure!(
            loose == 0,
            "{} holds {loose} loose object(s); the same wrong-answer hazard as above",
            git_dir.display()
        );
        let idx = PackIdx::open(&idxs[0])?;
        let (fanout, stree) = match which {
            Answerer::Passthrough | Answerer::Baseline => (None, None),
            Answerer::Fanout16 => (Some(Fanout16::build(&idx)), None),
            Answerer::Stree32 => (None, Some(OidStree32::build(&idx))),
        };
        Ok(Self {
            inner,
            idx,
            which,
            have_path,
            fanout,
            stree,
            answered: AtomicU64::new(0),
            present: AtomicU64::new(0),
            as_have: AtomicU64::new(0),
            by_structure: AtomicU64::new(0),
        })
    }

    /// `(answered, present, answered by the named structure, answered as a
    /// `have`)`.
    pub fn tally(&self) -> (u64, u64, u64, u64) {
        (
            self.answered.load(Ordering::Relaxed),
            self.present.load(Ordering::Relaxed),
            self.by_structure.load(Ordering::Relaxed),
            self.as_have.load(Ordering::Relaxed),
        )
    }

    /// One membership answer, for the contract's single [`GitOps::has`].
    ///
    /// `as_have` ticks unconditionally now; see the field's own note for why it
    /// can no longer distinguish a negotiation lookup from a walk one.
    ///
    /// Returns a `Result` where it used to return a plain `bool`, because
    /// [`GitOps::has`] is fallible where `ObjectStore::contains` was not. The
    /// error is propagated rather than flattened to `false`: an engine that
    /// failed to answer would otherwise be recorded as an engine that answered
    /// "absent", which on the negotiation path means sending objects the client
    /// already has — a correctness change reported as a cache miss.
    fn answer(&self, bytes: Oid<'_>) -> anyhow::Result<bool> {
        self.answered.fetch_add(1, Ordering::Relaxed);
        self.as_have.fetch_add(1, Ordering::Relaxed);
        // `GUNNAR_BENCH_HAVE_RED=noswitch` reroutes the tree's answers through
        // the plain binary search. The counter above still ticks and the wire
        // bytes are still identical, so BOTH of the obvious checks stay green —
        // which is exactly why `by_structure` exists and is asserted separately.
        let sabotage = std::env::var("GUNNAR_BENCH_HAVE_RED").as_deref() == Ok("noswitch");
        let hit = match self.which {
            // The only arm that measures gunnar's own store. `have_path` used to
            // choose between two membership methods here; there is one, and
            // `open_with` has already refused the variant that named the other.
            Answerer::Passthrough => {
                debug_assert_eq!(self.have_path, HavePath::NoRescan);
                self.inner.has(bytes)?
            }
            Answerer::Baseline => self.idx.find(bytes).is_some(),
            Answerer::Fanout16 if !sabotage => {
                self.by_structure.fetch_add(1, Ordering::Relaxed);
                self.fanout
                    .as_ref()
                    .expect("fanout built")
                    .find(&self.idx, bytes)
                    .is_some()
            }
            Answerer::Stree32 if !sabotage => {
                self.by_structure.fetch_add(1, Ordering::Relaxed);
                self.stree
                    .as_ref()
                    .expect("stree built")
                    .find(&self.idx, bytes)
                    .is_some()
            }
            _ => self.idx.find(bytes).is_some(),
        };
        if hit {
            self.present.fetch_add(1, Ordering::Relaxed);
        }
        Ok(hit)
    }

    pub fn which(&self) -> Answerer {
        self.which
    }

    /// The index every candidate shares, for the probe-level arm.
    pub fn idx(&self) -> &PackIdx {
        &self.idx
    }
}

/// Loose objects under `objects/??/`, which the decorator cannot see.
pub fn loose_object_count(git_dir: &std::path::Path) -> usize {
    let objects = git_dir.join("objects");
    let Ok(rd) = std::fs::read_dir(&objects) else {
        return 0;
    };
    let mut n = 0usize;
    for e in rd.flatten() {
        let name = e.file_name();
        let name = name.to_string_lossy();
        if name.len() == 2 && name.chars().all(|c| c.is_ascii_hexdigit()) {
            if let Ok(inner) = std::fs::read_dir(e.path()) {
                n += inner.flatten().count();
            }
        }
    }
    n
}

/// The one intervention — [`GitOps::has`] — and eleven forwards.
///
/// This is still the narrowest decorator that can replace the thing under
/// measurement: the negotiation's membership test is the only method answered
/// from the prebuilt index, and reads, headers and the pack writer are handed
/// straight through, which is what makes a difference in the `negotiate` stage
/// attributable.
impl GitOps for IndexedContains<'_> {
    /// **The intervention.** Answered from the index, never forwarded.
    fn has(&self, oid: Oid<'_>) -> anyhow::Result<bool> {
        self.answer(oid)
    }

    fn put(&self, pack: &[u8], refs: &[RefUpdate]) -> anyhow::Result<TxId> {
        self.inner.put(pack, refs)
    }
    fn put_pack(&self, bytes: &[u8]) -> anyhow::Result<TxId> {
        self.inner.put_pack(bytes)
    }
    fn put_refs(&self, updates: &[RefUpdate]) -> anyhow::Result<TxId> {
        self.inner.put_refs(updates)
    }
    fn get(&self, oid: Oid<'_>) -> anyhow::Result<Option<Stored>> {
        self.inner.get(oid)
    }
    fn size(&self, oid: Oid<'_>) -> anyhow::Result<Option<u64>> {
        self.inner.size(oid)
    }
    fn extents(&self, oids: &[Oid<'_>]) -> anyhow::Result<Vec<Option<Extent>>> {
        self.inner.extents(oids)
    }
    fn refs(&self) -> anyhow::Result<Vec<RefRow>> {
        self.inner.refs()
    }
    fn update_ref(
        &self,
        name: &str,
        old: Option<Oid<'_>>,
        new: Option<Oid<'_>>,
    ) -> anyhow::Result<TxId> {
        self.inner.update_ref(name, old, new)
    }
    fn put_refs_cas(&self, edits: &[RefCas<'_>]) -> anyhow::Result<TxId> {
        self.inner.put_refs_cas(edits)
    }
    fn reachable(&self, want: &[Oid<'_>], have: &[Oid<'_>]) -> anyhow::Result<Vec<Vec<u8>>> {
        self.inner.reachable(want, have)
    }
    fn gc(&self) -> anyhow::Result<GcReport> {
        self.inner.gc()
    }
}

impl GitServe for IndexedContains<'_> {
    fn read(&self, oid: Oid<'_>) -> anyhow::Result<Option<(ObjType, Vec<u8>)>> {
        self.inner.read(oid)
    }
    fn header(&self, oid: Oid<'_>) -> anyhow::Result<Option<(ObjType, u64)>> {
        self.inner.header(oid)
    }
    fn sizes(&self, oids: &[Oid<'_>]) -> anyhow::Result<Vec<Option<u64>>> {
        self.inner.sizes(oids)
    }
    fn head(&self) -> anyhow::Result<Option<RefRow>> {
        self.inner.head()
    }
    fn set_head(&self, target: &str) -> anyhow::Result<TxId> {
        self.inner.set_head(target)
    }
    fn emit_pack(
        &self,
        objects: &[Oid<'_>],
        have: &[Oid<'_>],
        caps: &Caps,
        out: &mut dyn std::io::Write,
    ) -> anyhow::Result<PackStats> {
        self.inner.emit_pack(objects, have, caps, out)
    }
    fn select(&self, want: &[Oid<'_>], have: &[Oid<'_>]) -> anyhow::Result<Option<ReachSet>> {
        self.inner.select(want, have)
    }
}

/// One measured negotiation shape: how many distinct `have`s, and what fraction
/// of them the server holds.
#[derive(Clone, Copy, Debug)]
pub struct HaveShape {
    pub name: &'static str,
    pub haves: usize,
    pub hit_frac: f64,
}

/// The shapes, straight off the measurement in this module's header.
///
/// `behind16` is the one that matters: **every** client that is merely behind,
/// at every depth from 1 to 400 commits, produced exactly this — one round of
/// 16 haves, all present.
pub const SHAPES: &[HaveShape] = &[
    HaveShape {
        name: "behind16",
        haves: 16,
        hit_frac: 1.0,
    },
    HaveShape {
        name: "diverge16",
        haves: 16,
        hit_frac: 0.375,
    },
    HaveShape {
        name: "diverge32",
        haves: 32,
        hit_frac: 0.25,
    },
    HaveShape {
        name: "diverge128",
        haves: 128,
        hit_frac: 0.162,
    },
    // Not observed. `MAX_IN_VAIN` is 256 unproductive haves, so this is the
    // ceiling git's own give-up rule allows, carried as an upper bound and
    // labelled as one.
    HaveShape {
        name: "cap256_unobserved",
        haves: 256,
        hit_frac: 0.0,
    },
];

/// Build one shape's have list out of a repository's real oids.
///
/// Present haves are real oids from the index. Absent ones are a client's own
/// commits, modelled as well-formed oids the server does not hold — and every
/// one is **checked** against the index by the caller, because a "miss" that is
/// really a hit turns the whole measurement into a different one.
pub fn have_list(idx: &PackIdx, shape: &HaveShape, seed: u64) -> Vec<ObjectId> {
    let mut state = seed | 1;
    let mut next = || {
        state = state
            .wrapping_mul(6364136223846793005)
            .wrapping_add(1442695040888963407);
        state ^ (state >> 31)
    };
    let want_hits = (shape.haves as f64 * shape.hit_frac).round() as usize;
    let mut out = Vec::with_capacity(shape.haves);
    for i in 0..shape.haves {
        let src = idx.oid((next() % idx.n as u64) as usize).to_vec();
        // `GUNNAR_BENCH_HAVE_RED=mix` makes the ABSENT haves present while the
        // shape still calls them absent, so the probe-set assertion has to fire.
        // The expected count is deliberately NOT adjusted with it: a red probe
        // that moves the goalposts along with the bug passes, which is how both
        // of `gunnar.oid_index`'s first red switches were blind.
        if i < want_hits || std::env::var("GUNNAR_BENCH_HAVE_RED").as_deref() == Ok("mix") {
            out.push(ObjectId::from_bytes_or_panic(&src));
        } else {
            let mut m = src;
            m[0] ^= 0xff;
            m[1] ^= (i & 0xff) as u8;
            m[idx.hashlen - 1] ^= 0x5a;
            out.push(ObjectId::from_bytes_or_panic(&m));
        }
    }
    out
}

/// What one negotiated fetch cost, with `contains` answered by `which`.
#[derive(Debug, Clone, Copy)]
pub struct FetchCost {
    /// Whole-request CPU, `getrusage(RUSAGE_THREAD)`.
    pub cpu_ns: u64,
    /// Whole-request wall.
    pub wall_ns: u64,
    /// The `negotiate` stage, children subtracted.
    pub negotiate_excl_wall_ns: u64,
    pub negotiate_excl_user_ns: u64,
    /// Every stage's exclusive wall, i.e. the whole request as the table sees it.
    pub all_excl_wall_ns: u64,
    /// `contains` calls the decorator answered, how many were present, how
    /// many the named structure itself answered, and how many arrived on the
    /// negotiation's own method.
    pub answered: u64,
    pub present: u64,
    pub by_structure: u64,
    pub as_have: u64,
    /// **The observable this whole measurement turns on**: how many times a miss
    /// went and asked the filesystem whether this store's snapshot was stale
    /// (one `statx` each), and how many of those paid a full re-scan.
    ///
    /// Taken as a difference across the served request, off
    /// `FsStore::rescan_probes`. Nothing else can witness the change: answers,
    /// wire bytes and call counts are all defined not to move.
    pub rescan_probes: u64,
    pub rescans: u64,
    /// FNV-1a of the whole response, its length and its object count. **The
    /// fidelity check.**
    pub digest: u64,
    pub wire_bytes: u64,
    pub objects_sent: u64,
}

/// Serve one real negotiated fetch out of `repo`, with `contains` answered by
/// `which`, and decompose it.
///
/// Reuses [`crate::stages`]' request builder and instrument installation rather
/// than carrying a second copy of either; the only thing this adds is
/// the decorated store, which is the whole intervention.
///
/// # `repo` and `index_dir` are two different directories, and must be
///
/// [`IndexedContains`] answers `contains` out of a **v2 pack index**, so it
/// needs a git-shaped `objects/pack`. The store the fetch is *served* from is
/// whatever `GUNNAR_ENGINE` selected, and on the znippy arm that has no pack
/// index and no `objects/` at all. Passing one path for both — which is what
/// this did until 2026-08-14 — meant that on znippy the arm served out of a
/// store nobody had written and failed with *"object 9890e3ca… is not in this
/// repository (a want)"*.
///
/// The two are the same directory on a git-shaped engine and the caller may
/// pass it twice. What it may **not** do is let this function invent the second
/// from the first: the pack index is an input to the measurement (its `n` is on
/// the row), and silently taking it from a store that does not have one is how
/// a "0 of 16 haves present" result comes to look like a fast negotiation.
pub fn negotiated_fetch(
    repo: &std::path::Path,
    index_dir: &std::path::Path,
    want: &ObjectId,
    haves: &[ObjectId],
    which: Answerer,
) -> anyhow::Result<FetchCost> {
    negotiated_fetch_with(repo, index_dir, want, haves, which, HavePath::NoRescan)
}

/// [`negotiated_fetch`], naming which store method the negotiation's have
/// lookups reach. See [`HavePath`].
pub fn negotiated_fetch_with(
    repo: &std::path::Path,
    index_dir: &std::path::Path,
    want: &ObjectId,
    haves: &[ObjectId],
    which: Answerer,
    have_path: HavePath,
) -> anyhow::Result<FetchCost> {
    let instruments = crate::stages::install();
    anyhow::ensure!(
        instruments.cpu,
        "no per-thread CPU clock is installed, so every CPU column below would be a confident \
         zero — the one failure mode shaped exactly like a free stage"
    );
    // The hash kind is a property of the HISTORY, so it is read off the
    // git-shaped directory: a znippy store root carries no `config` and
    // `stored_hash_kind` defaults to sha1 when it cannot find one, which would
    // open a sha256 corpus as sha1 with no error anywhere.
    let kind = stored_hash_kind(index_dir)?;
    let store = crate::engine::open_repo(repo, kind)?;
    let git = store.git();
    let decorated = IndexedContains::open_with(git.as_ref(), index_dir, which, have_path)?;

    let format = match kind {
        gix_hash::Kind::Sha1 => "sha1",
        _ => "sha256",
    };
    let request = crate::stages::fetch_request(want, haves, format);
    let upload = gunnar_wire::upload_pack::UploadPack::new(&decorated, kind);
    let mut input = std::io::BufReader::new(std::io::Cursor::new(request));
    let mut out: Vec<u8> = Vec::new();

    gunnar_stage::reset();
    gunnar_stage::enable();
    // **Zero, and it is a real zero rather than a missing measurement.**
    //
    // `rescan_probes` counted how often the packfile engine re-scanned
    // `objects/pack` before answering a `have` miss — git's `OBJECT_INFO_QUICK`
    // question. The Arrow engine has no packfile directory to re-scan, so the
    // syscall this counted cannot happen and the honest count is 0. Left as a
    // field rather than deleted because a second engine is coming back and the
    // comparison is the point.
    let probes0 = (0u64, 0u64);
    let cpu0 = crate::stages::thread_cpu_ns();
    let t0 = std::time::Instant::now();
    let summary = upload.serve_v2(&mut input, &mut out)?;
    let wall = t0.elapsed().as_nanos() as u64;
    let cpu1 = crate::stages::thread_cpu_ns();
    let probes1 = (0u64, 0u64);
    let cpu_ns = (cpu1.0 + cpu1.1).saturating_sub(cpu0.0 + cpu0.1);
    gunnar_stage::disable();
    let report = gunnar_stage::thread_report();

    anyhow::ensure!(
        !report.is_empty(),
        "the serve path recorded NO stages; `gunnar-wire`'s `stages` feature is off and the \
         table would be empty rather than wrong"
    );
    let neg = report.get("negotiate").copied().unwrap_or_default();
    anyhow::ensure!(
        neg.calls > 0,
        "the `negotiate` stage was never entered, so this fetch negotiated nothing and the \
         share it is about to report is a share of a stage that did not run"
    );
    let all: u64 = report.values().map(|t| t.excl_wall_ns).sum();

    let mut digest: u64 = 0xcbf2_9ce4_8422_2325;
    for byte in &out {
        digest ^= u64::from(*byte);
        digest = digest.wrapping_mul(0x0000_0100_0000_01b3);
    }
    let (answered, present, by_structure, as_have) = decorated.tally();
    Ok(FetchCost {
        cpu_ns,
        wall_ns: wall,
        negotiate_excl_wall_ns: neg.excl_wall_ns,
        negotiate_excl_user_ns: neg.excl_user_ns,
        all_excl_wall_ns: all,
        answered,
        present,
        by_structure,
        as_have,
        rescan_probes: probes1.0.saturating_sub(probes0.0),
        rescans: probes1.1.saturating_sub(probes0.1),
        digest,
        wire_bytes: out.len() as u64,
        objects_sent: u64::from(summary.objects_sent),
    })
}

/// A [`GitServe`] that forwards **everything** and counts the membership test.
///
/// [`IndexedContains`] does the same counting, but it opens a pack index and
/// refuses a repository with a second pack or a loose object, because its other
/// three answerers read that index. Those preconditions are vacuous for
/// [`Answerer::Passthrough`] and they exclude most real repositories, so a sweep
/// over the corpus needs this instead: no index, no preconditions, everything
/// handed straight through.
///
/// The forwarding block is not duplicated logic — it is what a trait costs.
///
/// # It no longer *routes*, and the name is now half a lie
///
/// It was "routed" because it chose which of `ObjectStore`'s two membership
/// methods a have reached. [`GitOps`] has one, so the choice is gone; what
/// survives is the counter, which is still worth having because it is the only
/// evidence that the decorator was installed and reached at all. The type is
/// kept under its own name so that the corpus sweep keeps compiling and so that
/// the reattachment point is obvious; [`HaveRouted::new`] refuses
/// [`HavePath::Rescan`] for the same reason [`IndexedContains::open_with`] does.
pub struct HaveRouted<'a> {
    inner: &'a dyn GitServe,
    #[allow(dead_code)]
    path: HavePath,
    /// How many calls arrived on the membership method. Asserted by the caller:
    /// this decorator is defined to change no byte, so the only evidence that it
    /// was installed at all is that it was reached.
    as_have: AtomicU64,
}

impl<'a> HaveRouted<'a> {
    /// Refuses [`HavePath::Rescan`]; see [`HavePath`].
    pub fn new(inner: &'a dyn GitServe, path: HavePath) -> anyhow::Result<Self> {
        anyhow::ensure!(
            path != HavePath::Rescan,
            "HavePath::Rescan cannot be measured: `GitOps` has ONE membership method. \
             See `have_lookup::HavePath`."
        );
        Ok(HaveRouted {
            inner,
            path,
            as_have: AtomicU64::new(0),
        })
    }
    pub fn as_have(&self) -> u64 {
        self.as_have.load(Ordering::Relaxed)
    }
}

impl GitOps for HaveRouted<'_> {
    /// Counted, then forwarded unchanged.
    fn has(&self, oid: Oid<'_>) -> anyhow::Result<bool> {
        self.as_have.fetch_add(1, Ordering::Relaxed);
        self.inner.has(oid)
    }

    fn put(&self, pack: &[u8], refs: &[RefUpdate]) -> anyhow::Result<TxId> {
        self.inner.put(pack, refs)
    }
    fn put_pack(&self, bytes: &[u8]) -> anyhow::Result<TxId> {
        self.inner.put_pack(bytes)
    }
    fn put_refs(&self, updates: &[RefUpdate]) -> anyhow::Result<TxId> {
        self.inner.put_refs(updates)
    }
    fn get(&self, oid: Oid<'_>) -> anyhow::Result<Option<Stored>> {
        self.inner.get(oid)
    }
    fn size(&self, oid: Oid<'_>) -> anyhow::Result<Option<u64>> {
        self.inner.size(oid)
    }
    fn extents(&self, oids: &[Oid<'_>]) -> anyhow::Result<Vec<Option<Extent>>> {
        self.inner.extents(oids)
    }
    fn refs(&self) -> anyhow::Result<Vec<RefRow>> {
        self.inner.refs()
    }
    fn update_ref(
        &self,
        name: &str,
        old: Option<Oid<'_>>,
        new: Option<Oid<'_>>,
    ) -> anyhow::Result<TxId> {
        self.inner.update_ref(name, old, new)
    }
    fn put_refs_cas(&self, edits: &[RefCas<'_>]) -> anyhow::Result<TxId> {
        self.inner.put_refs_cas(edits)
    }
    fn reachable(&self, want: &[Oid<'_>], have: &[Oid<'_>]) -> anyhow::Result<Vec<Vec<u8>>> {
        self.inner.reachable(want, have)
    }
    fn gc(&self) -> anyhow::Result<GcReport> {
        self.inner.gc()
    }
}

impl GitServe for HaveRouted<'_> {
    fn read(&self, oid: Oid<'_>) -> anyhow::Result<Option<(ObjType, Vec<u8>)>> {
        self.inner.read(oid)
    }
    fn header(&self, oid: Oid<'_>) -> anyhow::Result<Option<(ObjType, u64)>> {
        self.inner.header(oid)
    }
    fn sizes(&self, oids: &[Oid<'_>]) -> anyhow::Result<Vec<Option<u64>>> {
        self.inner.sizes(oids)
    }
    fn head(&self) -> anyhow::Result<Option<RefRow>> {
        self.inner.head()
    }
    fn set_head(&self, target: &str) -> anyhow::Result<TxId> {
        self.inner.set_head(target)
    }
    fn emit_pack(
        &self,
        objects: &[Oid<'_>],
        have: &[Oid<'_>],
        caps: &Caps,
        out: &mut dyn std::io::Write,
    ) -> anyhow::Result<PackStats> {
        self.inner.emit_pack(objects, have, caps, out)
    }
    fn select(&self, want: &[Oid<'_>], have: &[Oid<'_>]) -> anyhow::Result<Option<ReachSet>> {
        self.inner.select(want, have)
    }
}

/// The repository's hash algorithm, read from its own config.
///
/// `gunnar_store::fs::stored_hash_kind` read it off the packfile engine's
/// on-disk layout and went with that engine. A repository served under the wrong
/// hash is not a degraded repository, it is a different one, so this asks the
/// repository rather than defaulting.
fn stored_hash_kind(repo: &std::path::Path) -> anyhow::Result<gix_hash::Kind> {
    let config = std::fs::read_to_string(repo.join("config")).unwrap_or_default();
    Ok(if config.to_ascii_lowercase().contains("sha256") {
        gix_hash::Kind::Sha256
    } else {
        gix_hash::Kind::Sha1
    })
}