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/// The store for `git_dir`, opening it on first use.
317///
318/// `account` is the indexer's scope — one background drain per account, because
319/// the index is built after the ack — and is the repository's own path, so two
320/// repositories served by one process do not share a queue.
321///
322/// Opened **under** the lock, unlike an ordinary read cache: two `GitStore`s
323/// over one directory is the failure this exists to prevent, so "two threads
324/// race and one discards its copy" is not an acceptable outcome here.
325fn open_shared(git_dir: &Path, hash_kind: gix_hash::Kind) -> Result<Arc<SelectedStore>> {
326 let root = store_root(git_dir);
327 let account = git_dir.to_string_lossy().into_owned();
328 let make_in = root.clone();
329 store_cache::shared(&STORES, root, move || {
330 let store = open_selected(&make_in, &account, to_znippy_kind(hash_kind), arms()?)
331 .with_context(|| format!("opening the git store at {}", make_in.display()))?;
332 // ── WHICH ARMS ACTUALLY GOT BUILT ──────────────────────────────────
333 //
334 // The counterpart of `store.engine_selected` (`stores.rs`), one level
335 // down: that line says which *engine* opened the store, this one says
336 // which implementations inside it. Same target, same level, so both are
337 // on a container's log with no `RUST_LOG` set.
338 //
339 // **Every field here is applied output, and that is the entire reason
340 // the line exists.** `writer` is the `Box<dyn ArchiveWrite>`'s own
341 // `name()` and `index` is the built projection's own `name()` — never
342 // `arms.writer.as_str()` or `arms.index.as_str()`, which are the
343 // selector echoing back and would read identically on a server that
344 // ignored the selector completely. That server existed until
345 // 2026-08-11; see [`arms`].
346 tracing::info!(
347 target: "gunnar::store",
348 event = "store.znippy_arms_selected",
349 writer = store.writer_name(),
350 index = store.index_name(),
351 durability = store.writer_durability(),
352 root = %make_in.display(),
353 "the archive writer and index layout this store was built on"
354 );
355 Ok(store)
356 })
357}
358
359/// Open — creating if absent — the repository at `git_dir`.
360///
361/// There is no `ZnippyRepo` type any more. It was a three-field struct with a
362/// three-method [`RepoStore`](gunnar_store::RepoStore) impl whose gix twin was
363/// byte-identical apart from the concrete store's name, so both collapsed into
364/// [`OpenRepo`] and the engine-specific part is the one `Arc<GitStore>` below.
365pub fn open(git_dir: &Path, hash_kind: gix_hash::Kind) -> Result<Arc<OpenRepo>> {
366 Ok(OpenRepo::new(
367 git_dir,
368 hash_kind,
369 open_shared(git_dir, hash_kind)?,
370 ))
371}
372
373/// One push into the archive.
374///
375/// The storage contract's write path — walk, closure-check, verbatim append,
376/// blob fsync, journal row, journal fsync — all of it inside `put_pack`, none of
377/// it here.
378///
379/// # Why `want_objects` is a flag and not always on
380///
381/// The ids are not a by-product of storing the pack. A pack entry does not carry
382/// its oid, so `put_pack`'s walk cannot name one; they exist only once the
383/// indexer has absorbed the pack, and asking for them here **waits for that
384/// drain**. Deferring it is what pays 2×/11×/127× on the push path, so a push
385/// whose policy has no gate over objects must not pay it back.
386///
387/// # `progress` publishes from the walk, and only from the walk
388///
389/// The pack arrives whole — `read_pushed_pack_spooling` has already taken every
390/// byte off the wire before `put_pack` sees one — so the object count moves
391/// during the *split*, not during the download. That ordering is the honest
392/// one and is stated rather than hidden: `FetchProgress::pack_bytes` is the
393/// figure that moves while the network is the bottleneck, and the object
394/// numerator is the figure that moves while storage is. Between them a console
395/// has something live for both halves of a sync, and neither is fabricated.
396pub fn ingest(
397 git_dir: &Path,
398 hash_kind: gix_hash::Kind,
399 pack: &mut dyn BufRead,
400 objects: super::ObjectSet,
401 progress: Option<&dyn git_storage_trait::PackProgress>,
402 before_store: Option<BeforeStore<'_>>,
403) -> Result<PushReceipt> {
404 let started = std::time::Instant::now();
405 let store = open_shared(git_dir, hash_kind)?;
406 // Spooled beside the store: same filesystem as the archive it is copied into.
407 let pushed = super::read_pushed_pack_spooling(pack, hash_kind, Some(&store_root(git_dir)))?;
408 // The transaction log first (`Engine::ingest_push`'s `before_store`): these exact bytes are
409 // a `Pack` frame before the store sees one of them.
410 if let Some(hook) = before_store {
411 hook(&pushed.bytes).context("framing the pushed pack in the transaction log")?;
412 }
413 let tx = match progress {
414 Some(sink) => store.put_pack_observed(&pushed.bytes, sink),
415 None => store.put_pack(&pushed.bytes),
416 }
417 .context("storing the pushed pack verbatim")?;
418 let pack_id = super::pack_trailer(&pushed.bytes, hash_kind)?;
419 // This engine can always answer, so `Required` and `Wanted` are the same
420 // request to it. The distinction exists for an engine that cannot — see
421 // `storage::ObjectSet`.
422 let want_objects = objects != super::ObjectSet::NotNeeded;
423 let new_oids = match (want_objects, tx.extent) {
424 (true, Some(extent)) => Some(
425 store
426 .oids_in_extent(extent)
427 .context("enumerating the objects this push introduced")?,
428 ),
429 // A push the journal recorded without an extent stored no bytes, so it
430 // introduced nothing. `Some(empty)` rather than `None`: the set is
431 // known, and it is empty.
432 (true, None) => Some(Vec::new()),
433 (false, _) => None,
434 };
435 Ok(PushReceipt {
436 num_objects: pushed.num_objects,
437 version: pushed.version,
438 pack_id,
439 pack_bytes: pushed.bytes.len() as u64,
440 new_oids,
441 elapsed_ms: started.elapsed().as_millis() as u64,
442 })
443}
444
445/// `seal` then `gc`, and the order is the correctness argument — see
446/// [`Engine::maintain`](super::Engine::maintain).
447pub fn maintain(git_dir: &Path, hash_kind: gix_hash::Kind) -> Result<MaintenanceReport> {
448 let store = open_shared(git_dir, hash_kind)?;
449 let sections = store
450 .seal()
451 .context("sealing the live logs into reserved sections")?;
452 let report = store.gc().context("compacting the archive")?;
453 // **And the read cache goes with it.** `gc` is the verb an operator reaches
454 // for when `/data` is full; leaving the lazily-exploded objects behind
455 // would leave the largest reclaimable thing on the box untouched, and every
456 // one of them is re-derivable from the packs `gc` just compacted. The
457 // commit-and-tree pool is kept: it is the graph, not a cache.
458 let cache = store.drop_explode_cache().unwrap_or(0);
459 if cache > 0 {
460 tracing::info!(
461 target: "gunnar::store",
462 git_dir = %git_dir.display(),
463 bytes = cache,
464 "gc reclaimed the exploded read cache"
465 );
466 }
467 Ok(MaintenanceReport {
468 strategy: report.strategy.to_owned(),
469 // The cache was on the disk before and is not after, so it belongs on
470 // the BEFORE side. Adding it to `bytes_after` instead would report a gc
471 // that grew the store.
472 bytes_before: report.bytes_before + cache,
473 bytes_after: report.bytes_after,
474 rows: report.rows,
475 retired_packs: report.retired_packs,
476 verified: report.verified,
477 sections_sealed: sections.len(),
478 })
479}
480
481// ── the adapter's own door for the archive and the quota parser ─────────────
482//
483// `storage.rs`'s header: *"This module is the only place in gunnar that names
484// an implementation of it."* `xtask`'s engine-seam guard checks that sentence,
485// and until 2026-08-30 six call sites in `admin.rs` and `control_plane.rs`
486// reached `znippy_plugin_git::` directly — the guard was red on them.
487//
488// These wrappers are the fix, and they are wrappers rather than a redesign on
489// purpose: the archive and the explode quota are the znippy engine's own
490// features and there is nothing to abstract over yet, so what the seam buys
491// here is the ONE place a second engine would have to answer, not a false
492// generality. A gix arm that grew an archive would implement these; today it
493// does not have one, and saying so at this seam is cheaper than pretending at
494// every call site.
495
496// **The archive types are the PLUGIN's, re-exported — not re-declared here.**
497//
498// This module originally carried its own `Cut`, `Restored` and `Segment`,
499// field-for-field copies of the plugin's receipts with a `cut_of` converter
500// between them. Two things were wrong with that. It is a second definition of
501// the archive format's vocabulary (one implementation), and it had already gone stale:
502// `znippy_plugin_git::archive_file::Cut` no longer exists — the plugin's types
503// are `ArchiveOutReceipt` and `ArchiveInReceipt` since 2026-08-30 — so the
504// converter could not compile against today's engine at all.
505//
506// Re-exporting costs nothing, keeps the doc comments the plugin writes on each
507// field, and cannot drift. The old names are kept as ALIASES because call sites
508// use both spellings, and an alias is not a second type.
509pub use znippy_plugin_git::archive_file::{
510 ArchiveInReceipt, ArchiveOutReceipt, Segment, OBJECT_FORMAT,
511};
512
513/// What archiving a repository out produced. An alias, not a type.
514pub type Cut = ArchiveOutReceipt;
515/// What restoring an archive produced. An alias, not a type.
516pub type Restored = ArchiveInReceipt;
517
518/// Restore one repository's bytes from one file.
519pub fn archive_in(file: &Path, dir: &Path) -> Result<ArchiveInReceipt> {
520 znippy_plugin_git::archive_file::archive_in(file, dir)
521}
522
523/// Write one repository's bytes to one file.
524pub fn archive_out(dir: &Path, to: &Path, from_generation: u64) -> Result<ArchiveOutReceipt> {
525 znippy_plugin_git::archive_file::archive_out(dir, to, from_generation)
526}
527
528/// The same, into a sink — the streaming half `Repos.ArchiveOut` uses.
529pub fn archive_out_to<W: std::io::Write>(
530 dir: &Path,
531 sink: &mut W,
532 from_generation: u64,
533) -> Result<ArchiveOutReceipt> {
534 znippy_plugin_git::archive_file::archive_out_to(dir, sink, from_generation)
535}
536
537/// `8G`, `512M`, `unlimited` — the explode quota an operator types.
538pub fn parse_explode_budget(spec: &str) -> Result<u64> {
539 znippy_plugin_git::exploded_cache::parse_budget(spec)
540}
541
542/// The same number, rendered for a person.
543pub fn human_bytes(bytes: u64) -> String {
544 znippy_plugin_git::exploded_cache::human(bytes)
545}
546
547/// **Stop persisting the commit graph, process-wide.**
548///
549/// The one line of `hold_no_graph_memo` that needs the engine crate by name.
550/// It lives here rather than in `storage.rs` for the reason `storage.rs`
551/// states about itself: this module is the ONLY place in gunnar that names an
552/// implementation, and everything above it reaches an engine as `dyn
553/// GitServe`. `xtask/tests/engine_seam.rs` checks that sentence, and this
554/// wrapper is what makes it true again — the call itself is unchanged.
555pub fn set_graph_cache_default(on: bool) {
556 znippy_plugin_git::arms::set_graph_cache_default(on);
557}
558
559/// **The twin's whole memory bound, set in code** — the four doors
560/// `znippy-plugin-git` 0.1.5 added beside `set_graph_cache_default`.
561///
562/// Here for the same reason as the wrapper above: this module is the only place in gunnar that
563/// names an implementation. The figures are [`crate::storage`]'s, beside the seam that owns
564/// this door — never re-typed here, so the number in a doc and the number in the call cannot
565/// drift apart.
566///
567/// Each one governs every store opened AFTER it. That is the whole contract, and it is why
568/// `mark_twin` also sweeps the stores that are already open.
569pub fn hold_twin_memory_bound() {
570 znippy_plugin_git::set_explode_default(
571 znippy_plugin_git::exploded_arrow::ExplodePolicy::Off,
572 );
573 znippy_plugin_git::set_explode_budget_default(Some(
574 crate::storage::TWIN_EXPLODE_BUDGET_BYTES,
575 ));
576 znippy_plugin_git::set_resolve_workers_default(crate::storage::TWIN_RESOLVE_WORKERS);
577 znippy_plugin_git::set_resolve_lookahead_default(
578 crate::storage::TWIN_RESOLVE_LOOKAHEAD_BYTES,
579 );
580 // **The policy as the LIBRARY now resolves it, read back through its own getters** — not
581 // the constants this function passed in. `store.znippy_env_selected` fires once at process
582 // start, before a box is told it is a twin, so until this line nothing on a console showed
583 // that the runtime bound took. An explicit `ZNIPPY_GIT_*` in the environment still wins
584 // over these setters, and this line is where that would show.
585 tracing::info!(
586 target: gunnar_core::observe::TARGET_TWIN,
587 event = "twin.memory_bound",
588 explode = ?znippy_plugin_git::explode_default(),
589 explode_budget_bytes = ?znippy_plugin_git::budget_default(),
590 resolve_workers = znippy_plugin_git::resolve::workers_from_env(),
591 resolve_lookahead_bytes = znippy_plugin_git::resolve::lookahead_from_env(),
592 "the twin's memory bound, as the engine now resolves it for every store opened from here on"
593 );
594}
595
596/// **The stores that are ALREADY open when a box is told it is a twin**: their exploded pools
597/// are unlinked and capped.
598///
599/// A store carries the explode policy it opened with — znippy has no live switch for one — so
600/// the default above reaches every FUTURE open and nothing that is already there. On the
601/// one-medium path a box is told it is a twin before it holds a repository, so this normally
602/// touches nothing; it exists so a box that DID open one does not keep a cache a twin may not
603/// hold. Returns the bytes unlinked.
604pub fn bound_open_stores_to_twin() -> u64 {
605 let mut freed = 0;
606 for (root, _) in store_cache::live(&STORES) {
607 // `store_root` appends the archive dir; `live` hands it back, so fold once.
608 let git_dir = match root.parent() {
609 Some(d) => d.to_path_buf(),
610 None => continue,
611 };
612 set_explode_budget(&git_dir, crate::storage::TWIN_EXPLODE_BUDGET_BYTES);
613 freed += drop_explode_cache(&git_dir);
614 }
615 freed
616}
617
618#[cfg(test)]
619mod tests {
620 use std::sync::Mutex;
621
622 use super::*;
623
624 /// The store cache is process-global and so is [`shutdown`], so two tests
625 /// that touch it are one test run twice unless they take turns.
626 static SERIAL: Mutex<()> = Mutex::new(());
627
628 /// ★ **The twin's memory bound really is set, and it reaches what spends the memory.**
629 ///
630 /// The acceptance question for the one-medium twin is not "was a setter called" but "does
631 /// the twin stop building caches and stop resolving wide", so this asserts on what
632 /// `znippy-plugin-git` will hand its indexer and its next store open, not on our own call.
633 ///
634 /// Lane T3's one-medium run of 2026-09-21 found five `objects.exploded` files on a twin
635 /// because NOTHING turned the explode policy off at runtime — `hold_no_graph_memo` governs
636 /// `objects.graph` and only that. `ExplodePolicy::Off` below is that gap closed.
637 ///
638 /// Seen RED by dropping any one line of [`hold_twin_memory_bound`].
639 ///
640 /// It takes `SERIAL` because the four defaults are process-wide, and it does not restore
641 /// them: a test binary that has decided it is a twin stays one, which is exactly the
642 /// property being asserted.
643 #[test]
644 fn the_twin_memory_bound_reaches_the_resolver_and_the_next_store_open() {
645 let _g = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
646 hold_twin_memory_bound();
647
648 assert_eq!(
649 znippy_plugin_git::exploded_arrow::ExplodePolicy::from_env().unwrap(),
650 znippy_plugin_git::exploded_arrow::ExplodePolicy::Off,
651 "a store opened now would still explode objects — a twin holds no read cache"
652 );
653 assert_eq!(
654 znippy_plugin_git::exploded_cache::budget_from_env(std::path::Path::new("/"))
655 .unwrap(),
656 crate::storage::TWIN_EXPLODE_BUDGET_BYTES,
657 "the exploded pool is still sized from the filesystem, not from the twin's cap"
658 );
659
660 let cancel = std::sync::atomic::AtomicBool::new(false);
661 let progress = znippy_plugin_git::progress::IndexProgress::new("twin-bound-guard");
662 let dog = Arc::new(znippy_plugin_git::memguard::MemWatchdog::from_env());
663 let spill = || 0u64;
664 let limits = znippy_plugin_git::resolve::ResolveLimits::for_indexer(
665 "twin", &cancel, &progress, dog, &spill,
666 );
667 assert_eq!(
668 limits.workers,
669 crate::storage::TWIN_RESOLVE_WORKERS,
670 "the indexer would still resolve one worker per core: +133 MiB measured"
671 );
672 assert_eq!(
673 limits.lookahead_bytes,
674 crate::storage::TWIN_RESOLVE_LOOKAHEAD_BYTES,
675 "the resolver's workers may still run 32 MiB ahead of the stream"
676 );
677 }
678
679 /// **The cache holds no strong reference, so the caller's handle is the
680 /// store's whole lifetime.**
681 ///
682 /// This used to assert `strong_count == 2` — "the cache and this test are
683 /// the two owners a fresh open has" — because the map held an `Arc`. That
684 /// map was immortal: nothing ever removed an entry, so every repository the
685 /// process had touched stayed fully resident, redb page cache and all, and
686 /// the `DEFAULT_OPEN_REPOS` LRU one layer up evicted `OpenRepo` handles
687 /// while freeing nothing underneath them. On the reference desktop that read as **4815 MB of
688 /// anonymous RSS, flat across a 3 257-object clone and a 352 396-object
689 /// one** (`super::store_cache` records the measurement).
690 ///
691 /// It is `Weak` now, so `1` is the correct count and the assertion is the
692 /// memory property: when the last `OpenRepo` goes, the store goes, redb's
693 /// `Drop` runs, and the archive is left clean.
694 #[test]
695 fn the_cache_does_not_keep_a_store_alive() {
696 let _serial = SERIAL.lock().unwrap_or_else(|p| p.into_inner());
697 shutdown();
698 let dir = tempfile::tempdir().expect("tempdir");
699 let git_dir = dir.path().join("repo.git");
700 std::fs::create_dir_all(&git_dir).expect("git dir");
701
702 let store = open_shared(&git_dir, gix_hash::Kind::Sha1).expect("opening the store");
703 assert_eq!(
704 Arc::strong_count(&store),
705 1,
706 "something other than this test holds the store. A strong entry in the cache is \
707 what made every repository the process ever opened immortal."
708 );
709
710 // ── and it is STILL one shared handle while anything holds it ───────
711 //
712 // The whole reason this cache exists (`super::store_cache`) is that a
713 // reader opened before a push must see that push, which needs ingest
714 // and serve to hold the SAME store. `Weak` must not have weakened that.
715 let again = open_shared(&git_dir, gix_hash::Kind::Sha1).expect("second open");
716 assert!(
717 Arc::ptr_eq(&store, &again),
718 "two live opens of one root produced two stores, which is the defect this cache \
719 exists to prevent"
720 );
721 drop(again);
722
723 // Dropping the last handle must actually release it — that is what the
724 // eviction one layer up now buys.
725 let weak = Arc::downgrade(&store);
726 drop(store);
727 assert!(
728 weak.upgrade().is_none(),
729 "the store outlived its last handle, so the LRU above it still frees nothing"
730 );
731 shutdown();
732 }
733
734 /// `shutdown` is idempotent and safe before anything is opened.
735 ///
736 /// The bench server calls it on its error path, which is reachable before a
737 /// repository has been opened at all, and `gunnar_server::run` calls it for
738 /// every verb including the ones that open no store.
739 #[test]
740 fn shutdown_is_safe_with_nothing_open() {
741 let _serial = SERIAL.lock().unwrap_or_else(|p| p.into_inner());
742 shutdown();
743 shutdown();
744 }
745}