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
})
}