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