znippy-plugin-git 0.1.1

Git object-store metadata plugin for znippy (native builtin — no WASM). Carries the reserved oid / commit-graph / reachability sub-indexes.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
//! **Is the 4.8 GB plateau REACHABLE, or UNREACHABLE-BUT-UNFREED?**
//!
//! # Why this exists before any more eviction work
//!
//! gunnar-on-znippy sits at 4792–4872 MB of anonymous RSS on oden's forge suite,
//! **flat** — the same figure whether the clone it served was 3 257 objects or 352 396.
//! Two rounds of work have treated that as a retention bug and bounded a cache; the
//! second round (`gunnar` `ecc2c842`) reported its own fix as failed, correctly: the
//! bound is `DEFAULT_OPEN_REPOS = 256` and the forge seeds ~20 repositories, so it never
//! fires.
//!
//! **An eviction bound is a tool for a growth curve.** Against a flat plateau it can only
//! help if the plateau exceeds the bound. So the question that has to be answered before
//! any more dropping is which of two completely different defects this is:
//!
//! * **(a) REACHABLE** — something deliberately holds those bytes. The fix is to find the
//!   owner and decide whether it should hold them.
//! * **(b) UNREACHABLE BUT UNFREED** — the bytes are `free()`d and the allocator never
//!   hands the pages back to the OS. **No amount of dropping helps**; the fix is an
//!   allocator or arena change.
//!
//! Every measurement taken so far — RSS while the server runs — is identical under both,
//! which is why three sessions could not tell them apart.
//!
//! # The discriminator
//!
//! For each phase, three readings of anonymous RSS out of `/proc/self/smaps_rollup`
//! (a file read; no shelling, LAW):
//!
//! 1. **live** — the structure built and warm.
//! 2. **dropped** — every owner gone. Anything still resident is now, by definition,
//!    unreachable from the program.
//! 3. **trimmed** — after `malloc_trim(0)`, which asks glibc to return free arena pages.
//!
//! | live → dropped | dropped → trimmed | verdict |
//! |---|---|---|
//! | falls | — | the drop works; the plateau's owner is upstream of this structure |
//! | flat | **falls** | **(b)** glibc retention — eviction cannot fix it |
//! | flat | flat | **(a)** something still holds it, or the arena is too fragmented to trim |
//!
//! # The two controls are the point, not decoration
//!
//! glibc routes allocations at or above `M_MMAP_THRESHOLD` (128 KiB by default) straight
//! to `mmap`, and `free` on those `munmap`s immediately — those pages always go back.
//! Below it, allocations come from the arena and `free` returns them to a free list, not
//! to the OS. So *the same number of bytes behaves completely differently depending on
//! the block size it was requested in*, and a probe that does not pin that down cannot
//! attribute what it sees.
//!
//! That matters here specifically: redb's page cache holds **4 KiB pages**, far below the
//! threshold. 64 MiB of cache is ~16 000 small blocks, and freeing them is exactly the
//! shape that stays in the arena. The controls measure that directly on this box rather
//! than quoting it from documentation.
//!
//! # Run
//!
//! ```bash
//! cargo test -p znippy-plugin-git --test rss_retention_probe -- --ignored --nocapture
//! ```
//!
//! `#[ignore]` because it is a measurement, it allocates ~1 GB, and libtest would
//! otherwise run it beside other tests in the same process — where their allocations land
//! in the same arena and the readings stop meaning anything.

use std::path::Path;

use znippy_plugin_git::arms::DEFAULT_REDB_CACHE_BYTES;
// `lookup` reaches the stack through the `ObjectIndex` trait it implements, not an
// inherent method — the trait has to be in scope for the cache-warming reads.
use znippy_plugin_git::index_layout::{IndexEntry, ObjType, ObjectIndex, OneTableFourColumns};
use znippy_plugin_git::read_stack::{ObjectReadStack, RebuildTriggers};

/// Anonymous RSS of THIS process, in KiB, from `/proc/self/smaps_rollup`.
///
/// `Anonymous:` and not `Rss:` deliberately. `Rss` includes file-backed pages — mapped
/// binaries, and any page cache a mapping brings in — which the kernel reclaims on demand
/// and which no allocator change would touch. The plateau under investigation was already
/// reported as anonymous, and this keeps the two from being conflated.
fn anon_rss_kb() -> u64 {
    let s = std::fs::read_to_string("/proc/self/smaps_rollup")
        .expect("/proc/self/smaps_rollup (Linux 4.14+)");
    for line in s.lines() {
        if let Some(rest) = line.strip_prefix("Anonymous:") {
            let kb = rest.trim().trim_end_matches("kB").trim();
            return kb.parse().expect("Anonymous: is a kB count");
        }
    }
    panic!("no `Anonymous:` line in smaps_rollup");
}

fn mb(kb: u64) -> f64 {
    kb as f64 / 1024.0
}

/// **The allocator's own books**: `(in_use, from_kernel)` in KiB, via `mallinfo2`.
///
/// This is the reading that settles reachable-vs-unfreed **without dropping anything**,
/// and it is why the probe leads with it. `uordblks + hblkhd` is what the program has
/// asked for and not given back — *live, reachable bytes*. `arena + hblkhd` is what
/// glibc has taken from the kernel. The gap between them is free list: bytes the program
/// no longer owns and the OS has not got back.
///
/// Three sessions measured RSS, which is the SUM of those two and therefore cannot
/// distinguish them. The technique is not new here — `znippy-zoomies`'
/// `osm-katana/src/phase_log.rs` reached for exactly this pair on 2026-08-03 for the same
/// question, with the same reasoning written down ("a number near zero says the memory is
/// genuinely live … a large number says the allocator was the leak"). It is read here
/// rather than imported because `osm-katana` sits DOWNSTREAM of the `znippy-zoomies` root
/// that znippy depends on, so importing it would invert the dependency; see the report's
/// note on where the shared primitive belongs.
fn mallinfo_kb() -> Option<(u64, u64)> {
    #[cfg(all(target_os = "linux", target_env = "gnu"))]
    {
        // SAFETY: `mallinfo2` takes no arguments and returns a plain POD struct.
        let mi = unsafe { libc::mallinfo2() };
        let in_use = (mi.uordblks as u64 + mi.hblkhd as u64) / 1024;
        let from_kernel = (mi.arena as u64 + mi.hblkhd as u64) / 1024;
        return Some((in_use, from_kernel));
    }
    #[cfg(not(all(target_os = "linux", target_env = "gnu")))]
    None
}

fn mallinfo_line(tag: &str) -> String {
    match mallinfo_kb() {
        Some((used, from_kernel)) => format!(
            "{tag}: in-use {:8.1} MB · from-kernel {:8.1} MB · free-list {:8.1} MB",
            mb(used),
            mb(from_kernel),
            mb(from_kernel.saturating_sub(used))
        ),
        None => format!("{tag}: mallinfo2 unavailable (not glibc)"),
    }
}

/// Ask glibc to return free arena pages to the OS. Safe to call at any time; it is a
/// hint, and on a non-glibc libc the symbol would not link — which is itself the honest
/// signal that this whole diagnosis is glibc-specific.
fn malloc_trim() {
    unsafe { libc::malloc_trim(0) };
}

/// One phase's three readings, printed as the table the report quotes.
struct Phase {
    what: String,
    base: u64,
    live: u64,
    dropped: u64,
    trimmed: u64,
}

impl Phase {
    fn report(&self) -> Self {
        let grew = self.live.saturating_sub(self.base);
        let after_drop = self.dropped.saturating_sub(self.base);
        let after_trim = self.trimmed.saturating_sub(self.base);
        eprintln!(
            "\n── {} ──\n  grew            {:8.1} MB\n  after drop      {:8.1} MB  ({:5.1}% still \
             resident)\n  after trim      {:8.1} MB  ({:5.1}% still resident)",
            self.what,
            mb(grew),
            mb(after_drop),
            100.0 * after_drop as f64 / grew.max(1) as f64,
            mb(after_trim),
            100.0 * after_trim as f64 / grew.max(1) as f64,
        );
        Self { what: self.what.clone(), ..*self }
    }
    fn held_after_drop_pct(&self) -> f64 {
        let grew = self.live.saturating_sub(self.base).max(1);
        100.0 * self.dropped.saturating_sub(self.base) as f64 / grew as f64
    }
    fn held_after_trim_pct(&self) -> f64 {
        let grew = self.live.saturating_sub(self.base).max(1);
        100.0 * self.trimmed.saturating_sub(self.base) as f64 / grew as f64
    }
}

/// **CONTROL A — many SMALL blocks**, the shape redb's 4 KiB page cache has.
/// Expectation, to be confirmed rather than assumed: the drop frees nothing back to the
/// OS and `malloc_trim` is what returns it.
fn control_small_blocks(target_mb: usize) -> Phase {
    const BLOCK: usize = 4096;
    let n = target_mb * 1024 * 1024 / BLOCK;
    let base = anon_rss_kb();
    let mut v: Vec<Vec<u8>> = Vec::with_capacity(n);
    for i in 0..n {
        // Written, not just reserved: an untouched page is not resident, and a probe
        // that measured reservations would report a number the kernel never backed.
        let mut b = vec![0u8; BLOCK];
        b[0] = i as u8;
        b[BLOCK - 1] = (i >> 8) as u8;
        v.push(b);
    }
    let live = anon_rss_kb();
    drop(v);
    let dropped = anon_rss_kb();
    malloc_trim();
    let trimmed = anon_rss_kb();
    Phase { what: format!("CONTROL A — {n} x 4 KiB blocks (redb page shape)"), base, live, dropped, trimmed }
}

/// **CONTROL B — few LARGE blocks**, above glibc's mmap threshold. The counterweight: if
/// this one also failed to return its pages, the probe would be measuring something other
/// than the arena and Control A would prove nothing.
fn control_large_blocks(target_mb: usize) -> Phase {
    const BLOCK: usize = 1024 * 1024;
    let n = target_mb;
    let base = anon_rss_kb();
    let mut v: Vec<Vec<u8>> = Vec::with_capacity(n);
    for i in 0..n {
        let mut b = vec![0u8; BLOCK];
        for p in (0..BLOCK).step_by(4096) {
            b[p] = i as u8;
        }
        v.push(b);
    }
    let live = anon_rss_kb();
    drop(v);
    let dropped = anon_rss_kb();
    malloc_trim();
    let trimmed = anon_rss_kb();
    Phase { what: format!("CONTROL B — {n} x 1 MiB blocks (above mmap threshold)"), base, live, dropped, trimmed }
}

/// The per-database redb page-cache ceiling this run uses. Defaults to the shipped
/// [`DEFAULT_REDB_CACHE_BYTES`]; override with `PROBE_REDB_CACHE_BYTES` **as a plain byte
/// count** to measure what the ceiling is actually worth.
///
/// Deliberately a *separate* variable from the product's own `ZNIPPY_GIT_REDB_CACHE_BYTES`,
/// and deliberately parsed the same strict way: `arms::redb_cache_bytes()` does
/// `raw.parse::<usize>()` and **`bail!`s on anything that is not a bare integer** — so
/// `=8m` is an error, never 8 MiB. Its own doc says why, in writing: *"a silent fallback
/// would let an operator believe a measurement came from a ceiling that was never
/// applied."* A probe that took `8m` here and reported a number would be that operator.
fn probe_cache_bytes() -> usize {
    match std::env::var("PROBE_REDB_CACHE_BYTES") {
        Ok(v) if !v.trim().is_empty() => v.trim().parse::<usize>().unwrap_or_else(|_| {
            panic!(
                "PROBE_REDB_CACHE_BYTES={v:?} is not a bare byte count. Suffixes are NOT \
                 parsed — write 8388608, not 8m. Failing loudly rather than measuring a \
                 ceiling that was never applied."
            )
        }),
        _ => DEFAULT_REDB_CACHE_BYTES,
    }
}

/// Where the probe writes its redb fixtures.
///
/// **`/tmp` on oden is tmpfs — RAM.** A memory probe that puts a gigabyte of databases in
/// `std::env::temp_dir()` there is charging the box for its own fixtures, and the harness
/// on this machine had already leaked 96 GB into `/tmp` as abandoned store dirs before
/// anyone noticed. Set `PROBE_SCRATCH` to a real disk (md1). If the chosen directory is
/// tmpfs this says so out loud rather than quietly measuring itself — the fixtures land in
/// page cache and not in `Anonymous:`, so they do not corrupt THIS reading, but they do
/// consume the memory the probe exists to reason about.
fn scratch_root() -> std::path::PathBuf {
    let root = std::env::var("PROBE_SCRATCH")
        .ok()
        .filter(|s| !s.trim().is_empty())
        .map(std::path::PathBuf::from)
        .unwrap_or_else(std::env::temp_dir);
    if is_tmpfs(&root) {
        eprintln!(
            "  WARNING: {} is tmpfs (RAM). The redb fixtures for this run are being written \
             into memory. Set PROBE_SCRATCH to a real disk.",
            root.display()
        );
    }
    root
}

/// Is `p` under a tmpfs mount? Read out of `/proc/mounts` — a file read, no shelling.
/// Longest matching mount point wins, so `/tmp` beats `/`.
fn is_tmpfs(p: &Path) -> bool {
    let Ok(mounts) = std::fs::read_to_string("/proc/mounts") else { return false };
    let mut best: Option<(usize, bool)> = None;
    for line in mounts.lines() {
        let mut f = line.split_whitespace();
        let (_dev, point, fstype) = (f.next(), f.next(), f.next());
        let (Some(point), Some(fstype)) = (point, fstype) else { continue };
        if p.starts_with(point) {
            let len = point.len();
            if best.is_none_or(|(b, _)| len > b) {
                best = Some((len, fstype == "tmpfs"));
            }
        }
    }
    best.is_some_and(|(_, t)| t)
}

fn synthetic(n: usize, seed: u64) -> Vec<IndexEntry> {
    let mut s = seed | 1;
    let mut out = Vec::with_capacity(n);
    let mut offset = 12u64;
    for _ in 0..n {
        // xorshift — deterministic, no rand dep, and the oids must be well spread or
        // the redb btree degenerates and the page count stops resembling a real store.
        s ^= s << 13;
        s ^= s >> 7;
        s ^= s << 17;
        let mut oid = vec![0u8; 20];
        oid[..8].copy_from_slice(&s.to_le_bytes());
        oid[8..16].copy_from_slice(&s.rotate_left(21).to_le_bytes());
        let len = 64 + (s % 4096);
        out.push(IndexEntry {
            oid,
            offset,
            len,
            obj_type: ObjType::Blob,
            uncompressed_size: len * 3,
            delta_base: 0,
        });
        offset += len;
    }
    out
}

/// **THE REAL PATH — `ObjectReadStack` over redb, at the forge's shape.**
///
/// `repos` stacks, each carrying `rows` entries, each opened with the SAME
/// `DEFAULT_REDB_CACHE_BYTES` the server uses. The cache is deliberately left at its
/// default: shrinking it is a different experiment (one already queued elsewhere), and it
/// answers "is the cache the owner", not "is the plateau reachable" — which is this
/// question and has to be settled first.
fn real_stacks(dir: &Path, repos: usize, rows: usize) -> Phase {
    let base = anon_rss_kb();
    let mut stacks = Vec::with_capacity(repos);
    for r in 0..repos {
        let path = dir.join(format!("repo{r}.objects.tail.redb"));
        let stack = ObjectReadStack::<OneTableFourColumns>::open(
            &path,
            RebuildTriggers::manual(),
            probe_cache_bytes(),
        )
        .expect("open a read stack");
        let entries = synthetic(rows, 0x9E3779B97F4A7C15 ^ (r as u64));
        stack.append(&entries).expect("append");
        stack.rebuild().expect("rebuild");
        // WARM THE CACHE. Without reads the redb page cache holds almost nothing and the
        // probe would measure an empty cache and conclude the cache is small — the exact
        // way a memory probe lies.
        for e in entries.iter().step_by(7) {
            let _ = stack.lookup(&e.oid);
        }
        stacks.push(stack);
    }
    let live = anon_rss_kb();
    let held_mb = mb(live.saturating_sub(base));
    eprintln!(
        "  [live] {repos} stacks x {rows} rows = {:.1} MB anonymous, {:.1} MB per stack \
         (cache cap {} MiB/db)",
        held_mb,
        held_mb / repos as f64,
        probe_cache_bytes() / (1024 * 1024),
    );
    // THE READING THAT ANSWERS (a) WITHOUT DROPPING ANYTHING. If in-use is close to the
    // resident growth here, these bytes are genuinely live and reachable while the
    // handles are open — which is the forge's actual state, because its LRU bound (256)
    // never fires over ~20 repositories and nothing is ever released.
    eprintln!("  {}", mallinfo_line("[live]   "));
    drop(stacks);
    let dropped = anon_rss_kb();
    eprintln!("  {}", mallinfo_line("[dropped]"));
    malloc_trim();
    let trimmed = anon_rss_kb();
    eprintln!("  {}", mallinfo_line("[trimmed]"));
    Phase {
        what: format!("REAL — {repos} x ObjectReadStack over redb ({rows} rows each)"),
        base,
        live,
        dropped,
        trimmed,
    }
}

#[test]
#[ignore = "measurement: allocates ~1 GB and must not share a process with other tests"]
fn reachable_or_merely_unfreed() {
    eprintln!(
        "\n=== RSS RETENTION PROBE ===\nallocator: {} (no #[global_allocator] in \
         gunnar-server or znippy ⇒ system malloc)\n",
        if cfg!(target_env = "gnu") { "glibc malloc" } else { "NOT glibc — read the verdict with care" }
    );

    // ORDER MATTERS, AND GETTING IT WRONG COST A RUN. On the first pass the small-block
    // control ran FIRST, and both controls then held ~99% after drop — the guard below
    // fired and refused to attribute anything, correctly. The cause is not exotic: the
    // small control frees ~700 MB into the arena, `malloc_trim` returns those pages to
    // the kernel but the free CHUNKS remain in glibc's books, and the large control's
    // 1 MiB requests are then satisfied out of that free list instead of by `mmap`. A
    // control that never reached the mmap path could not measure the mmap path.
    //
    // Large first, on a fresh arena, is the only order in which the two are independent.
    let b = control_large_blocks(700).report();
    let a = control_small_blocks(700).report();

    let dir = tempfile::Builder::new()
        .prefix("znippy-rss-probe-")
        .tempdir_in(scratch_root())
        .expect("tempdir on the scratch root");
    let real = real_stacks(dir.path(), 20, 300_000).report();

    eprintln!("\n=== VERDICT ===");

    // ── THE CALIBRATION, and it is not the one this probe was first written with ──────
    //
    // The original guard here asserted the two controls would DISAGREE — small blocks
    // held in the arena, large blocks `munmap`ped straight back. **That premise is false
    // on this box**: with a fresh arena BOTH controls return ~100% of their bytes on
    // `drop` alone, before any trim. Recorded as a wrong prediction rather than quietly
    // deleted, because it is load-bearing: it means block size is NOT what separates the
    // controls from the real workload, so the real workload's retention needs a different
    // explanation (fragmentation across live allocations and per-thread arenas, which
    // top-of-heap trimming cannot reach — see the report).
    //
    // What the controls DO establish, and it is the only thing this probe needs from
    // them: **the instrument can see memory come back.** A probe that could not would
    // report "still resident" for everything and prove nothing. Both controls freeing
    // clean is that calibration.
    for c in [&b, &a] {
        assert!(
            c.held_after_drop_pct() < 10.0,
            "{}: a control that allocates and frees a known {:.0} MB left {:.1}% resident. \
             This probe cannot detect memory being returned, so NOTHING below can be \
             attributed — the reading is about the instrument, not the workload.",
            c.what,
            mb(c.live.saturating_sub(c.base)),
            c.held_after_drop_pct()
        );
    }
    eprintln!(
        "instrument OK: both controls returned their bytes on drop ({:.1}% / {:.1}% left), \
         so a return IS visible to this probe.",
        b.held_after_drop_pct(),
        a.held_after_drop_pct()
    );

    let held_drop = real.held_after_drop_pct();
    let held_trim = real.held_after_trim_pct();
    // ── (a) — asked of the LIVE reading, which is the forge's actual state ────────────
    //
    // The forge's LRU bound is DEFAULT_OPEN_REPOS = 256 against ~20 repositories, so it
    // never fires and NOTHING IS EVER RELEASED there. The live in-use figure is therefore
    // the one that describes production, and the drop/trim pair below describes what
    // would happen if eviction were fixed.
    eprintln!(
        "(a) while LIVE: {:.1} MB resident for {} databases — see the [live] in-use line \
         above. If in-use tracks resident, these bytes are REACHABLE and deliberately \
         held, and the owner is the per-database redb page cache at its \
         {} MiB cap.",
        mb(real.live.saturating_sub(real.base)),
        20,
        probe_cache_bytes() / (1024 * 1024),
    );

    // ── (b) — asked of the drop/trim pair ────────────────────────────────────────────
    if held_drop < 30.0 {
        eprintln!(
            "(b) NO: dropping the stacks returned {:.1}% on its own, so releasing a store \
             does give RSS back and eviction alone would be a complete fix.",
            100.0 - held_drop
        );
    } else if held_trim < held_drop - 20.0 {
        eprintln!(
            "(b) YES — VERIFIED. {held_drop:.1}% of the bytes survived dropping EVERY handle, \
             and malloc_trim(0) then returned them, leaving {held_trim:.1}%. The program owns \
             nothing at that point; glibc was sitting on the pages.\n\
             \n\
             CONSEQUENCE, and it is the reason two eviction fixes changed nothing: eviction \
             and trim are BOTH required. Evicting without trimming frees bytes the OS never \
             gets back, so RSS stays flat and the fix looks like it failed. Trimming without \
             evicting has nothing to trim, because a bound that never fires never frees."
        );
    } else {
        eprintln!(
            "(b) NO: {held_drop:.1}% survived the drop AND {held_trim:.1}% survived \
             malloc_trim. Something still owns these bytes after every handle is gone. \
             Find the owner."
        );
    }
    eprintln!();
}