Skip to main content

znippy_plugin_git/
arms.rs

1//! **Which implementation of each trait a store is built on** — one selector,
2//! read once, never per operation.
3//!
4//! Three traits in this crate have more than one implementation, every one of
5//! them written and benchmarked (PLAN §7, §8):
6//!
7//! | trait | arms | what varies |
8//! |---|---|---|
9//! | [`ArchiveWrite`] | [`WriterArm::Fast`] / [`Safe`](WriterArm::Safe) / [`Uring`](WriterArm::Uring) | **the durability contract**, and nothing else |
10//! | [`ObjectIndex`](crate::index_layout::ObjectIndex) | [`IndexArm::OneTableFourColumns`] / [`FourTables`](IndexArm::FourTables) / [`PackedPayload`](IndexArm::PackedPayload) | the payload layout behind the same `stree` |
11//! | [`Gc`] | [`GcArm::NewGeneration`] / [`CompactInPlace`](GcArm::CompactInPlace) | whether the old generation survives until the new one is proven |
12//!
13//! Until this module existed, `GitStore` named one of each by hand, so none of
14//! them could be A/B'd through a server or a bench without editing the source.
15//! [`StoreConfig`] is that choice made data.
16//!
17//! # The default is the shipping combination and it did not move
18//!
19//! [`StoreConfig::DEFAULT`] is `SafeWriter` + `ObjectReadStack<OneTableFourColumns>`
20//! + `NewGeneration`, which is exactly what
21//! [`GitStore::open`](crate::git_ops::GitStore::open) built before this module
22//! and is exactly what it builds now.
23//! **`open` and `open_with` do not read the environment at all** — an operator
24//! who exports a variable cannot silently change the durability contract under a
25//! caller that never asked for a selector. Choosing an arm is something a caller
26//! does on purpose, through
27//! [`open_with_arms`](crate::git_ops::GitStore::open_with_arms) or
28//! [`open_from_env`](crate::git_ops::open_from_env).
29//!
30//! # Read once, at construction. Never per operation.
31//!
32//! Every `getenv` this crate performs goes through [`read_env`] and happens
33//! either inside [`StoreConfig::from_env`] (which a store calls **once**, while
34//! it is being opened — the three arms and, since 2026-08-21, the cache ceiling
35//! with them) or behind a process-wide `OnceLock` for the knobs that never vary
36//! between two stores in one process. A `std::env::var` in a copy loop would be
37//! one syscall per object served, and that exact defect was found and fixed in
38//! gunnar the day before this was written. It is not a style rule here, it is
39//! asserted: [`env_reads_here`] counts every read this module makes on the
40//! calling thread, and
41//! `the_selector_is_read_once_at_construction_and_never_per_operation` pushes
42//! and serves a whole pack across an unchanged counter.
43//!
44//! Five reads at construction — three arms, the cache ceiling and the explode
45//! policy — and zero per operation. [`env_reads`] is the same count
46//! process-wide; see [`env_reads_here`] for why the guard asserts on the
47//! thread-local one. The complete roster of keys is [`ALL_ENV`].
48//!
49//! # The names, so an operator can type them
50//!
51//! ```text
52//!   ZNIPPY_GIT_WRITER = fast | safe | uring     (default: safe)
53//!   ZNIPPY_GIT_INDEX  = one-table | four-tables | packed   (default: one-table)
54//!   ZNIPPY_GIT_GC     = new-generation | in-place          (default: new-generation)
55//!
56//!   ZNIPPY_GIT_REDB_CACHE_BYTES = <bytes>                  (default: 67108864)
57//!
58//!   ZNIPPY_GIT_EXPLODE        = off | graph | full          (default: see exploded_arrow.rs)
59//!   ZNIPPY_GIT_BOUNDARY_DELTA = 0 | off | false to disable  (default: on)
60//!   ZNIPPY_GIT_REACH_COMMITS  = <commits>                   (default: 512)
61//!   ZNIPPY_GIT_EMIT_WORKERS   = <workers>, 0 = all cores    (default: 4)
62//! ```
63//!
64//! The first four are per store and are what [`StoreConfig`] holds and prints;
65//! the last four are per process. [`ALL_ENV`] is all eight, and a consumer that
66//! has to carry any of them across a boundary asserts its list against it.
67//!
68//! An unset variable takes the default. A variable set to something else is an
69//! **error**, named and listing what is accepted — a typo that silently fell
70//! back to the default would make an operator believe a measurement came from an
71//! arm that never ran, which is worse than a failed open.
72//!
73//! ⚠ The fourth one is **not an arm**, and it is the only variable here that
74//! [`GitStore::open`](crate::git_ops::GitStore::open) reads. It selects no
75//! implementation and changes no contract — it is a memory ceiling, and redb's
76//! own default for it is 1 GiB *per database*. See [`redb_cache_bytes`].
77
78use std::path::{Path, PathBuf};
79use std::sync::atomic::{AtomicU64, Ordering};
80
81use anyhow::{Result, bail};
82
83use crate::archive_write::{ArchiveWrite, FastWriter, SafeWriter};
84use crate::gc::{CompactInPlace, Gc, NewGeneration};
85
86/// `getenv` calls this module has made since the process started.
87///
88/// The counter behind the read-once law. Every path that reads the environment
89/// in this crate goes through [`read_env`], which bumps it; nothing else does.
90/// A caller — or a guard — can therefore prove that serving N objects cost zero
91/// environment reads, which is the only way to state the law as an assertion
92/// rather than as a comment.
93pub fn env_reads() -> u64 {
94    ENV_READS.load(Ordering::Relaxed)
95}
96
97/// The same count, **for the calling thread only**.
98///
99/// [`env_reads`] is process-wide, which makes it the right number to report and
100/// the wrong number to *assert on*: any other thread opening a store moves it,
101/// so a guard written against it is a race dressed as a test. It was only ever
102/// accidentally deterministic — until 2026-08-10 the sole reader was
103/// [`StoreConfig::from_env`], every caller of which took `ENV_LOCK`, so the
104/// tests that counted were the only tests that counted. That stopped being true
105/// when [`redb_cache_bytes`] joined it: **every** store open reads the
106/// environment now, including [`GitStore::open`](crate::git_ops::GitStore::open),
107/// and most of the suite opens stores without the lock.
108///
109/// So the law is asserted per thread, where a call path actually lives. It is
110/// also the stronger statement: "this thread served 2687 objects and touched the
111/// environment zero times" is what the law says, and it is now true regardless
112/// of what else the process is doing.
113pub fn env_reads_here() -> u64 {
114    ENV_READS_HERE.with(|c| c.get())
115}
116
117static ENV_READS: AtomicU64 = AtomicU64::new(0);
118
119thread_local! {
120    static ENV_READS_HERE: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
121}
122
123/// The one place this crate touches the environment.
124///
125/// `pub(crate)` so the per-process knobs in `delta.rs`, `exploded_arrow.rs`
126/// and `git_ops.rs` read through the same counted door rather than through
127/// their own `std::env::var` — every read this crate makes moves
128/// [`env_reads`], or the read-once law is a comment.
129pub(crate) fn read_env(key: &str) -> Option<String> {
130    ENV_READS.fetch_add(1, Ordering::Relaxed);
131    ENV_READS_HERE.with(|c| c.set(c.get() + 1));
132    match std::env::var(key) {
133        Ok(v) if !v.trim().is_empty() => Some(v.trim().to_string()),
134        _ => None,
135    }
136}
137
138// ── the writer arm ────────────────────────────────────────────────────────────
139
140/// Which [`ArchiveWrite`] the push path appends through.
141///
142/// **The arm changes what `append` promises and nothing else.** All three write
143/// the pushed pack's bytes verbatim, at the same offset, into the same file; a
144/// store built on any of them holds byte-identical payload. What differs is how
145/// much of the write is durable when `append` returns, and that is the whole
146/// axis.
147#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
148pub enum WriterArm {
149    /// [`FastWriter`] — **one `pwrite`, no fsync, no journal.**
150    ///
151    /// `append` returns as soon as the bytes are in the kernel's page cache.
152    /// Stated plainly, because an operator selecting this is choosing it:
153    ///
154    /// * **A machine crash (power loss, panic, a hard reset) after `append`
155    ///   returns loses the bytes.** Not "may lose" — nothing has told the device
156    ///   about them.
157    /// * There is **no journal**, so a store on this arm has no durable record
158    ///   that a pack was ever acked. It therefore has **no crash recovery**: a
159    ///   reopen cannot re-queue an interrupted pack because there is nothing to
160    ///   diff the index against, and pack ordinals restart at 0
161    ///   (`indexer::packs_already_acked`).
162    /// * A *process* crash alone keeps the bytes: page cache survives `exit`.
163    ///   That is the only crash it survives.
164    ///
165    /// It is here because it is the **ceiling the other two are measured
166    /// against** — at 8 KiB it acks in 3.9 µs against `SafeWriter`'s 132 µs
167    /// (`examples/push_path_bench.rs`, oden 2026-08-07) — and because there are
168    /// real workloads whose durability contract is not git's: a rebuildable
169    /// mirror, a bulk import that is re-run on failure, a benchmark. Selecting
170    /// it is not refused and not warned about. It is the operator's call and
171    /// the contract above is what they are choosing.
172    Fast,
173    /// [`SafeWriter`] — blob `fsync`, **then** the journal row, then the
174    /// journal's `fsync`. **The default, and git's contract**: a push that was
175    /// acked survives a crash. Four syscalls per append.
176    #[default]
177    Safe,
178    /// [`UringWriter`](crate::uring_write::UringWriter) — the **same ordering**
179    /// as [`Safe`](WriterArm::Safe), enforced by the kernel through an
180    /// `IOSQE_IO_LINK` chain instead of by the caller blocking between four
181    /// syscalls. One `io_uring_enter` carrying four linked ops.
182    ///
183    /// MEASURED 2026-08-07 (PLAN §8.3): **it did not win** — within noise of
184    /// `SafeWriter` at every pack size, because the cost is the two device
185    /// flushes and not the syscall count. Kept as a selectable arm precisely so
186    /// that finding can be re-run rather than remembered. Linux only.
187    ///
188    /// **What this arm costs that the other two do not: pinned memory, and a
189    /// ceiling on how many stores a process can hold.** Its ring registers a
190    /// staging buffer, and `IORING_REGISTER_BUFFERS` pins pages against
191    /// `RLIMIT_MEMLOCK` — a limit the kernel counts on the `user_struct`, so it
192    /// is shared by every process the uid is running. One writer per store means
193    /// the number of repositories this arm can serve is
194    /// `RLIMIT_MEMLOCK / page`, and `Fast` and `Safe` have no such ceiling
195    /// because they register nothing. It was **~107 stores** on a stock 8 MiB
196    /// limit until 2026-08-14, when the registration was cut from 64 KiB to the
197    /// one page a journal row actually needs; see
198    /// [`uring_write`](crate::uring_write)'s module docs for the arithmetic, the
199    /// measurement and the failure it produced in gunnar's sweep.
200    ///
201    /// It used to differ from `Safe` in one more way, and that is now closed:
202    /// [`UringWriter::create`](crate::uring_write::UringWriter::create) opened
203    /// its journal with `File::create`, so a **reopen truncated the journal**
204    /// where `SafeWriter` appends to it — the durability of `Safe` within a
205    /// process and the crash recovery of `Fast` across one. Both durable arms
206    /// now open the same log the same way, which is what "one journal format,
207    /// two transports" (LAW 5) always claimed of them.
208    Uring,
209}
210
211impl WriterArm {
212    /// Every arm, for a bench that sweeps them.
213    pub const ALL: [WriterArm; 3] = [WriterArm::Fast, WriterArm::Safe, WriterArm::Uring];
214
215    /// The spelling an operator types.
216    pub fn as_str(self) -> &'static str {
217        match self {
218            WriterArm::Fast => "fast",
219            WriterArm::Safe => "safe",
220            WriterArm::Uring => "uring",
221        }
222    }
223
224    /// One line on what this arm's `append` promises. Same string the trait's
225    /// own [`ArchiveWrite::durability`] returns, so a bench row and a config
226    /// dump cannot disagree.
227    pub fn durability(self) -> &'static str {
228        match self {
229            WriterArm::Fast => {
230                "none — page cache only; a machine crash after return loses the bytes, and there \
231                 is no journal, so no crash recovery and no stable pack ordinals"
232            }
233            WriterArm::Safe | WriterArm::Uring => {
234                "full — blob fsynced, then a journal row fsynced; crash after return keeps both"
235            }
236        }
237    }
238
239    /// Whether this arm keeps the durable extent log that
240    /// [`GitStore::open_with_arms`](crate::git_ops::GitStore::open_with_arms)
241    /// derives §13.12's `indexed` bit from.
242    ///
243    /// `None` is [`Fast`](WriterArm::Fast) and it is load-bearing rather than
244    /// cosmetic: with no journal there is no durable record of an ack, so the
245    /// crash-recovery diff has nothing to run against and a reopen re-queues
246    /// nothing. Returning the path of a journal this arm does not write would
247    /// make a reopened store diff against a **stale** log and re-absorb packs
248    /// that a different arm acked.
249    pub fn journal(self, blobs: &Path) -> Option<PathBuf> {
250        match self {
251            WriterArm::Fast => None,
252            WriterArm::Safe | WriterArm::Uring => Some(crate::archive_write::journal_path(blobs)),
253        }
254    }
255
256    /// Build the writer. `blobs` is the file the pushed packs are appended to.
257    pub fn create(self, blobs: &Path) -> Result<Box<dyn ArchiveWrite>> {
258        Ok(match self {
259            WriterArm::Fast => Box::new(FastWriter::create(blobs)?),
260            WriterArm::Safe => Box::new(SafeWriter::create(blobs)?),
261            #[cfg(target_os = "linux")]
262            WriterArm::Uring => Box::new(crate::uring_write::UringWriter::create(blobs)?),
263            #[cfg(not(target_os = "linux"))]
264            WriterArm::Uring => bail!(
265                "the `uring` writer arm is Linux-only — this target has no io_uring, and this \
266                 crate does not substitute a pwrite path under an io_uring name"
267            ),
268        })
269    }
270
271    /// Parse the spelling an operator types. Errors name what is accepted.
272    pub fn parse(s: &str) -> Result<Self> {
273        Ok(match s.trim().to_ascii_lowercase().as_str() {
274            "fast" | "fastwriter" => WriterArm::Fast,
275            "safe" | "safewriter" => WriterArm::Safe,
276            "uring" | "uringwriter" | "io_uring" => WriterArm::Uring,
277            other => bail!(
278                "'{other}' is not a writer arm — expected one of fast, safe, uring \
279                 ({}={other})",
280                ENV_WRITER
281            ),
282        })
283    }
284}
285
286// ── the index arm ─────────────────────────────────────────────────────────────
287
288/// Which payload layout sits behind the read stack's `stree`.
289///
290/// **This one is a *type*, not a value**, and that is deliberate: it is the hot
291/// path. `GitStore` is generic over it
292/// (`GitStore<S = OneTableFourColumns>`), so a caller that knows its arm at
293/// compile time — which includes every existing caller, through the default —
294/// pays no dispatch at all. This enum exists so a *runtime* selector can pick
295/// one; see [`crate::git_ops::open_from_env`], which monomorphises all three and
296/// hands back a `Box<dyn GitOps>`.
297///
298/// MEASURED 2026-08-07 (PLAN §13): a packed 25-byte column beats four columns by
299/// 19–76% on the payload gather but loses column scans by 3.3x–14.4x, and no git
300/// operation does a full-row fetch. That is why `OneTableFourColumns` is the
301/// default — and why the losing arms stay selectable, because that conclusion is
302/// a measurement and measurements get re-run.
303#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
304pub enum IndexArm {
305    /// [`OneTableFourColumns`](crate::index_layout::OneTableFourColumns) — the
306    /// default. One Arrow IPC section, four columns.
307    #[default]
308    OneTableFourColumns,
309    /// [`FourTables`](crate::index_layout::FourTables) — four independent IPC
310    /// sections joined on the ordinal.
311    FourTables,
312    /// [`PackedPayload`](crate::index_layout::PackedPayload) — one 25-byte
313    /// fixed-size-binary column holding the whole row.
314    PackedPayload,
315}
316
317impl IndexArm {
318    pub const ALL: [IndexArm; 3] = [
319        IndexArm::OneTableFourColumns,
320        IndexArm::FourTables,
321        IndexArm::PackedPayload,
322    ];
323
324    /// The spelling an operator types.
325    pub fn as_str(self) -> &'static str {
326        match self {
327            IndexArm::OneTableFourColumns => "one-table",
328            IndexArm::FourTables => "four-tables",
329            IndexArm::PackedPayload => "packed",
330        }
331    }
332
333    /// The name [`ObjectIndex::name`](crate::index_layout::ObjectIndex::name)
334    /// reports for the projection this arm builds. Distinct from
335    /// [`as_str`](IndexArm::as_str) on purpose: one is what an operator types,
336    /// the other is what the built object calls itself, and a guard that
337    /// compares them is comparing the selector against applied output.
338    pub fn projection_name(self) -> &'static str {
339        match self {
340            IndexArm::OneTableFourColumns => "OneTableFourColumns",
341            IndexArm::FourTables => "FourTables",
342            IndexArm::PackedPayload => "PackedPayload",
343        }
344    }
345
346    pub fn parse(s: &str) -> Result<Self> {
347        Ok(match s.trim().to_ascii_lowercase().as_str() {
348            "one-table" | "one_table" | "onetablefourcolumns" | "one" => {
349                IndexArm::OneTableFourColumns
350            }
351            "four-tables" | "four_tables" | "fourtables" | "four" => IndexArm::FourTables,
352            "packed" | "packedpayload" => IndexArm::PackedPayload,
353            other => bail!(
354                "'{other}' is not an index arm — expected one of one-table, four-tables, packed \
355                 ({ENV_INDEX}={other})"
356            ),
357        })
358    }
359}
360
361// ── the gc arm ────────────────────────────────────────────────────────────────
362
363/// Which [`Gc`] [`GitOps::gc`](crate::git_ops::GitOps::gc) runs as its third
364/// step, after reachability and after the dead index rows are dropped.
365#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
366pub enum GcArm {
367    /// [`NewGeneration`] — the default. Hard-link, compact the link, **verify
368    /// the result by reading every entry back**, rename to `x.gN.znippy`,
369    /// unlink the old generation last. A death at any point leaves at least one
370    /// complete readable archive.
371    #[default]
372    NewGeneration,
373    /// [`CompactInPlace`] — base znippy's `compact_archive` against the archive
374    /// under its own name: stage beside it, rename over it. Verification is
375    /// **post-hoc**: by the time there is something to read back, the original
376    /// is already gone. One archive on disk instead of two, and no generation
377    /// suffix — which is the reason to pick it — at the price of that window.
378    CompactInPlace,
379}
380
381impl GcArm {
382    pub const ALL: [GcArm; 2] = [GcArm::NewGeneration, GcArm::CompactInPlace];
383
384    pub fn as_str(self) -> &'static str {
385        match self {
386            GcArm::NewGeneration => "new-generation",
387            GcArm::CompactInPlace => "in-place",
388        }
389    }
390
391    /// The name the built implementation reports through [`Gc::name`], which is
392    /// also what lands in [`GcReport::strategy`](crate::gc::GcReport::strategy)
393    /// — the applied output a guard reads.
394    pub fn strategy(self) -> &'static str {
395        match self {
396            GcArm::NewGeneration => "NewGeneration",
397            GcArm::CompactInPlace => "CompactInPlace",
398        }
399    }
400
401    /// Build it. `Send + Sync` because a `GitStore` is shared across the
402    /// indexer's threads.
403    pub fn create(self) -> Box<dyn Gc + Send + Sync> {
404        match self {
405            GcArm::NewGeneration => Box::new(NewGeneration::new()),
406            GcArm::CompactInPlace => Box::new(CompactInPlace::new()),
407        }
408    }
409
410    pub fn parse(s: &str) -> Result<Self> {
411        Ok(match s.trim().to_ascii_lowercase().as_str() {
412            "new-generation" | "new_generation" | "newgeneration" | "new" | "generation" => {
413                GcArm::NewGeneration
414            }
415            "in-place" | "in_place" | "inplace" | "compactinplace" | "compact" => {
416                GcArm::CompactInPlace
417            }
418            other => bail!(
419                "'{other}' is not a gc arm — expected one of new-generation, in-place \
420                 ({ENV_GC}={other})"
421            ),
422        })
423    }
424}
425
426// ── the config ────────────────────────────────────────────────────────────────
427
428/// Environment variable naming the [`ArchiveWrite`] arm.
429pub const ENV_WRITER: &str = "ZNIPPY_GIT_WRITER";
430/// Environment variable naming the
431/// [`ObjectIndex`](crate::index_layout::ObjectIndex) arm.
432pub const ENV_INDEX: &str = "ZNIPPY_GIT_INDEX";
433/// Environment variable naming the [`Gc`] arm.
434pub const ENV_GC: &str = "ZNIPPY_GIT_GC";
435/// Environment variable bounding **redb's page cache**, in bytes. See
436/// [`redb_cache_bytes`].
437pub const ENV_REDB_CACHE: &str = "ZNIPPY_GIT_REDB_CACHE_BYTES";
438/// Environment variable switching the **boundary re-delta** off for an A/B
439/// (`0` / `off` / `false`; anything else, or unset, is on). Read once per
440/// process by [`crate::delta::enabled`].
441pub const ENV_BOUNDARY_DELTA: &str = "ZNIPPY_GIT_BOUNDARY_DELTA";
442/// Environment variable naming the **explode policy** of the
443/// `objects.exploded` table (`off` · `graph` · `full`). Read once per store
444/// open by `ExplodePolicy::from_env`.
445pub const ENV_EXPLODE: &str = "ZNIPPY_GIT_EXPLODE";
446/// Environment variable capping the **live reachability walk** in commits. An
447/// operator knob, not an arm — see `live_reach_policy` in `git_ops.rs`. Read
448/// once per process.
449pub const ENV_REACH_COMMITS: &str = "ZNIPPY_GIT_REACH_COMMITS";
450/// Environment variable setting the **phase-1 emit workers per request**. An
451/// operator knob, not an arm — see `emit_workers` in `git_ops.rs`. Read once
452/// per process.
453pub const ENV_EMIT_WORKERS: &str = "ZNIPPY_GIT_EMIT_WORKERS";
454
455/// **Every environment variable this crate reads outside its tests**, in one
456/// place, so a consumer that carries them across a process or container
457/// boundary can assert its list against this one instead of against a count
458/// it remembers.
459///
460/// # Why a list, and why here
461///
462/// gunnar's forge runs `gunnar serve` inside a podman container, and podman
463/// passes nothing implicitly: a variable set on the host reaches the server
464/// only if the forge names it in its crossing list. On 2026-08-11 five separate
465/// wrong conclusions were drawn on one box from knobs that were "set" and never
466/// arrived — the server ran on its default and the probe reported "no effect",
467/// a null result shaped exactly like a real one. The forge's own list was three
468/// entries long against a crate that read four keys, and **nothing went red
469/// when this crate grew a key**, because the only list that looked was the
470/// consumer's.
471///
472/// This is the producer's list. It is the one a consumer asserts against — "my
473/// crossing list plus my named exclusions equals `ALL_ENV`" — so the day this
474/// crate grows a ninth key, the consumer's guard is what says so, and the
475/// failure names the key rather than producing a quiet default.
476///
477/// # It is asserted complete, not trusted
478///
479/// `every_environment_read_in_this_crate_is_named_in_all_env` (in this module's
480/// tests) walks the crate's own source and checks that every `std::env::var`
481/// call outside a `#[cfg(test)]` region names a key on this list — by literal
482/// or by one of the `ENV_*` constants above. Grow a read without growing the
483/// list and that test is what goes red.
484///
485/// Three of the eight are **arms** (they select an implementation and change
486/// what the store promises); the other five are knobs and ceilings that select
487/// nothing. [`StoreConfig`] holds the arms and the cache ceiling, and its
488/// `Display` prints those four; the remaining four are per-process knobs that
489/// never vary between two stores in one process.
490pub const ALL_ENV: &[&str] = &[
491    ENV_WRITER,
492    ENV_INDEX,
493    ENV_GC,
494    ENV_REDB_CACHE,
495    ENV_BOUNDARY_DELTA,
496    ENV_EXPLODE,
497    ENV_REACH_COMMITS,
498    ENV_EMIT_WORKERS,
499];
500
501/// **The page-cache ceiling for one repository's two redb databases**, in bytes.
502///
503/// # Why this exists at all
504///
505/// `redb::Database::create(path)` is `Builder::new().create(path)`, and
506/// `Builder::new` ends with `set_cache_size(1024 * 1024 * 1024)` — redb-2.6.3
507/// `db.rs:1140`. **One GiB, per database, by default**, split 90 % read /
508/// 10 % write by `set_cache_size` (`db.rs:1184`). A store opens two of them, so
509/// the stock ceiling is 2 GiB *per repository*, and a long-lived server holding
510/// N repositories has N times that.
511///
512/// It is a ceiling and not a reservation, so a small repository never noticed.
513/// MEASURED 2026-08-10 (t14s, `h2h-linear-sha1-2048c-1024f-16k`, a 1 078 472 704-byte
514/// `objects.exploded`): a single clone parked **594 MB resident** and kept it for
515/// the life of the process, because the pages a full-file traversal touched all
516/// fit under the ceiling and nothing evicted them.
517///
518/// # How the default was chosen
519///
520/// A B-tree read cache earns its keep on the **interior** nodes, which every
521/// lookup re-touches, and earns nothing on a single sequential pass over the
522/// leaves, which is what a `refold` is. The interior levels of a 4 KiB-page
523/// B-tree over that 1.078 GB table are ~7 MB; 64 MiB holds all of them nine
524/// times over and leaves 6.4 MiB of write cache, which is more than one absorb
525/// batch dirties. Measured against the 1 GiB default on the clone path it costs
526/// nothing and returns most of the resident set — see the sweep in
527/// `agentAA-report.md`.
528///
529/// # This is the one environment read that is not an arm
530///
531/// Every other variable in this module selects an *implementation* and therefore
532/// changes what the store promises, which is why
533/// [`GitStore::open`](crate::git_ops::GitStore::open) deliberately reads none of
534/// them. This one selects nothing: it is a memory ceiling, every arm behaves
535/// identically under any value of it, and the bytes on disk are the same either
536/// way. So it *is* read on the `open` path — an operator who has to cap a
537/// server's footprint cannot be told to use a different constructor.
538///
539/// An unparseable or zero value is an **error**, for the same reason a
540/// misspelled arm is: a silent fallback would let an operator believe a
541/// measurement came from a ceiling that was never applied.
542pub fn redb_cache_bytes() -> Result<usize> {
543    let Some(raw) = read_env(ENV_REDB_CACHE) else {
544        return Ok(DEFAULT_REDB_CACHE_BYTES);
545    };
546    match raw.parse::<usize>() {
547        Ok(0) | Err(_) => bail!(
548            "{ENV_REDB_CACHE}={raw:?} is not a positive byte count; it bounds redb's page \
549             cache per database and the default is {DEFAULT_REDB_CACHE_BYTES}"
550        ),
551        Ok(n) => Ok(n),
552    }
553}
554
555/// 64 MiB. See [`redb_cache_bytes`] for how that number was arrived at.
556pub const DEFAULT_REDB_CACHE_BYTES: usize = 64 * 1024 * 1024;
557
558/// One implementation of each trait, chosen.
559///
560/// A plain `Copy` value with no interior state: it is *read* once, at
561/// construction, and from then on the store holds the built objects rather than
562/// this. Handing it around after that is a description of what was built, not a
563/// switch anything consults.
564#[derive(Debug, Clone, Copy, PartialEq, Eq)]
565pub struct StoreConfig {
566    pub writer: WriterArm,
567    pub index: IndexArm,
568    pub gc: GcArm,
569    /// The redb page-cache ceiling the store's two databases are opened under.
570    /// **Not an arm** — it selects nothing — but it is part of what was built,
571    /// and a `Display` of the config that left it out would have hidden the
572    /// one key that was "falsified" on 2026-08-11 by never reaching the
573    /// container at all. [`StoreConfig::from_env`] fills it from
574    /// [`redb_cache_bytes`]; [`DEFAULT`](Self::DEFAULT) is
575    /// [`DEFAULT_REDB_CACHE_BYTES`].
576    pub redb_cache_bytes: usize,
577}
578
579impl Default for StoreConfig {
580    /// [`StoreConfig::DEFAULT`], spelled out. A derived `Default` would put a
581    /// zero-byte page cache on the shipping combination.
582    fn default() -> Self {
583        Self::DEFAULT
584    }
585}
586
587impl StoreConfig {
588    /// **What shipped before this module existed, and what still ships.**
589    ///
590    /// `SafeWriter` + `ObjectReadStack<OneTableFourColumns>` + `NewGeneration`.
591    /// [`GitStore::open`](crate::git_ops::GitStore::open) builds exactly this
592    /// and consults no environment to do it.
593    pub const DEFAULT: StoreConfig = StoreConfig {
594        writer: WriterArm::Safe,
595        index: IndexArm::OneTableFourColumns,
596        gc: GcArm::NewGeneration,
597        redb_cache_bytes: DEFAULT_REDB_CACHE_BYTES,
598    };
599
600    /// Read the three arms and the cache ceiling. **Four `getenv`s, once, here.**
601    ///
602    /// It is called from a store's constructor and from nowhere on a serving
603    /// path; [`env_reads`] is what makes that assertable rather than asserted-
604    /// by-comment. An unset or empty variable takes the default; a variable set
605    /// to an unknown value is an error rather than a silent fallback, so a typo
606    /// cannot make a measurement look like it came from an arm that never ran.
607    ///
608    /// The ceiling rides along because it is read on the same construction path
609    /// and printed by the same `Display`: a caller that logs `from_env()`'s
610    /// result once at store open has then logged **every** per-store variable
611    /// this crate consults, and `ZNIPPY_GIT_REDB_CACHE_BYTES` is no longer the
612    /// one that can be set on a host, stop at a container boundary, and leave
613    /// no trace of having done so.
614    pub fn from_env() -> Result<Self> {
615        let mut cfg = StoreConfig::DEFAULT;
616        if let Some(v) = read_env(ENV_WRITER) {
617            cfg.writer = WriterArm::parse(&v)?;
618        }
619        if let Some(v) = read_env(ENV_INDEX) {
620            cfg.index = IndexArm::parse(&v)?;
621        }
622        if let Some(v) = read_env(ENV_GC) {
623            cfg.gc = GcArm::parse(&v)?;
624        }
625        cfg.redb_cache_bytes = redb_cache_bytes()?;
626        Ok(cfg)
627    }
628
629    /// Same three arms with a different writer. For a bench that sweeps one axis.
630    pub fn with_writer(mut self, w: WriterArm) -> Self {
631        self.writer = w;
632        self
633    }
634
635    pub fn with_index(mut self, i: IndexArm) -> Self {
636        self.index = i;
637        self
638    }
639
640    pub fn with_gc(mut self, g: GcArm) -> Self {
641        self.gc = g;
642        self
643    }
644
645    /// Same arms, a different redb page-cache ceiling. What
646    /// [`GitStore::open`](crate::git_ops::GitStore::open) uses to put the
647    /// environment's ceiling on the default arms without reading any arm.
648    pub fn with_redb_cache_bytes(mut self, bytes: usize) -> Self {
649        self.redb_cache_bytes = bytes;
650        self
651    }
652}
653
654/// `KEY=value` for every per-store variable, in the spelling an operator
655/// would type — so one log line at store open is a complete, reproducible
656/// statement of what the environment selected, including the ceiling.
657impl std::fmt::Display for StoreConfig {
658    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
659        write!(
660            f,
661            "{}={} {}={} {}={} {}={}",
662            ENV_WRITER,
663            self.writer.as_str(),
664            ENV_INDEX,
665            self.index.as_str(),
666            ENV_GC,
667            self.gc.as_str(),
668            ENV_REDB_CACHE,
669            self.redb_cache_bytes
670        )
671    }
672}
673
674#[cfg(test)]
675mod tests {
676    use super::*;
677    use crate::archive_write::read_journal;
678    use crate::git_ops::{GitOps, GitStore, open_from_env, open_selected};
679    use crate::index_layout::{OneTableFourColumns, PackedPayload};
680    use crate::object::GitHashKind;
681    use crate::store::tests::{real_pack, tmpdir};
682    use std::sync::Mutex;
683
684    fn loadavg() -> String {
685        std::fs::read_to_string("/proc/loadavg")
686            .unwrap_or_default()
687            .split_whitespace()
688            .take(3)
689            .collect::<Vec<_>>()
690            .join(" ")
691    }
692
693    /// The environment is process-global and this crate's tests run in parallel
694    /// threads, so every guard that sets or counts an environment read takes
695    /// this first. Without it [`env_reads`] would be a shared counter two tests
696    /// moved at once, and the read-once guard below would be a coin toss.
697    static ENV_LOCK: Mutex<()> = Mutex::new(());
698
699    /// Run `f` with this module's variables set to `vars`, restoring whatever
700    /// was there before.
701    ///
702    /// `ENV_REDB_CACHE` is cleared along with the three arms even though no test
703    /// sets it: it is read on the same construction path, and a value inherited
704    /// from the surrounding shell would otherwise change the footprint a test
705    /// measures without appearing anywhere in the test.
706    fn with_env<R>(vars: &[(&str, &str)], f: impl FnOnce() -> R) -> R {
707        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
708        let saved: Vec<(String, Option<String>)> = [ENV_WRITER, ENV_INDEX, ENV_GC, ENV_REDB_CACHE]
709            .iter()
710            .map(|k| (k.to_string(), std::env::var(k).ok()))
711            .collect();
712        // SAFETY (edition 2024): the process-wide environment is mutated only
713        // under ENV_LOCK, and no thread this crate spawns reads it.
714        unsafe {
715            for (k, _) in &saved {
716                std::env::remove_var(k);
717            }
718            for (k, v) in vars {
719                std::env::set_var(k, v);
720            }
721        }
722        let out = f();
723        unsafe {
724            for (k, v) in &saved {
725                match v {
726                    Some(v) => std::env::set_var(k, v),
727                    None => std::env::remove_var(k),
728                }
729            }
730        }
731        out
732    }
733
734    /// **The writer arm really is the writer that runs — asserted on the files
735    /// on disk, not on a name.**
736    ///
737    /// The two arms differ in exactly one observable thing and it is a file: a
738    /// durable arm writes an Arrow IPC journal row naming the extent before
739    /// `append` returns, and [`WriterArm::Fast`] writes no journal at all. So
740    /// the same pack is pushed into two stores that differ only in
741    /// [`StoreConfig::writer`], and what is asserted is the **payload byte for
742    /// byte in both** (the arm must not change what is stored) and the
743    /// **journal present in one and absent in the other** (the arm must change
744    /// what is promised).
745    ///
746    /// Seen RED by ignoring the selection in `GitStore::open_with_arms` —
747    /// `arms.writer.create(&blobs)?` → `WriterArm::Safe.create(&blobs)?`, which
748    /// is the hardcoded `SafeWriter::create(&blobs)?` this whole change removes:
749    /// "the fast arm left a journal at
750    /// /tmp/…-arm-writer-fast-…/objects.pack.journal — a writer that was not
751    /// selected ran". Restored.
752    ///
753    /// `writer_name()` is asserted **after** the two directory listings, on
754    /// purpose: with it first the same break failed on a `&'static str`
755    /// (`left: "SafeWriter", right: "FastWriter"`), which proves a label was
756    /// copied and not that a byte moved.
757    ///
758    /// **What it does NOT catch, found by trying.** Making `WriterArm::journal`
759    /// return `Some` for `Fast` as well leaves this guard **green**: no
760    /// `SafeWriter` runs, so no journal file is ever created, and
761    /// `!fast_journal.exists()` still holds. That break is caught one level down
762    /// by [`only_the_arms_that_write_a_journal_name_one`], which asserts on the
763    /// path itself rather than on a file — recorded here rather than quietly
764    /// claimed, because a guard's blind spots are the part worth writing down.
765    #[test]
766    fn each_writer_arm_leaves_its_own_durability_on_disk() {
767        let (pack, rows) = real_pack();
768
769        // ── the fast arm: bytes, and nothing that says they were acked ───────
770        let fast_dir = tmpdir("arm-writer-fast");
771        let fast = GitStore::<OneTableFourColumns>::open_with_arms(
772            &fast_dir,
773            "rickard",
774            GitHashKind::Sha1,
775            StoreConfig::DEFAULT.with_writer(WriterArm::Fast),
776        )
777        .unwrap();
778        let fast_tx = fast.put_pack(&pack).unwrap();
779        let (fo, fl) = fast_tx.extent.unwrap();
780        let fast_blobs = fast_dir.join("objects.pack");
781        let fast_journal = crate::archive_write::journal_path(&fast_blobs);
782        assert_eq!(
783            &std::fs::read(&fast_blobs).unwrap()[fo as usize..(fo + fl) as usize],
784            &pack[..],
785            "the fast arm did not store the pack verbatim — the arm may change the promise, \
786             never the payload"
787        );
788        assert!(
789            !fast_journal.exists(),
790            "the fast arm left a journal at {} — a writer that was not selected ran",
791            fast_journal.display()
792        );
793
794        // ── the safe arm: the same bytes, plus the durable row that claims them
795        let safe_dir = tmpdir("arm-writer-safe");
796        let safe = GitStore::<OneTableFourColumns>::open_with_arms(
797            &safe_dir,
798            "rickard",
799            GitHashKind::Sha1,
800            StoreConfig::DEFAULT.with_writer(WriterArm::Safe),
801        )
802        .unwrap();
803        let safe_tx = safe.put_pack(&pack).unwrap();
804        let (so, sl) = safe_tx.extent.unwrap();
805        let safe_blobs = safe_dir.join("objects.pack");
806        let safe_journal = crate::archive_write::journal_path(&safe_blobs);
807        assert_eq!(
808            &std::fs::read(&safe_blobs).unwrap()[so as usize..(so + sl) as usize],
809            &pack[..],
810            "the safe arm did not store the pack verbatim"
811        );
812        assert!(
813            safe_journal.exists(),
814            "the safe arm wrote no journal — the durable arm did not run"
815        );
816        assert_eq!(
817            read_journal(&safe_journal).unwrap(),
818            vec![(so, sl)],
819            "the journal does not name the extent that was acked"
820        );
821
822        // Both arms store the same bytes at the same offset: the selection
823        // moves the durability contract and nothing else.
824        assert_eq!((fo, fl), (so, sl), "the two arms disagree about the extent");
825        // The labels, asserted **after** the evidence — a name that agreed with
826        // a directory listing that did not would be the wrong thing to fail on.
827        assert_eq!(fast.writer_name(), "FastWriter");
828        assert_eq!(safe.writer_name(), "SafeWriter");
829        fast.wait_indexed();
830        safe.wait_indexed();
831        assert_eq!(fast.object_count(), rows.len());
832        assert_eq!(safe.object_count(), rows.len());
833        eprintln!(
834            "load {}; {} objects: fast arm {} journal row(s), safe arm {} — {}",
835            loadavg(),
836            rows.len(),
837            if fast_journal.exists() { 1 } else { 0 },
838            read_journal(&safe_journal).unwrap().len(),
839            WriterArm::Fast.durability(),
840        );
841    }
842
843    /// **The third writer arm is selectable through a store too, and it lands
844    /// the same durable journal.**
845    ///
846    /// The `fast`/`safe` guard above is the contrast; this one is the coverage.
847    /// It asserts on applied output — the pack verbatim on disk and one journal
848    /// row naming its extent — which is the same pair
849    /// [`each_writer_arm_leaves_its_own_durability_on_disk`] asserts for `safe`,
850    /// because the two durable arms write **one** journal format through two
851    /// transports (LAW 5) and a store must not be able to tell them apart.
852    ///
853    /// A kernel that cannot run the chain is a **failure, not a skip**: the same
854    /// policy `tests/archive_write.rs` already takes, because a silently skipped
855    /// arm is an arm nobody notices stopped working.
856    ///
857    /// Seen RED by pointing the arm at the wrong writer —
858    /// `WriterArm::Uring => Box::new(SafeWriter::create(blobs)?)` in
859    /// `WriterArm::create`: "the uring arm ran a different writer — left:
860    /// \"SafeWriter\", right: \"UringWriter\"". Restored.
861    ///
862    /// **That red lands on a name, and here that is the honest ceiling.** The
863    /// two durable arms are *required* to be indistinguishable on disk — one
864    /// journal format, one encoder, two transports — so there is no applied
865    /// output that separates them, and inventing one would mean breaking LAW 5
866    /// to make a guard feel better. The applied-output assertions above prove
867    /// the chain did the job; the name is the only thing that can say which
868    /// transport did it.
869    #[cfg(target_os = "linux")]
870    #[test]
871    fn the_uring_arm_is_selectable_through_a_store_and_lands_the_same_journal() {
872        let dir = tmpdir("arm-writer-uring");
873        let store = GitStore::<OneTableFourColumns>::open_with_arms(
874            &dir,
875            "rickard",
876            GitHashKind::Sha1,
877            StoreConfig::DEFAULT.with_writer(WriterArm::Uring),
878        )
879        .expect(
880            "the io_uring arm could not be built on this kernel — not skipped, this is a real \
881             failure",
882        );
883        let (pack, oid) = crate::store::tests::one_blob_pack(b"a push down the io_uring chain");
884        let tx = store.put_pack(&pack).unwrap();
885        let (o, l) = tx.extent.unwrap();
886
887        let blobs = dir.join("objects.pack");
888        assert_eq!(
889            &std::fs::read(&blobs).unwrap()[o as usize..(o + l) as usize],
890            &pack[..],
891            "the uring arm did not store the pack verbatim"
892        );
893        assert_eq!(
894            read_journal(&crate::archive_write::journal_path(&blobs)).unwrap(),
895            vec![(o, l)],
896            "the uring arm's journal does not name the extent it acked — the two durable arms \
897             must write one journal format"
898        );
899        assert_eq!(store.writer_name(), "UringWriter", "the uring arm ran a different writer");
900        store.wait_indexed();
901        assert!(store.has(&oid).unwrap(), "the pushed object never reached the index");
902    }
903
904    /// **The gc arm really is the gc that runs — asserted on which files exist
905    /// afterwards.**
906    ///
907    /// [`GcArm::NewGeneration`] renames the compacted archive to `x.g1.znippy`
908    /// and unlinks the original **last**; [`GcArm::CompactInPlace`] renames over
909    /// the original name and produces no generation. Those are two different
910    /// sets of files on disk for the same input, which is what is asserted —
911    /// `GcReport::strategy` is checked too, but it is a label and the
912    /// directory listing is the evidence.
913    ///
914    /// Seen RED by ignoring the selection in `GitStore::open_with_arms` —
915    /// `gc: arms.gc.create()` → `gc: GcArm::NewGeneration.create()`: "the
916    /// in-place arm produced a new generation at
917    /// /tmp/…-arm-gc-in-place-…/repository.g1.znippy — a gc that was not
918    /// selected ran". Restored.
919    ///
920    /// `report.strategy` is asserted **after** the directory listing, on
921    /// purpose: with the strategy check first the same break failed on a
922    /// `&'static str` (`left: "NewGeneration", right: "CompactInPlace"`), which
923    /// is a label agreeing with itself and says nothing about what happened to
924    /// the archive.
925    #[test]
926    fn each_gc_arm_leaves_its_own_generation_on_disk() {
927        for arm in GcArm::ALL {
928            let dir = tmpdir(&format!("arm-gc-{}", arm.as_str()));
929            let store = GitStore::<OneTableFourColumns>::open_with_arms(
930                &dir,
931                "rickard",
932                GitHashKind::Sha1,
933                StoreConfig::DEFAULT.with_gc(arm),
934            )
935            .unwrap();
936            let (pack, _) = real_pack();
937            store.put(&pack, &[]).unwrap();
938            store.absorb_pending().unwrap();
939
940            let root = store
941                .graph_snapshot()
942                .into_iter()
943                .find(|c| c.generation == 1)
944                .expect("a root commit");
945            let root_raw = hex::decode(&root.oid).unwrap();
946            store
947                .update_ref("refs/heads/root", None, Some(&root_raw))
948                .unwrap();
949
950            // A real znippy archive for the compaction step to work on — the
951            // same fixture `store::tests`' own gc guard builds.
952            let files = vec![
953                ("pack-0.pack".to_string(), pack.clone()),
954                ("pack-1.pack".to_string(), pack.clone()),
955            ];
956            znippy_common::create_archive(store.archive_path(), &files, 3).unwrap();
957            let original = store.archive_path().to_path_buf();
958            let generation = crate::gc::next_generation(&original).unwrap();
959
960            let report = store.gc().unwrap();
961            match arm {
962                GcArm::NewGeneration => {
963                    assert_eq!(report.archive, generation);
964                    assert!(
965                        generation.exists(),
966                        "the new-generation arm produced no {}",
967                        generation.display()
968                    );
969                    assert!(
970                        !original.exists(),
971                        "the new-generation arm kept the old generation at {}",
972                        original.display()
973                    );
974                    assert_eq!(report.retired.as_deref(), Some(original.as_path()));
975                    assert!(report.verified, "the new generation was not read back");
976                }
977                GcArm::CompactInPlace => {
978                    assert!(
979                        !generation.exists(),
980                        "the in-place arm produced a new generation at {} — a gc that was not \
981                         selected ran",
982                        generation.display()
983                    );
984                    assert!(
985                        original.exists(),
986                        "the in-place arm removed the archive it compacts into"
987                    );
988                    assert_eq!(report.archive, original);
989                    assert_eq!(report.retired, None);
990                }
991            }
992            // The label, after the evidence.
993            assert_eq!(report.strategy, arm.strategy());
994            assert!(
995                report.bytes_after <= report.bytes_before,
996                "{} grew the archive: {} → {}",
997                arm.strategy(),
998                report.bytes_before,
999                report.bytes_after
1000            );
1001            eprintln!(
1002                "load {}; gc arm {}: {} → {} bytes, archive now {}",
1003                loadavg(),
1004                arm.strategy(),
1005                report.bytes_before,
1006                report.bytes_after,
1007                report.archive.display()
1008            );
1009        }
1010    }
1011
1012    /// **The index arm really is the layout that gets built — asserted on the
1013    /// Arrow bytes it materialised, and on all three agreeing about every
1014    /// object.**
1015    ///
1016    /// A type name would prove nothing here (all three stacks report
1017    /// `ObjectReadStack`, because that is what they are). What distinguishes the
1018    /// arms is the projection they actually built:
1019    /// [`ObjectIndex::ipc_bytes`] is the size of the Arrow IPC payload held
1020    /// resident, and one packed 25-byte column, four columns in one section and
1021    /// four independent sections are three different numbers for the same 2687
1022    /// objects. Pairwise-distinct is the assertion.
1023    ///
1024    /// The other half matters more: the arms are a **layout** choice, so all
1025    /// three must answer identically. Every oid's full row is compared across
1026    /// the three, so an arm that decoded its own payload wrongly fails here
1027    /// rather than in production.
1028    ///
1029    /// Driven through [`open_selected`] — the runtime door — so the guard fails
1030    /// when the *selector* stops selecting rather than only when a layout is
1031    /// broken.
1032    ///
1033    /// Seen RED by pinning the type in `open_selected`, every match arm building
1034    /// `SelectedStore::OneTableFourColumns(GitStore::<OneTableFourColumns>…)`,
1035    /// which is the state this change starts from: "one-table and four-tables
1036    /// materialised the same 145544 IPC bytes — the index arm was not
1037    /// selected". Restored.
1038    ///
1039    /// **`store.arms().index` stayed green under that break**, and that is the
1040    /// reason it is not the assertion: `arms` is the value that was *requested*,
1041    /// so it agrees with the caller no matter which type got built. The byte
1042    /// count is what the store actually did.
1043    #[test]
1044    fn each_index_arm_builds_its_own_projection_and_all_three_agree() {
1045        let (pack, rows) = real_pack();
1046
1047        let mut built = Vec::new();
1048        for arm in IndexArm::ALL {
1049            let store = open_selected(
1050                &tmpdir(&format!("arm-index-{}", arm.as_str())),
1051                "rickard",
1052                GitHashKind::Sha1,
1053                StoreConfig::DEFAULT.with_index(arm),
1054            )
1055            .unwrap();
1056            store.put_pack(&pack).unwrap();
1057            store.wait_indexed();
1058            // A rebuild, so the projection under test — not the redb tail — is
1059            // what answers below.
1060            store.rebuild_projection().unwrap();
1061            assert_eq!(store.arms().index, arm);
1062            // The name the BUILT projection reports, against the name this arm
1063            // says it builds. `arms().index` above is the request echoed back
1064            // and stays green when the selector selects nothing;
1065            // `index_name()` goes through `ObjectReadStack::projection_name`
1066            // to `S::name()`, so it can only answer what was monomorphised.
1067            //
1068            // It is what a server logs to say which layout it is running —
1069            // gunnar's `store.znippy_arms_selected` — and the reason
1070            // `ObjectIndex::name` on the stack itself is no use for that: the
1071            // stack answers "ObjectReadStack" for all three, because that is
1072            // what the stack is.
1073            assert_eq!(
1074                store.index_name(),
1075                arm.projection_name(),
1076                "the {} arm built a projection calling itself {}",
1077                arm.as_str(),
1078                store.index_name()
1079            );
1080            built.push((arm, store));
1081        }
1082
1083        // Applied output: three layouts, three different quantities of Arrow IPC
1084        // materialised for the same objects.
1085        for (i, (a, sa)) in built.iter().enumerate() {
1086            for (b, sb) in &built[i + 1..] {
1087                assert_ne!(
1088                    sa.index_ipc_bytes(),
1089                    sb.index_ipc_bytes(),
1090                    "{} and {} materialised the same {} IPC bytes — the index arm was not selected",
1091                    a.as_str(),
1092                    b.as_str(),
1093                    sa.index_ipc_bytes()
1094                );
1095            }
1096        }
1097
1098        // …and they are three spellings of one answer. `get` goes through the
1099        // whole stack — index row, extent, bytes off disk — so a layout that
1100        // decoded its own payload wrongly cannot agree here by accident.
1101        for r in rows.iter().take(512) {
1102            let first = built[0].1.get(&r.oid).unwrap();
1103            assert!(first.is_some(), "{} is missing from one-table", hex::encode(&r.oid));
1104            for (arm, store) in &built[1..] {
1105                assert_eq!(
1106                    store.get(&r.oid).unwrap(),
1107                    first,
1108                    "{} disagrees with one-table about {}",
1109                    arm.as_str(),
1110                    hex::encode(&r.oid)
1111                );
1112            }
1113        }
1114        eprintln!(
1115            "load {}; {} objects: {}",
1116            loadavg(),
1117            rows.len(),
1118            built
1119                .iter()
1120                .map(|(a, s)| format!("{} {} B", a.as_str(), s.index_ipc_bytes()))
1121                .collect::<Vec<_>>()
1122                .join(", ")
1123        );
1124    }
1125
1126    /// **The default did not move, and no environment variable can move it.**
1127    ///
1128    /// The compatibility half of this change. `GitStore::open` built
1129    /// `SafeWriter` + `ObjectReadStack<OneTableFourColumns>` + `NewGeneration`
1130    /// before the selector existed and must build exactly that after it —
1131    /// including in a process whose environment names all three *other* arms,
1132    /// because a caller that never asked for a selector must not have its
1133    /// durability contract changed by somebody's shell.
1134    ///
1135    /// Asserted on applied output rather than on `arms()`: the journal row that
1136    /// only a durable writer produces, and the generation file that only
1137    /// `NewGeneration` produces.
1138    ///
1139    /// Seen RED by making the old constructor read the environment —
1140    /// `Self::open_with_arms(root, account, hash, StoreConfig::DEFAULT)` →
1141    /// `Self::open_with_arms(root, account, hash, StoreConfig::from_env()?)` in
1142    /// `GitStore::open_with`: "GitStore::open wrote no journal — an environment
1143    /// variable moved the default durability contract". Restored.
1144    ///
1145    /// `arms()` is asserted **after** the journal, for the third time in this
1146    /// module and for the same reason: with it first the break failed on
1147    /// `StoreConfig { writer: Fast, … }` vs `StoreConfig { writer: Safe, … }`,
1148    /// which is the request echoed back rather than the contract that was kept.
1149    #[test]
1150    fn the_default_is_unchanged_and_the_environment_cannot_move_it() {
1151        with_env(
1152            &[
1153                (ENV_WRITER, "fast"),
1154                (ENV_INDEX, "packed"),
1155                (ENV_GC, "in-place"),
1156            ],
1157            || {
1158                let dir = tmpdir("arm-default");
1159                let store = GitStore::open(&dir, "rickard").unwrap();
1160                let (pack, rows) = real_pack();
1161                let tx = store.put(&pack, &[]).unwrap();
1162                let journal = crate::archive_write::journal_path(&dir.join("objects.pack"));
1163                assert!(
1164                    journal.exists(),
1165                    "GitStore::open wrote no journal — an environment variable moved the default \
1166                     durability contract"
1167                );
1168                assert_eq!(read_journal(&journal).unwrap(), vec![tx.extent.unwrap()]);
1169                store.wait_indexed();
1170                assert_eq!(store.object_count(), rows.len());
1171                // The labels, after the evidence.
1172                assert_eq!(
1173                    store.arms(),
1174                    StoreConfig::DEFAULT,
1175                    "GitStore::open did not build the shipping arms"
1176                );
1177                assert_eq!(store.writer_name(), "SafeWriter");
1178
1179                // And the gc arm is still the one that keeps the old generation
1180                // until the new one is proven.
1181                let root = store
1182                    .graph_snapshot()
1183                    .into_iter()
1184                    .find(|c| c.generation == 1)
1185                    .expect("a root commit");
1186                let root_raw = hex::decode(&root.oid).unwrap();
1187                store
1188                    .update_ref("refs/heads/root", None, Some(&root_raw))
1189                    .unwrap();
1190                let files = vec![("pack-0.pack".to_string(), pack.clone())];
1191                znippy_common::create_archive(store.archive_path(), &files, 3).unwrap();
1192                let generation = crate::gc::next_generation(store.archive_path()).unwrap();
1193                let report = store.gc().unwrap();
1194                assert_eq!(report.strategy, "NewGeneration");
1195                assert!(
1196                    generation.exists(),
1197                    "GitStore::open's gc arm is not NewGeneration — the environment moved it"
1198                );
1199            },
1200        );
1201    }
1202
1203    /// **The selector is read once, at construction, and never per operation.**
1204    ///
1205    /// gunnar's own rule, stated in its source: *read once, here, and not per
1206    /// entry — a getenv inside the copy loop would be one syscall per object
1207    /// served*. A per-object `getenv` was a real defect found and fixed there,
1208    /// so this is asserted with a counter rather than trusted to review.
1209    ///
1210    /// [`env_reads`] counts every environment read this crate makes. A whole
1211    /// real pack is then pushed and served — one `put`, 2687 `has`, a batch
1212    /// `extents`, `get`, `size`, `refs` — across a **counter that does not
1213    /// move**.
1214    ///
1215    /// **Five reads at construction since 2026-08-21** (four since 2026-08-10,
1216    /// three before). Three are [`StoreConfig::from_env`]'s arms; the fourth is
1217    /// [`redb_cache_bytes`], which `from_env` now reads alongside them so the
1218    /// ceiling is part of the config a caller logs; the fifth is the explode
1219    /// policy `ExplodedArchive::open` reads through the same counted door.
1220    /// None of the extra two is an arm — they select no implementation — but
1221    /// each is an environment read, so each is counted, and each is read
1222    /// exactly once per store rather than once per database or once per lookup.
1223    ///
1224    /// Seen RED by re-reading the selector on the lookup path — adding
1225    /// `let _ = crate::arms::StoreConfig::from_env()?;` at the top of
1226    /// `GitStore::lookup_one`: "the selector was read **8070** times while
1227    /// serving 2692 operations — it must be read once, at construction; left:
1228    /// 8070, right: 3". Restored.
1229    ///
1230    /// Seen RED a second time, and this one is what keeps the guard from being
1231    /// vacuous: `StoreConfig::from_env` stubbed to `return
1232    /// Ok(StoreConfig::DEFAULT)` before it reads anything gives "opening a store
1233    /// read the environment **0** times, not once per variable". A selector that
1234    /// is never read at all also never moves the counter during serving, so
1235    /// without the construction-time line the guard would pass over a selector
1236    /// that does nothing. Restored.
1237    #[test]
1238    fn the_selector_is_read_once_at_construction_and_never_per_operation() {
1239        with_env(
1240            &[
1241                (ENV_WRITER, "safe"),
1242                (ENV_INDEX, "four-tables"),
1243                (ENV_GC, "in-place"),
1244            ],
1245            || {
1246                let dir = tmpdir("arm-read-once");
1247                let (pack, rows) = real_pack();
1248
1249                // `env_reads_here`, not `env_reads`: the process-wide counter
1250                // is moved by every other test thread that opens a store, and
1251                // since `redb_cache_bytes` joined the readers that is most of
1252                // them. Per thread it is exact.
1253                let before = env_reads_here();
1254                let store = open_from_env(&dir, "rickard", GitHashKind::Sha1).unwrap();
1255                let at_open = env_reads_here();
1256                // Three arms, the redb cache ceiling and the explode policy.
1257                // Once each — a store opens TWO redb databases and still reads
1258                // the ceiling once.
1259                assert_eq!(
1260                    at_open - before,
1261                    5,
1262                    "opening a store read the environment {} times, not once per variable",
1263                    at_open - before
1264                );
1265
1266                // Everything below this line is serving. Nothing here may read
1267                // the environment even once.
1268                let mut ops = 0u64;
1269                store.put(&pack, &[]).unwrap();
1270                ops += 1;
1271                let oids: Vec<&[u8]> = rows.iter().map(|r| r.oid.as_slice()).collect();
1272                for oid in &oids {
1273                    assert!(store.has(oid).unwrap());
1274                    ops += 1;
1275                }
1276                let ext = store.extents(&oids).unwrap();
1277                ops += 1;
1278                assert_eq!(ext.len(), oids.len());
1279                assert!(ext.iter().all(Option::is_some));
1280                assert!(store.get(oids[0]).unwrap().is_some());
1281                assert!(store.size(oids[0]).unwrap().is_some());
1282                store.refs().unwrap();
1283                ops += 3;
1284
1285                let after = env_reads_here();
1286                assert_eq!(
1287                    after, at_open,
1288                    "the selector was read {} times while serving {ops} operations — it must be \
1289                     read once, at construction",
1290                    after - before
1291                );
1292                eprintln!(
1293                    "load {}; {ops} operations over {} objects: {} environment read(s), all of \
1294                     them at construction",
1295                    loadavg(),
1296                    rows.len(),
1297                    at_open - before,
1298                );
1299            },
1300        );
1301    }
1302
1303    /// **A bad value is an error, not a silent default** — through the real
1304    /// front door, so the refusal cannot be a parser test that nothing calls.
1305    #[test]
1306    fn an_unknown_arm_refuses_to_open_rather_than_falling_back() {
1307        with_env(&[(ENV_WRITER, "safest")], || {
1308            let dir = tmpdir("arm-typo");
1309            let e = match open_from_env(&dir, "rickard", GitHashKind::Sha1) {
1310                Ok(_) => panic!("a typo opened a store on the default arm"),
1311                Err(e) => e,
1312            };
1313            let msg = format!("{e:#}");
1314            assert!(
1315                msg.contains("'safest' is not a writer arm") && msg.contains(ENV_WRITER),
1316                "the refusal must name the bad value and the variable it came from: {msg}"
1317            );
1318        });
1319        // …and an empty environment is the shipping default, not an error.
1320        with_env(&[], || {
1321            assert_eq!(StoreConfig::from_env().unwrap(), StoreConfig::DEFAULT);
1322        });
1323    }
1324
1325    /// **The runtime door and the typed door build the same store.**
1326    ///
1327    /// [`open_selected`] exists so a value can choose the index arm, which is a
1328    /// type; the risk it carries is that the dispatched path drifts from the
1329    /// monomorphised one. Asserted on applied output: the same pack pushed
1330    /// through both answers the same for every oid.
1331    #[test]
1332    fn the_selected_store_and_the_typed_store_answer_alike() {
1333        let (pack, rows) = real_pack();
1334        let arms = StoreConfig::DEFAULT.with_index(IndexArm::PackedPayload);
1335
1336        let boxed = open_selected(
1337            &tmpdir("arm-selected"),
1338            "rickard",
1339            GitHashKind::Sha1,
1340            arms,
1341        )
1342        .unwrap();
1343        let typed = GitStore::<PackedPayload>::open_with_arms(
1344            &tmpdir("arm-typed"),
1345            "rickard",
1346            GitHashKind::Sha1,
1347            arms,
1348        )
1349        .unwrap();
1350
1351        boxed.put(&pack, &[]).unwrap();
1352        typed.put(&pack, &[]).unwrap();
1353        for r in rows.iter().take(512) {
1354            let a = boxed.get(&r.oid).unwrap();
1355            let b = typed.get(&r.oid).unwrap();
1356            assert_eq!(a, b, "the boxed and typed stores disagree about {}", hex::encode(&r.oid));
1357            assert!(a.is_some());
1358        }
1359    }
1360
1361    /// Round-trips through every spelling, and a typo is an error rather than a
1362    /// default.
1363    #[test]
1364    fn every_arm_parses_from_the_name_an_operator_types() {
1365        for a in WriterArm::ALL {
1366            assert_eq!(WriterArm::parse(a.as_str()).unwrap(), a);
1367            assert_eq!(WriterArm::parse(&a.as_str().to_uppercase()).unwrap(), a);
1368        }
1369        for a in IndexArm::ALL {
1370            assert_eq!(IndexArm::parse(a.as_str()).unwrap(), a);
1371        }
1372        for a in GcArm::ALL {
1373            assert_eq!(GcArm::parse(a.as_str()).unwrap(), a);
1374        }
1375        // A typo must not become the default: an operator who mistyped `safe`
1376        // and got a measurement labelled `safe` would be reading a lie.
1377        for bad in ["saef", "", "fastwriter2", "none"] {
1378            assert!(
1379                WriterArm::parse(bad).is_err(),
1380                "'{bad}' parsed as a writer arm"
1381            );
1382        }
1383        assert!(IndexArm::parse("one-tabel").is_err());
1384        assert!(GcArm::parse("newgen").is_err());
1385    }
1386
1387    /// **[`ALL_ENV`] is complete, checked against the crate's own source.**
1388    ///
1389    /// Walks every `.rs` under this crate's `src/`, cuts each file at its first
1390    /// `#[cfg(test)]` (the convention here: one tests module, at the bottom),
1391    /// and finds every environment read in what is left — `std::env::var(…)`,
1392    /// `env::var_os(…)` and [`read_env`]`(…)`. The key each one names, whether
1393    /// a string literal or one of the `ENV_*` constants declared in this file,
1394    /// must be on [`ALL_ENV`]. The only `std::env::var` allowed outside that
1395    /// rule is the one inside [`read_env`] itself, which takes its key as a
1396    /// parameter.
1397    ///
1398    /// This is what goes red the day the crate grows a key without naming it —
1399    /// the defect gunnar's forge could not see from its side (it asserted its
1400    /// crossing list against a count it remembered, and the count was wrong
1401    /// for four months of one day). A source scan is the honest shape for it:
1402    /// the keys are string literals and the reads are call sites, and no
1403    /// runtime counter can tell which *names* were read.
1404    ///
1405    /// Seen RED by adding `std::env::var("ZNIPPY_GIT_NEW_KNOB")` to
1406    /// `git_ops.rs` outside its tests: "git_ops.rs reads ZNIPPY_GIT_NEW_KNOB
1407    /// and ALL_ENV does not name it". Restored.
1408    #[test]
1409    fn every_environment_read_in_this_crate_is_named_in_all_env() {
1410        let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
1411        let this_file = std::fs::read_to_string(src.join("arms.rs")).unwrap();
1412
1413        // `pub const ENV_X: &str = "…";` → ENV_X ↦ "…", from this file only.
1414        let mut consts: std::collections::BTreeMap<String, String> = Default::default();
1415        for line in this_file.lines() {
1416            let t = line.trim();
1417            let Some(rest) = t.strip_prefix("pub const ENV_") else { continue };
1418            let Some((name, val)) = rest.split_once(": &str = \"") else { continue };
1419            let val = val.split('"').next().unwrap();
1420            consts.insert(format!("ENV_{name}"), val.to_string());
1421        }
1422        assert!(consts.len() >= 8, "fewer ENV_* constants than expected: {consts:?}");
1423
1424        let mut seen: Vec<(String, String)> = Vec::new();
1425        let mut files = std::fs::read_dir(&src)
1426            .unwrap()
1427            .map(|e| e.unwrap().path())
1428            .filter(|p| p.extension().is_some_and(|e| e == "rs"))
1429            .collect::<Vec<_>>();
1430        files.sort();
1431        assert!(!files.is_empty());
1432        for path in files {
1433            let file = path.file_name().unwrap().to_string_lossy().into_owned();
1434            let text = std::fs::read_to_string(&path).unwrap();
1435            let non_test = match text.find("\n#[cfg(test)]") {
1436                Some(at) => &text[..at],
1437                None => &text[..],
1438            };
1439            for needle in ["env::var(", "env::var_os(", "read_env("] {
1440                let mut from = 0;
1441                while let Some(at) = non_test[from..].find(needle) {
1442                    let call_at = from + at;
1443                    from = call_at + needle.len();
1444                    // The definition of `read_env` itself, and doc/comment lines
1445                    // that merely mention a call, are not reads.
1446                    let line_start = non_test[..call_at].rfind('\n').map_or(0, |i| i + 1);
1447                    let line = non_test[line_start..].lines().next().unwrap_or("").trim_start();
1448                    if line.starts_with("//") || line.starts_with("pub(crate) fn read_env") {
1449                        continue;
1450                    }
1451                    // The `env::var(key)` inside `read_env` — the one door.
1452                    if file == "arms.rs" && needle == "env::var(" && line.contains("var(key)") {
1453                        continue;
1454                    }
1455                    let arg = non_test[from..].trim_start();
1456                    let key = if let Some(lit) = arg.strip_prefix('"') {
1457                        lit.split('"').next().unwrap().to_string()
1458                    } else {
1459                        let ident: String = arg
1460                            .chars()
1461                            .take_while(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == ':')
1462                            .collect();
1463                        let short = ident.rsplit("::").next().unwrap_or(&ident).to_string();
1464                        consts.get(&short).cloned().unwrap_or_else(|| {
1465                            panic!(
1466                                "{file} reads the environment through `{needle}{ident}…`, which \
1467                                 is neither a string literal nor an ENV_* constant this test \
1468                                 can resolve — name the key as a `pub const ENV_*` in arms.rs"
1469                            )
1470                        })
1471                    };
1472                    seen.push((file.clone(), key));
1473                }
1474            }
1475        }
1476        assert!(
1477            seen.len() >= 8,
1478            "the scan found only {} environment reads; it is supposed to find at least the \
1479             eight in ALL_ENV ({seen:?})",
1480            seen.len()
1481        );
1482        for (file, key) in &seen {
1483            assert!(
1484                ALL_ENV.contains(&key.as_str()),
1485                "{file} reads {key} and ALL_ENV does not name it"
1486            );
1487        }
1488        // And the other direction: a key on the list that nothing reads is a
1489        // stale entry a consumer would be carrying for nothing.
1490        for key in ALL_ENV {
1491            assert!(
1492                seen.iter().any(|(_, k)| k == key),
1493                "ALL_ENV names {key} but no non-test code in this crate reads it"
1494            );
1495        }
1496    }
1497
1498    /// The default is the shipping combination, spelled out rather than derived
1499    /// — a derived `Default` that drifted would move the durability contract.
1500    #[test]
1501    fn the_default_config_is_the_shipping_combination() {
1502        assert_eq!(StoreConfig::default(), StoreConfig::DEFAULT);
1503        assert_eq!(StoreConfig::DEFAULT.writer, WriterArm::Safe);
1504        assert_eq!(StoreConfig::DEFAULT.index, IndexArm::OneTableFourColumns);
1505        assert_eq!(StoreConfig::DEFAULT.gc, GcArm::NewGeneration);
1506        assert_eq!(
1507            StoreConfig::DEFAULT.to_string(),
1508            "ZNIPPY_GIT_WRITER=safe ZNIPPY_GIT_INDEX=one-table ZNIPPY_GIT_GC=new-generation \
1509             ZNIPPY_GIT_REDB_CACHE_BYTES=67108864"
1510        );
1511    }
1512
1513    /// **Only the durable arms name a journal**, and the fast one names none.
1514    ///
1515    /// Not cosmetic: the path returned here is what a reopen derives §13.12's
1516    /// `indexed` bit from, so an arm that writes no journal must not hand back
1517    /// the name of one — a store that ran on `SafeWriter` and is reopened on
1518    /// `FastWriter` would then diff against a log this writer is not appending
1519    /// to, and resume pack ordinals from it.
1520    ///
1521    /// Seen RED by `WriterArm::Fast => Some(journal_path(blobs))`: "left:
1522    /// Some(\"/nonexistent/objects.pack.journal\") right: None". Restored.
1523    /// This is the guard that catches that break — the disk-level one above
1524    /// stays green for it, which is why both exist.
1525    #[test]
1526    fn only_the_arms_that_write_a_journal_name_one() {
1527        let blobs = Path::new("/nonexistent/objects.pack");
1528        assert_eq!(WriterArm::Fast.journal(blobs), None);
1529        assert_eq!(
1530            WriterArm::Safe.journal(blobs),
1531            Some(PathBuf::from("/nonexistent/objects.pack.journal"))
1532        );
1533        assert_eq!(
1534            WriterArm::Uring.journal(blobs),
1535            WriterArm::Safe.journal(blobs),
1536            "the two durable arms share one journal format (LAW 5) and must share its name"
1537        );
1538    }
1539
1540    /// The arm's stated durability is the writer's own, not a second copy of it
1541    /// that could drift.
1542    #[test]
1543    fn the_arms_durability_line_matches_the_writer_it_builds() {
1544        let dir = crate::store::tests::tmpdir("arm-durability");
1545        for arm in [WriterArm::Fast, WriterArm::Safe] {
1546            let w = arm.create(&dir.join(format!("{}.pack", arm.as_str()))).unwrap();
1547            match arm {
1548                WriterArm::Fast => {
1549                    assert_eq!(w.name(), "FastWriter");
1550                    assert!(
1551                        w.durability().starts_with("none"),
1552                        "FastWriter must say plainly that it promises nothing: {}",
1553                        w.durability()
1554                    );
1555                    assert!(arm.durability().starts_with("none"));
1556                }
1557                WriterArm::Safe => {
1558                    assert_eq!(w.name(), "SafeWriter");
1559                    assert_eq!(w.durability(), arm.durability());
1560                }
1561                WriterArm::Uring => unreachable!(),
1562            }
1563        }
1564    }
1565}