gunnar_server/storage/znippy_engine.rs
1//! **The znippy arm: `znippy-plugin-git`, Arrow-IPC in a znippy archive.**
2//!
3//! The default engine, and the only one the appliance image is built with.
4//!
5//! # One `GitStore` per repository, per process
6//!
7//! The server's read side and its write side are the *same store*. That is not a
8//! convenience: `GitStore` owns a redb tail, a journal and an account indexer
9//! thread, and two instances over one directory would be two writers with two
10//! tails — the state where a push lands in one and a fetch is served out of the
11//! other, and neither is wrong about what it holds.
12//!
13//! So the handle is cached by path, and the cache is **never invalidated**. The
14//! thing that would "invalidate" it is another process opening the same
15//! directory, which is precisely what must not happen. Handing back the same
16//! `Arc` for the same path is the invariant, not an optimisation.
17//!
18//! # Where it came from
19//!
20//! the deleted engine crate’s git_tier.rs, whose reason for being a separate
21//! crate named znippy by path and the workspace could not. That
22//! is no longer avoidable: this crate names the *contract* by path, so the
23//! sibling checkout is a hard requirement either way. See `Cargo.toml`.
24//!
25//! What did **not** come with it is the deleted engine crate’s git_store.rs — 1298
26//! lines implementing `ObjectStore` and `RefStore` over `GitOps`. Those two
27//! traits are deleted; the server takes `GitOps` directly, so the adapter has
28//! nothing left to adapt.
29
30use std::io::BufRead;
31use std::path::{Path, PathBuf};
32use std::sync::Arc;
33
34use anyhow::{Context, Result};
35use git_storage_trait::GitOps;
36use znippy_plugin_git::arms::StoreConfig;
37use znippy_plugin_git::git_ops::{open_selected, SelectedStore};
38use znippy_plugin_git::GitHashKind;
39
40use super::store_cache::{self, Cache};
41use super::{BeforeStore, MaintenanceReport, OpenRepo, PushReceipt};
42
43/// Where a repository's git store lives.
44///
45/// Inside the git directory, beside `objects/`, because it *is* the object
46/// storage. `RepoStore::git_dir` was never a promise that anything git-shaped is
47/// in there, and this engine is what that sentence was written about.
48pub fn store_root(git_dir: &Path) -> PathBuf {
49 git_dir.join("gunnar-git.znippy")
50}
51
52/// gix's hash kind, in znippy's vocabulary.
53///
54/// `gix_hash::Kind` is `#[non_exhaustive]`, so the fallback arm is not dead code
55/// waiting to be filled in — it is what a gix release that adds a third hash
56/// lands on. sha1 is the answer because it is what git's own `object-format`
57/// defaults to, and a repository whose hash this cannot name is one it must not
58/// silently serve under sha256.
59fn to_znippy_kind(kind: gix_hash::Kind) -> GitHashKind {
60 match kind {
61 gix_hash::Kind::Sha256 => GitHashKind::Sha256,
62 _ => GitHashKind::Sha1,
63 }
64}
65
66/// Every store this process has opened, by store root.
67static STORES: Cache<SelectedStore> = Cache::new();
68
69/// **The arms every store in this process is built on, resolved once.**
70///
71/// [`StoreConfig::from_env`] reads `ZNIPPY_GIT_WRITER`, `ZNIPPY_GIT_INDEX`,
72/// `ZNIPPY_GIT_GC` and (since 2026-08-21) `ZNIPPY_GIT_REDB_CACHE_BYTES`, and
73/// logs the lot once as `store.znippy_env_selected`; an unset variable takes
74/// znippy's shipping default
75/// (`SafeWriter` + `OneTableFourColumns` + `NewGeneration`), so a server with a
76/// clean environment builds exactly what it built before this existed.
77///
78/// # Why the server reads the selector and `GitStore::open` does not
79///
80/// znippy's own constructors deliberately ignore these variables — *"a caller
81/// that never asked for a selector must not have its durability contract moved
82/// out from under it"* — and until 2026-08-11 this engine called
83/// `GitStore::open_with`, so **`ZNIPPY_GIT_WRITER` had no effect whatsoever on
84/// `gunnar serve`**. An operator could export `fast` on a production server and
85/// get `SafeWriter`, and a bench could label a column `fast` and measure `safe`.
86/// That is the mislabelling the read-back below exists to end, and it could not
87/// be ended by reading harder: the variable genuinely did nothing.
88///
89/// A server *is* a caller that asked. It is the one process for which "the
90/// operator chose the durability contract" is the whole point, so this is the
91/// door [`open_from_env`](znippy_plugin_git::git_ops::open_from_env) was written
92/// for — spelled out here rather than called, because the resolution has to be
93/// cached (it is per process, not per repository) and because a bad value must
94/// fail once with the same message every time.
95///
96/// **Read once.** Not once per repository: `OnceLock` holds the parse, so a
97/// server with two hundred repositories performs three `getenv`s in its life.
98/// An unparseable value is an error on every call rather than a poisoned lock,
99/// which is why the `Result` is re-derived instead of stored.
100fn arms() -> Result<StoreConfig> {
101 use std::sync::OnceLock;
102 static ARMS: OnceLock<std::result::Result<StoreConfig, String>> = OnceLock::new();
103 ARMS.get_or_init(|| {
104 // The writer arm is decided AFTER the environment: the medium's
105 // `korp.data.io=` token and the io_uring probe live in
106 // `storage::writer`, which says on a row what it built and why.
107 let resolved = StoreConfig::from_env()
108 .map(super::writer::select)
109 .map_err(|e| format!("{e:#}"));
110 // ── WHAT THE ENVIRONMENT SELECTED, ONCE, IN THE OPERATOR'S SPELLING ──
111 //
112 // `StoreConfig`'s `Display` is `KEY=value` for every per-store variable
113 // znippy reads — the three arms AND the redb page-cache ceiling. One
114 // line, once per process, on the container's log with no `RUST_LOG`
115 // set. Had it existed on 2026-08-11 it would have shown
116 // `ZNIPPY_GIT_REDB_CACHE_BYTES=67108864` on a server an operator
117 // believed was running at 8 MiB, and one of five wrong conclusions
118 // would have been visible in one run. The per-store
119 // `store.znippy_arms_selected` line below is APPLIED output (the built
120 // objects' own names); this one is the SELECTOR, and the two together
121 // are what it takes to see a selector that did not apply.
122 match &resolved {
123 Ok(cfg) => tracing::info!(
124 target: "gunnar::store",
125 event = "store.znippy_env_selected",
126 config = %cfg,
127 "the znippy selector, resolved once per process from the environment"
128 ),
129 Err(e) => tracing::error!(
130 target: "gunnar::store",
131 event = "store.znippy_env_refused",
132 error = %e,
133 "the znippy selector could not be resolved from the environment"
134 ),
135 }
136 resolved
137 })
138 .clone()
139 .map_err(|e| anyhow::anyhow!(e))
140}
141
142/// **Drop every cached store, so `redb`'s clean-shutdown `Drop` actually runs.**
143///
144/// Rust never drops a `static`. The map above therefore outlives `main`, its
145/// `GitStore`s are never dropped, and neither is the `redb::Database` inside
146/// each one — so the `objects.exploded` file is left **dirty at every exit,
147/// normal exits included**, and the next open takes `redb::Database::new`'s
148/// `needs_repair()` branch (redb-2.6.3 `db.rs:896`). With no allocator state
149/// table to load, that is `do_repair` → `verify_primary_checksums` over the
150/// whole file. `Drop for Database` (`db.rs:1067`) is the *only* caller of the
151/// private `ensure_allocator_state_table` that would have written it.
152///
153/// It is self-perpetuating: the process that pays for the repair leaves the
154/// file dirty for the next one.
155///
156/// MEASURED 2026-08-10 (the 4-core laptop, `h2h-linear-sha1-2048c-1024f-16k`, a
157/// 1 078 472 704-byte `objects.exploded`), one `git-upload-pack` clone, n=7
158/// interleaved: **−427 MB peak RSS, −107 k minor faults, −38 k `pread64`,
159/// −552 MB read from `objects.exploded`** — and the emitted pack is
160/// byte-identical, md5 `fe9141c2544b0cf21e7f2ec228783d8c`, as it is with the
161/// whole file deleted. None of that work produced a byte of output.
162///
163/// # This does not remove the repair, and must not
164///
165/// An abnormal exit — a crash, a `SIGKILL`, a panic — still leaves the database
166/// dirty, and `verify_primary_checksums` is what recovers it. That is a
167/// *recovery* path and it is correct. The defect was that it ran on the
168/// **normal** path.
169///
170/// # Why the cache is not what changed
171///
172/// [`open_shared`] caching by path is deliberate and stays: two `GitStore`s over
173/// one directory is two writers with two tails, and the cache is also what makes
174/// `refold()` cost once per repository rather than once per request. A `Weak`
175/// cache would drop the store between requests — nothing upstream holds an
176/// `Arc<GitStore>` across one, `OpenRepo` is built fresh per `open_repo` — and
177/// would buy this saving back at the price of a per-request `refold`, which is
178/// 0.39 s and 180 MB on the same fixture. The cache is right; it just has to be
179/// emptied once, at the end.
180///
181/// # Where it is called from
182///
183/// Every exit path that opens a store, and they are three different shapes:
184///
185/// * [`gunnar_server::run`](crate::run) — the one server dispatch, which both
186/// the `gunnar-server` binary and every `gunnar-cli` binary's server verbs
187/// route through (one implementation). Two exit paths, one call site.
188/// * `crates/gunnar-server/examples/git-store-serve.rs` — the bench server,
189/// which does not route through `run` and calls this in its own `main`,
190/// before each `process::exit` as well as on the success path.
191///
192/// `std::process::exit` does not unwind and runs no destructor, which is why
193/// this is an explicit call before it rather than a guard value.
194pub fn shutdown() {
195 store_cache::clear(&STORES);
196}
197
198/// **Stop the index work of the store under `git_dir`, if it is open.**
199///
200/// The half of `repo delete` that lives in the engine: without it, dropping
201/// the last handle joins the account indexer's worker — hours into a kernel
202/// pack — and `remove_dir_all` has to wait for that join. With it the worker
203/// aborts its pack at the next checkpoint (milliseconds) and the drop is
204/// immediate. A store nobody holds has no worker and needs nothing.
205pub fn cancel_index(git_dir: &Path) {
206 if let Some(store) = store_cache::peek(&STORES, &store_root(git_dir)) {
207 store.cancel_index();
208 }
209}
210
211/// **Every open store's index progress**, as `(git_dir, snapshot)`. The
212/// dashboard tick and `Admin.Stats` read this; it is a few atomics per open
213/// store and takes no store lock.
214pub fn index_progress() -> Vec<(PathBuf, znippy_plugin_git::progress::Snapshot)> {
215 znippy_plugin_git::progress::snapshot_all(false)
216 .into_iter()
217 .filter_map(|s| {
218 let root = PathBuf::from(&s.root);
219 // `<git_dir>/gunnar-git.znippy` → `<git_dir>`.
220 let git_dir = root.parent()?.to_path_buf();
221 Some((git_dir, s))
222 })
223 .collect()
224}
225
226/// **A receive-pack's reads are validation, not a reader's — say so.**
227///
228/// Held for the whole of `serve_receive_pack`, which is the exact scope: an
229/// ingest, an index and a connectivity walk, on one thread, serving nobody.
230/// While it is held the engine's lazy read-cache declines write-backs from this
231/// thread; every serving connection, and the background absorber, are
232/// untouched.
233///
234/// MEASURED on `torvalds/linux`, the reference desktop 2026-09-03: without it the push writes
235/// **6 778 047 872 B** of read-cache — 31 % of the finished store, the largest
236/// non-pack thing in it — between the instant indexing completes and the ack,
237/// for a reader that does not exist. The 2026-09-03 lever table has the rest.
238///
239/// It lives here because this file is the engine seam: `znippy_plugin_git::`
240/// may be named in this module and nowhere else (`xtask`'s
241/// `Rule::EngineByName`), and `transport.rs` must not learn which engine is
242/// underneath it to say a thing that is true of any of them.
243#[must_use = "the suppression lasts only as long as the guard is held"]
244pub fn validation_reads() -> znippy_plugin_git::SuppressWriteBack {
245 znippy_plugin_git::suppress_write_back()
246}
247
248/// **Every open store's explode band**, as `(git_dir, band)`.
249///
250/// Reads `znippy_plugin_git::exploded_cache::use_all` — the same `Weak`
251/// registry [`index_progress`] reads for the index rows, so an open store is
252/// listed exactly once and a closed one is swept rather than reported stale.
253pub fn explode_use() -> Vec<(PathBuf, crate::storage::ExplodeBand)> {
254 znippy_plugin_git::exploded_cache::use_all()
255 .into_iter()
256 .filter_map(|(root, u)| {
257 // `<git_dir>/gunnar-git.znippy` -> `<git_dir>`, the same fold
258 // `index_progress` does and for the same reason: the dashboard and
259 // the catalog both name repositories by git_dir.
260 let git_dir = PathBuf::from(&root).parent()?.to_path_buf();
261 Some((
262 git_dir,
263 crate::storage::ExplodeBand {
264 eager_bytes: u.eager_bytes,
265 lazy_bytes: u.lazy_bytes,
266 budget: u.budget,
267 segments: u.segments,
268 evicted_segments: u.evicted_segments,
269 evicted_bytes: u.evicted_bytes,
270 written_back: u.written_back,
271 },
272 ))
273 })
274 .collect()
275}
276
277/// **Indexed objects of every OPEN store**, as `(git_dir, count)`.
278///
279/// This is the `objects` meter of `Usage.Read`: the RAM a store costs is a
280/// straight line in its object count (~382 B each, measured),
281/// and only an open store is paying it. Reads the same `Weak` cache
282/// [`open_shared`] fills, so a store the `OpenRepo` LRU has evicted is gone
283/// from here too, and a closed repository reports nothing rather than a
284/// number nobody is paying. `object_count` is the `objects` table's length —
285/// one `len()`, no store lock, no disk.
286pub fn open_object_counts() -> Vec<(PathBuf, u64)> {
287 store_cache::live(&STORES)
288 .into_iter()
289 .filter_map(|(root, store)| {
290 // `<git_dir>/gunnar-git.znippy` -> `<git_dir>`, the fold
291 // `explode_use` and `index_progress` make, for the same reason.
292 let git_dir = root.parent()?.to_path_buf();
293 Some((git_dir, store.object_count() as u64))
294 })
295 .collect()
296}
297
298/// Set the explode budget of every open store under `git_dir`.
299pub fn set_explode_budget(git_dir: &Path, bytes: u64) -> usize {
300 znippy_plugin_git::exploded_cache::set_budget_under(
301 &store_root(git_dir).display().to_string(),
302 bytes,
303 )
304}
305
306/// Unlink this store's lazily-exploded objects. Only a store that is already
307/// open is reached — a closed one holds no cache this process is paying for,
308/// and opening one to empty it would be work `gc` did not ask for.
309pub fn drop_explode_cache(git_dir: &Path) -> u64 {
310 match store_cache::peek(&STORES, &store_root(git_dir)) {
311 Some(store) => store.drop_explode_cache().unwrap_or(0),
312 None => 0,
313 }
314}
315
316/// [`crate::storage::Engine::hold_index_memory`] for the znippy engine: the store's
317/// own memory guard (`GitStore::set_mem_watchdog`), read from `meminfo` with `floor`,
318/// or the process-wide one again. Takes effect from the next pack this store absorbs.
319pub fn hold_index_memory(git_dir: &Path, meminfo: Option<(&Path, u64)>) -> bool {
320 use znippy_plugin_git::memguard::{self, MemWatchdog};
321 let Some(store) = store_cache::peek(&STORES, &store_root(git_dir)) else {
322 return false;
323 };
324 let dog = match meminfo {
325 Some((file, floor)) => Arc::new(
326 MemWatchdog::with(file, floor, memguard::POLL)
327 .bounded(memguard::RESUME_MARGIN_BYTES, memguard::PAUSE_DEADLINE),
328 ),
329 None => memguard::global(),
330 };
331 match &*store {
332 SelectedStore::OneTableFourColumns(s) => s.set_mem_watchdog(dog),
333 SelectedStore::FourTables(s) => s.set_mem_watchdog(dog),
334 SelectedStore::PackedPayload(s) => s.set_mem_watchdog(dog),
335 }
336 true
337}
338
339/// The store for `git_dir`, opening it on first use.
340///
341/// `account` is the indexer's scope — one background drain per account, because
342/// the index is built after the ack — and is the repository's own path, so two
343/// repositories served by one process do not share a queue.
344///
345/// Opened **under** the lock, unlike an ordinary read cache: two `GitStore`s
346/// over one directory is the failure this exists to prevent, so "two threads
347/// race and one discards its copy" is not an acceptable outcome here.
348fn open_shared(git_dir: &Path, hash_kind: gix_hash::Kind) -> Result<Arc<SelectedStore>> {
349 let root = store_root(git_dir);
350 let account = git_dir.to_string_lossy().into_owned();
351 let make_in = root.clone();
352 store_cache::shared(&STORES, root, move || {
353 let store = open_selected(&make_in, &account, to_znippy_kind(hash_kind), arms()?)
354 .with_context(|| format!("opening the git store at {}", make_in.display()))?;
355 // ── WHICH ARMS ACTUALLY GOT BUILT ──────────────────────────────────
356 //
357 // The counterpart of `store.engine_selected` (`stores.rs`), one level
358 // down: that line says which *engine* opened the store, this one says
359 // which implementations inside it. Same target, same level, so both are
360 // on a container's log with no `RUST_LOG` set.
361 //
362 // **Every field here is applied output, and that is the entire reason
363 // the line exists.** `writer` is the `Box<dyn ArchiveWrite>`'s own
364 // `name()` and `index` is the built projection's own `name()` — never
365 // `arms.writer.as_str()` or `arms.index.as_str()`, which are the
366 // selector echoing back and would read identically on a server that
367 // ignored the selector completely. That server existed until
368 // 2026-08-11; see [`arms`].
369 tracing::info!(
370 target: "gunnar::store",
371 event = "store.znippy_arms_selected",
372 writer = store.writer_name(),
373 index = store.index_name(),
374 durability = store.writer_durability(),
375 root = %make_in.display(),
376 "the archive writer and index layout this store was built on"
377 );
378 Ok(store)
379 })
380}
381
382/// Open — creating if absent — the repository at `git_dir`.
383///
384/// There is no `ZnippyRepo` type any more. It was a three-field struct with a
385/// three-method [`RepoStore`](gunnar_store::RepoStore) impl whose gix twin was
386/// byte-identical apart from the concrete store's name, so both collapsed into
387/// [`OpenRepo`] and the engine-specific part is the one `Arc<GitStore>` below.
388pub fn open(git_dir: &Path, hash_kind: gix_hash::Kind) -> Result<Arc<OpenRepo>> {
389 Ok(OpenRepo::new(
390 git_dir,
391 hash_kind,
392 open_shared(git_dir, hash_kind)?,
393 ))
394}
395
396/// One push into the archive.
397///
398/// The storage contract's write path — walk, closure-check, verbatim append,
399/// blob fsync, journal row, journal fsync — all of it inside `put_pack`, none of
400/// it here.
401///
402/// # Why `want_objects` is a flag and not always on
403///
404/// The ids are not a by-product of storing the pack. A pack entry does not carry
405/// its oid, so `put_pack`'s walk cannot name one; they exist only once the
406/// indexer has absorbed the pack, and asking for them here **waits for that
407/// drain**. Deferring it is what pays 2×/11×/127× on the push path, so a push
408/// whose policy has no gate over objects must not pay it back.
409///
410/// # `progress` publishes from the walk, and only from the walk
411///
412/// The pack arrives whole — `read_pushed_pack_spooling` has already taken every
413/// byte off the wire before `put_pack` sees one — so the object count moves
414/// during the *split*, not during the download. That ordering is the honest
415/// one and is stated rather than hidden: `FetchProgress::pack_bytes` is the
416/// figure that moves while the network is the bottleneck, and the object
417/// numerator is the figure that moves while storage is. Between them a console
418/// has something live for both halves of a sync, and neither is fabricated.
419pub fn ingest(
420 git_dir: &Path,
421 hash_kind: gix_hash::Kind,
422 pack: &mut dyn BufRead,
423 objects: super::ObjectSet,
424 progress: Option<&dyn git_storage_trait::PackProgress>,
425 before_store: Option<BeforeStore<'_>>,
426) -> Result<PushReceipt> {
427 let started = std::time::Instant::now();
428 let store = open_shared(git_dir, hash_kind)?;
429 // Spooled beside the store: same filesystem as the archive it is copied into.
430 let pushed = super::read_pushed_pack_spooling(pack, hash_kind, Some(&store_root(git_dir)))?;
431 // The transaction log first (`Engine::ingest_push`'s `before_store`): these exact bytes are
432 // a `Pack` frame before the store sees one of them.
433 if let Some(hook) = before_store {
434 hook(&pushed.bytes).context("framing the pushed pack in the transaction log")?;
435 }
436 let tx = match progress {
437 Some(sink) => store.put_pack_observed(&pushed.bytes, sink),
438 None => store.put_pack(&pushed.bytes),
439 }
440 .context("storing the pushed pack verbatim")?;
441 let pack_id = super::pack_trailer(&pushed.bytes, hash_kind)?;
442 // This engine can always answer, so `Required` and `Wanted` are the same
443 // request to it. The distinction exists for an engine that cannot — see
444 // `storage::ObjectSet`.
445 let want_objects = objects != super::ObjectSet::NotNeeded;
446 let new_oids = match (want_objects, tx.extent) {
447 (true, Some(extent)) => Some(
448 store
449 .oids_in_extent(extent)
450 .context("enumerating the objects this push introduced")?,
451 ),
452 // A push the journal recorded without an extent stored no bytes, so it
453 // introduced nothing. `Some(empty)` rather than `None`: the set is
454 // known, and it is empty.
455 (true, None) => Some(Vec::new()),
456 (false, _) => None,
457 };
458 Ok(PushReceipt {
459 num_objects: pushed.num_objects,
460 version: pushed.version,
461 pack_id,
462 pack_bytes: pushed.bytes.len() as u64,
463 new_oids,
464 elapsed_ms: started.elapsed().as_millis() as u64,
465 })
466}
467
468/// `seal` then `gc`, and the order is the correctness argument — see
469/// [`Engine::maintain`](super::Engine::maintain).
470pub fn maintain(git_dir: &Path, hash_kind: gix_hash::Kind) -> Result<MaintenanceReport> {
471 let store = open_shared(git_dir, hash_kind)?;
472 let sections = store
473 .seal()
474 .context("sealing the live logs into reserved sections")?;
475 let report = store.gc().context("compacting the archive")?;
476 // **And the read cache goes with it.** `gc` is the verb an operator reaches
477 // for when `/data` is full; leaving the lazily-exploded objects behind
478 // would leave the largest reclaimable thing on the box untouched, and every
479 // one of them is re-derivable from the packs `gc` just compacted. The
480 // commit-and-tree pool is kept: it is the graph, not a cache.
481 let cache = store.drop_explode_cache().unwrap_or(0);
482 if cache > 0 {
483 tracing::info!(
484 target: "gunnar::store",
485 git_dir = %git_dir.display(),
486 bytes = cache,
487 "gc reclaimed the exploded read cache"
488 );
489 }
490 Ok(MaintenanceReport {
491 strategy: report.strategy.to_owned(),
492 // The cache was on the disk before and is not after, so it belongs on
493 // the BEFORE side. Adding it to `bytes_after` instead would report a gc
494 // that grew the store.
495 bytes_before: report.bytes_before + cache,
496 bytes_after: report.bytes_after,
497 rows: report.rows,
498 retired_packs: report.retired_packs,
499 verified: report.verified,
500 sections_sealed: sections.len(),
501 })
502}
503
504// ── the adapter's own door for the archive and the quota parser ─────────────
505//
506// `storage.rs`'s header: *"This module is the only place in gunnar that names
507// an implementation of it."* `xtask`'s engine-seam guard checks that sentence,
508// and until 2026-08-30 six call sites in `admin.rs` and `control_plane.rs`
509// reached `znippy_plugin_git::` directly — the guard was red on them.
510//
511// These wrappers are the fix, and they are wrappers rather than a redesign on
512// purpose: the archive and the explode quota are the znippy engine's own
513// features and there is nothing to abstract over yet, so what the seam buys
514// here is the ONE place a second engine would have to answer, not a false
515// generality. A gix arm that grew an archive would implement these; today it
516// does not have one, and saying so at this seam is cheaper than pretending at
517// every call site.
518
519// **The archive types are the PLUGIN's, re-exported — not re-declared here.**
520//
521// This module originally carried its own `Cut`, `Restored` and `Segment`,
522// field-for-field copies of the plugin's receipts with a `cut_of` converter
523// between them. Two things were wrong with that. It is a second definition of
524// the archive format's vocabulary (one implementation), and it had already gone stale:
525// `znippy_plugin_git::archive_file::Cut` no longer exists — the plugin's types
526// are `ArchiveOutReceipt` and `ArchiveInReceipt` since 2026-08-30 — so the
527// converter could not compile against today's engine at all.
528//
529// Re-exporting costs nothing, keeps the doc comments the plugin writes on each
530// field, and cannot drift. The old names are kept as ALIASES because call sites
531// use both spellings, and an alias is not a second type.
532pub use znippy_plugin_git::archive_file::{
533 ArchiveInReceipt, ArchiveOutReceipt, Segment, OBJECT_FORMAT,
534};
535
536/// What archiving a repository out produced. An alias, not a type.
537pub type Cut = ArchiveOutReceipt;
538/// What restoring an archive produced. An alias, not a type.
539pub type Restored = ArchiveInReceipt;
540
541/// Restore one repository's bytes from one file.
542pub fn archive_in(file: &Path, dir: &Path) -> Result<ArchiveInReceipt> {
543 znippy_plugin_git::archive_file::archive_in(file, dir)
544}
545
546/// Write one repository's bytes to one file.
547pub fn archive_out(dir: &Path, to: &Path, from_generation: u64) -> Result<ArchiveOutReceipt> {
548 znippy_plugin_git::archive_file::archive_out(dir, to, from_generation)
549}
550
551/// The same, into a sink — the streaming half `Repos.ArchiveOut` uses.
552pub fn archive_out_to<W: std::io::Write>(
553 dir: &Path,
554 sink: &mut W,
555 from_generation: u64,
556) -> Result<ArchiveOutReceipt> {
557 znippy_plugin_git::archive_file::archive_out_to(dir, sink, from_generation)
558}
559
560/// `8G`, `512M`, `unlimited` — the explode quota an operator types.
561pub fn parse_explode_budget(spec: &str) -> Result<u64> {
562 znippy_plugin_git::exploded_cache::parse_budget(spec)
563}
564
565/// The same number, rendered for a person.
566pub fn human_bytes(bytes: u64) -> String {
567 znippy_plugin_git::exploded_cache::human(bytes)
568}
569
570/// **Stop persisting the commit graph, process-wide.**
571///
572/// The one line of `hold_no_graph_memo` that needs the engine crate by name.
573/// It lives here rather than in `storage.rs` for the reason `storage.rs`
574/// states about itself: this module is the ONLY place in gunnar that names an
575/// implementation, and everything above it reaches an engine as `dyn
576/// GitServe`. `xtask/tests/engine_seam.rs` checks that sentence, and this
577/// wrapper is what makes it true again — the call itself is unchanged.
578pub fn set_graph_cache_default(on: bool) {
579 znippy_plugin_git::arms::set_graph_cache_default(on);
580}
581
582/// **The twin's whole memory bound, set in code** — the four doors
583/// `znippy-plugin-git` 0.1.5 added beside `set_graph_cache_default`.
584///
585/// Here for the same reason as the wrapper above: this module is the only place in gunnar that
586/// names an implementation. The figures are [`crate::storage`]'s, beside the seam that owns
587/// this door — never re-typed here, so the number in a doc and the number in the call cannot
588/// drift apart.
589///
590/// Each one governs every store opened AFTER it. That is the whole contract, and it is why
591/// `mark_twin` also sweeps the stores that are already open.
592pub fn hold_twin_memory_bound() {
593 znippy_plugin_git::set_explode_default(znippy_plugin_git::exploded_arrow::ExplodePolicy::Off);
594 znippy_plugin_git::set_explode_budget_default(Some(crate::storage::TWIN_EXPLODE_BUDGET_BYTES));
595 znippy_plugin_git::set_resolve_workers_default(crate::storage::TWIN_RESOLVE_WORKERS);
596 znippy_plugin_git::set_resolve_lookahead_default(crate::storage::TWIN_RESOLVE_LOOKAHEAD_BYTES);
597 // **The policy as the LIBRARY now resolves it, read back through its own getters** — not
598 // the constants this function passed in. `store.znippy_env_selected` fires once at process
599 // start, before a box is told it is a twin, so until this line nothing on a console showed
600 // that the runtime bound took. An explicit `ZNIPPY_GIT_*` in the environment still wins
601 // over these setters, and this line is where that would show.
602 tracing::info!(
603 target: gunnar_core::observe::TARGET_TWIN,
604 event = "twin.memory_bound",
605 explode = ?znippy_plugin_git::explode_default(),
606 explode_budget_bytes = ?znippy_plugin_git::budget_default(),
607 resolve_workers = znippy_plugin_git::resolve::workers_from_env(),
608 resolve_lookahead_bytes = znippy_plugin_git::resolve::lookahead_from_env(),
609 "the twin's memory bound, as the engine now resolves it for every store opened from here on"
610 );
611}
612
613/// **The stores that are ALREADY open when a box is told it is a twin**: their exploded pools
614/// are unlinked and capped.
615///
616/// A store carries the explode policy it opened with — znippy has no live switch for one — so
617/// the default above reaches every FUTURE open and nothing that is already there. On the
618/// one-medium path a box is told it is a twin before it holds a repository, so this normally
619/// touches nothing; it exists so a box that DID open one does not keep a cache a twin may not
620/// hold. Returns the bytes unlinked.
621pub fn bound_open_stores_to_twin() -> u64 {
622 let mut freed = 0;
623 for (root, _) in store_cache::live(&STORES) {
624 // `store_root` appends the archive dir; `live` hands it back, so fold once.
625 let git_dir = match root.parent() {
626 Some(d) => d.to_path_buf(),
627 None => continue,
628 };
629 set_explode_budget(&git_dir, crate::storage::TWIN_EXPLODE_BUDGET_BYTES);
630 freed += drop_explode_cache(&git_dir);
631 }
632 freed
633}
634
635#[cfg(test)]
636mod tests {
637 use std::sync::Mutex;
638
639 use super::*;
640
641 /// The store cache is process-global and so is [`shutdown`], so two tests
642 /// that touch it are one test run twice unless they take turns.
643 static SERIAL: Mutex<()> = Mutex::new(());
644
645 /// ★ **The twin's memory bound really is set, and it reaches what spends the memory.**
646 ///
647 /// The acceptance question for the one-medium twin is not "was a setter called" but "does
648 /// the twin stop building caches and stop resolving wide", so this asserts on what
649 /// `znippy-plugin-git` will hand its indexer and its next store open, not on our own call.
650 ///
651 /// Lane T3's one-medium run of 2026-09-21 found five `objects.exploded` files on a twin
652 /// because NOTHING turned the explode policy off at runtime — `hold_no_graph_memo` governs
653 /// `objects.graph` and only that. `ExplodePolicy::Off` below is that gap closed.
654 ///
655 /// Seen RED by dropping any one line of [`hold_twin_memory_bound`].
656 ///
657 /// It takes `SERIAL` because the four defaults are process-wide, and it does not restore
658 /// them: a test binary that has decided it is a twin stays one, which is exactly the
659 /// property being asserted.
660 #[test]
661 fn the_twin_memory_bound_reaches_the_resolver_and_the_next_store_open() {
662 let _g = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
663 hold_twin_memory_bound();
664
665 assert_eq!(
666 znippy_plugin_git::exploded_arrow::ExplodePolicy::from_env().unwrap(),
667 znippy_plugin_git::exploded_arrow::ExplodePolicy::Off,
668 "a store opened now would still explode objects — a twin holds no read cache"
669 );
670 assert_eq!(
671 znippy_plugin_git::exploded_cache::budget_from_env(std::path::Path::new("/")).unwrap(),
672 crate::storage::TWIN_EXPLODE_BUDGET_BYTES,
673 "the exploded pool is still sized from the filesystem, not from the twin's cap"
674 );
675
676 let cancel = std::sync::atomic::AtomicBool::new(false);
677 let progress = znippy_plugin_git::progress::IndexProgress::new("twin-bound-guard");
678 let dog = Arc::new(znippy_plugin_git::memguard::MemWatchdog::from_env());
679 let spill = || 0u64;
680 let limits = znippy_plugin_git::resolve::ResolveLimits::for_indexer(
681 "twin", &cancel, &progress, dog, &spill,
682 );
683 assert_eq!(
684 limits.workers,
685 crate::storage::TWIN_RESOLVE_WORKERS,
686 "the indexer would still resolve one worker per core: +133 MiB measured"
687 );
688 assert_eq!(
689 limits.lookahead_bytes,
690 crate::storage::TWIN_RESOLVE_LOOKAHEAD_BYTES,
691 "the resolver's workers may still run 32 MiB ahead of the stream"
692 );
693 }
694
695 /// **The cache holds no strong reference, so the caller's handle is the
696 /// store's whole lifetime.**
697 ///
698 /// This used to assert `strong_count == 2` — "the cache and this test are
699 /// the two owners a fresh open has" — because the map held an `Arc`. That
700 /// map was immortal: nothing ever removed an entry, so every repository the
701 /// process had touched stayed fully resident, redb page cache and all, and
702 /// the `DEFAULT_OPEN_REPOS` LRU one layer up evicted `OpenRepo` handles
703 /// while freeing nothing underneath them. On the reference desktop that read as **4815 MB of
704 /// anonymous RSS, flat across a 3 257-object clone and a 352 396-object
705 /// one** (`super::store_cache` records the measurement).
706 ///
707 /// It is `Weak` now, so `1` is the correct count and the assertion is the
708 /// memory property: when the last `OpenRepo` goes, the store goes, redb's
709 /// `Drop` runs, and the archive is left clean.
710 #[test]
711 fn the_cache_does_not_keep_a_store_alive() {
712 let _serial = SERIAL.lock().unwrap_or_else(|p| p.into_inner());
713 shutdown();
714 let dir = tempfile::tempdir().expect("tempdir");
715 let git_dir = dir.path().join("repo.git");
716 std::fs::create_dir_all(&git_dir).expect("git dir");
717
718 let store = open_shared(&git_dir, gix_hash::Kind::Sha1).expect("opening the store");
719 assert_eq!(
720 Arc::strong_count(&store),
721 1,
722 "something other than this test holds the store. A strong entry in the cache is \
723 what made every repository the process ever opened immortal."
724 );
725
726 // ── and it is STILL one shared handle while anything holds it ───────
727 //
728 // The whole reason this cache exists (`super::store_cache`) is that a
729 // reader opened before a push must see that push, which needs ingest
730 // and serve to hold the SAME store. `Weak` must not have weakened that.
731 let again = open_shared(&git_dir, gix_hash::Kind::Sha1).expect("second open");
732 assert!(
733 Arc::ptr_eq(&store, &again),
734 "two live opens of one root produced two stores, which is the defect this cache \
735 exists to prevent"
736 );
737 drop(again);
738
739 // Dropping the last handle must actually release it — that is what the
740 // eviction one layer up now buys.
741 let weak = Arc::downgrade(&store);
742 drop(store);
743 assert!(
744 weak.upgrade().is_none(),
745 "the store outlived its last handle, so the LRU above it still frees nothing"
746 );
747 shutdown();
748 }
749
750 /// `shutdown` is idempotent and safe before anything is opened.
751 ///
752 /// The bench server calls it on its error path, which is reachable before a
753 /// repository has been opened at all, and `gunnar_server::run` calls it for
754 /// every verb including the ones that open no store.
755 #[test]
756 fn shutdown_is_safe_with_nothing_open() {
757 let _serial = SERIAL.lock().unwrap_or_else(|p| p.into_inner());
758 shutdown();
759 shutdown();
760 }
761
762 /// ★ The indexer's own guard, handed `import_room`'s cgroup text, pauses inside the
763 /// cgroup — where the host's `/proc/meminfo` would have said 500 GB available. Here and not
764 /// in `import_room.rs`: this is the one directory that may name the engine (`xtask/src/engine_seam.rs`).
765 #[test]
766 fn the_indexers_guard_sees_the_cgroup_through_the_text() {
767 use crate::import_room::{cgroup_meminfo, cgroup_memory_in, MARGIN_PERCENT};
768 const GIB: u64 = 1024 * 1024 * 1024;
769 let d = tempfile::tempdir().unwrap();
770 let cgroup = |current: u64| {
771 let cg = d.path().join("cg");
772 std::fs::create_dir_all(&cg).unwrap();
773 std::fs::write(cg.join("memory.max"), format!("{}\n", 6 * GIB)).unwrap();
774 std::fs::write(cg.join("memory.current"), format!("{current}\n")).unwrap();
775 std::fs::write(cg.join("memory.stat"), "").unwrap();
776 cgroup_memory_in(&cg).expect("a limited cgroup")
777 };
778 let file = d.path().join("import.meminfo");
779 std::fs::write(&file, cgroup_meminfo(&cgroup(6 * GIB - 64 * 1024 * 1024))).unwrap();
780 let floor = 6 * GIB / 100 * MARGIN_PERCENT;
781 let dog = znippy_plugin_git::memguard::MemWatchdog::with(
782 &file,
783 floor,
784 std::time::Duration::from_millis(10),
785 );
786 assert_eq!(dog.total(), 6 * GIB);
787 assert!(
788 dog.breached().is_some(),
789 "64 MiB left of a 6 GiB cgroup must pause the index"
790 );
791 // The import refreshes the file; room again, the guard lets go.
792 std::fs::write(&file, cgroup_meminfo(&cgroup(GIB))).unwrap();
793 assert!(dog.breached().is_none(), "5 GiB free of 6 must not pause");
794 }
795}