Skip to main content

znippy_plugin_git/
read_stack.rs

1//! **One read stack, not three components.**
2//!
3//! A git object lookup in this crate goes through a single stack with three
4//! surfaces, and the whole point of putting them in one type is that they cannot
5//! be reasoned about — or drift — separately:
6//!
7//! 1. **The Ragnar `stree` is the fast path.** It is not a cache in front of an
8//!    index; it *is* the index's key surface, [`crate::oid_index`]'s 8-byte-prefix
9//!    static B-tree, reached through whichever [`ObjectIndex`] arm the stack was
10//!    parameterised with. No third oid index is written here (LAW 5).
11//! 2. **The Arrow tables are what it points into.** The stree resolves an oid to
12//!    an ordinal; the ordinal addresses the five facts in the Arrow IPC columns
13//!    of [`FourTables`](crate::index_layout::FourTables) or
14//!    [`OneTableFourColumns`]. Ordinal and column live in the same object and are
15//!    replaced together.
16//! 3. **redb is what it falls through to, and redb always answers.** Every object
17//!    ever appended is in redb. The stree+Arrow surface is a *projection* of a
18//!    prefix of it, rebuilt on a trigger. So a stree miss is never an answer — it
19//!    is a routing decision, and the row is fetched from redb. The single
20//!    exception is not an exception to the rule but a proof of it: when the
21//!    projection covers **every** row, "the stree missed" and "the repository
22//!    does not have it" are the same statement, and the trip is skipped. See
23//!    [`ObjectReadStack::projection_is_complete`].
24//!
25//! This is [`skade`'s arrangement](../../../../skade/src/static_index.rs) copied
26//! rather than reinvented: `StaticIndex` (STree64 over snapshot ids) in front,
27//! the redb commit log behind, `resolve_many` doing one pipelined tree pass and
28//! then one redb transaction for the slots the tree did not fill. The names
29//! differ because the keys differ (oids, not snapshot ids); the shape does not.
30//!
31//! ## Scope: one stack per REPOSITORY
32//!
33//! Not per account. A git negotiation — `have`/`want`, the push connectivity
34//! check — is scoped to one repository, and an oid means nothing outside the
35//! repository that stores it. [`ObjectReadStack::open`] therefore takes the path
36//! of one repository's tail database.
37//!
38//! ## Why the projection may be incomplete and can still be trusted
39//!
40//! **The archive is append-only.** An object's five facts are written once and
41//! never rewritten — [`ObjectReadStack::append`] *refuses* a rewrite that would
42//! change them rather than accepting it (that refusal is what makes the rest of
43//! this true, and it is asserted in
44//! [`tests::an_append_only_violation_is_refused`]). Given that:
45//!
46//! * a **hit** in the stree is always valid — the row it points at cannot have
47//!   become stale, because nothing may change it;
48//! * a **miss** carries no information at all — it means "not in this
49//!   projection", which is not the same as "not in this repository";
50//! * so the projection is allowed to lag, and the only cost of lag is the redb
51//!   fall-through on the rows it has not absorbed yet.
52//!
53//! That asymmetry is the entire reason a *trigger* is sound where a
54//! rebuild-per-write would be ruinous.
55//!
56//! ## The rebuild trigger
57//!
58//! Two triggers, both in [`RebuildTriggers`], either one fires:
59//!
60//! * **misses** — lookups the stree missed **and redb answered**, against a
61//!   threshold relative to the size of the projection. Genuine absences are
62//!   counted separately in `absent` and deliberately do NOT count: a `have`
63//!   negotiation is mostly misses, and if those drove the trigger, a busy
64//!   read-only repository would rebuild for ever without a single new object
65//!   having arrived.
66//! * **volume** — stored object bytes appended since the last rebuild.
67//!
68//! Defaults, and the measurements they were derived from, are on
69//! [`RebuildTriggers::DEFAULT_TAIL_HITS_PER_ROW`],
70//! [`RebuildTriggers::DEFAULT_MIN_TAIL_HITS`] and
71//! [`RebuildTriggers::DEFAULT_TAIL_BYTES`].
72//!
73//! ## Threads
74//!
75//! There are none here. A rebuild runs inline on the [`append`](ObjectReadStack::append)
76//! that trips the trigger, or on an explicit [`rebuild`](ObjectReadStack::rebuild).
77//! skade rebuilds on a detached thread; the sanctioned home for a detached thread
78//! in this constellation is `gatling::background::Job` (LAW 3 — rayon is banned
79//! and so is a hand-rolled pool), and `Job` is join-required with no completion
80//! probe, so "in the background" would mean picking an arbitrary later call to
81//! block on. Inline on a trigger is deterministic and, with the defaults below,
82//! rare. An owner who wants the overlap can drive [`rebuild`](ObjectReadStack::rebuild)
83//! from a `Job` itself — it takes `&self` and swaps under an `RwLock`.
84
85use std::fs::{self, File};
86use std::io::Write;
87use std::path::{Path, PathBuf};
88use std::sync::atomic::{AtomicU64, Ordering};
89use std::sync::{Arc, Mutex, RwLock};
90
91use anyhow::{Context, Result, anyhow, bail};
92use redb::{Database, ReadableDatabase, ReadableTable, ReadableTableMetadata, TableDefinition};
93use znippy_common::arrow::buffer::Buffer;
94
95use crate::index_layout::{IndexEntry, IndexRow, ObjType, ObjectIndex, OneTableFourColumns};
96
97// ── the durable tail ──────────────────────────────────────────────────────────
98
99/// `oid → packed row`. The key is the raw oid (20 or 32 bytes), so redb's own
100/// B-tree order is oid-lexicographic order — the same order the stree and the
101/// Arrow columns are in, which makes a rebuild a straight ordered scan.
102const OBJECTS: TableDefinition<&[u8], &[u8]> = TableDefinition::new("objects");
103
104/// Small counters. `arrival_seq` is the only one that must survive a reopen: it
105/// is what gives a tail row an ordinal that no other tail row shares.
106const META: TableDefinition<&str, u64> = TableDefinition::new("meta");
107const META_ARRIVAL_SEQ: &str = "arrival_seq";
108/// A durable, monotonically increasing marker of the OBJECTS table's **content
109/// state**: bumped by one, inside the very same redb write transaction, whenever
110/// a commit changes the set of oids — an [`ObjectReadStack::append`] that inserts
111/// at least one new row, or a [`ObjectReadStack::retain`] that drops at least one.
112///
113/// It was introduced as the validity key of the `objects.ordinal` memo (S-082),
114/// which is gone since 2026-09-04 — the persisted index's oid column is the
115/// ordinal space now, current by construction. The epoch stays: it is the one
116/// cheap, sound, crash-consistent "has the oid set changed" signal this store
117/// has. `arrival_seq` alone will not do — it counts inserts and is blind to
118/// `retain`'s removals — and a blake3 over the oid set is a full scan. Because
119/// the bump shares the mutation's transaction, `epoch` names the OBJECTS content
120/// uniquely across this store's life: equal epochs ⟹ an unchanged set. See
121/// [`ObjectReadStack::tail_epoch_and_len`].
122const META_TAIL_EPOCH: &str = "tail_epoch";
123
124/// **The layout [`encode_row`] wrote these rows with.**
125///
126/// [`decode_row`] refuses a row whose width is not [`TAIL_ROW_BYTES`] — on
127/// purpose, because reinterpreting an unknown layout is how a wrong offset gets
128/// into an index and is then queried and believed. But a refusal with nothing
129/// behind it is a store that has stopped answering, and until this key existed
130/// that is exactly what a layout change produced: every read `bail`ing, with no
131/// way back short of deleting the repository.
132///
133/// There is a way back, and it costs nothing to take, because **the tail is a
134/// cache** (see [`decode_row`]) and the store already knows how to rebuild one:
135/// `Absorber::adopt_journal` derives what is unabsorbed as *"its extent is in
136/// the journal and its rows are not in the index"* and requeues it. Empty the
137/// OBJECTS table and every pack is unabsorbed by that definition, so the drain
138/// re-indexes the archive and reads fall back to the pack meanwhile — **slower,
139/// never wrong**, which is the same path a crash mid-absorb already takes.
140///
141/// So a version mismatch is a **drop and rebuild**, not a failure. Absent is
142/// treated as [`TAIL_FORMAT`] rather than as "old": every tail in existence when
143/// this key was introduced was written by the layout it names, so stamping them
144/// silently is correct and costs no rebuild. The next change to the row bumps
145/// the constant and those stores rebuild once.
146const META_TAIL_FORMAT: &str = "tail_format";
147
148/// The current value of [`META_TAIL_FORMAT`]. **Bump this in the same commit as
149/// any change to [`TAIL_ROW_BYTES`] or to what [`encode_row`] puts in it.**
150///
151/// `1` is the layout in use since the `delta_base` field landed (PLAN §13) — the
152/// 41-byte row this constant was introduced beside, not a new one.
153const TAIL_FORMAT: u64 = 1;
154
155/// `seq | offset | len | type | uncompressed_size | delta_base`, little-endian,
156/// fixed width.
157///
158/// ```text
159///   0  u64  seq (arrival, not a fact about the object)
160///   8  u64  offset
161///  16  u64  len
162///  24  u8   object type code
163///  25  u64  uncompressed_size
164///  33  u64  delta_base — archive offset of the base entry, 0 for none
165///                                                                     ══
166///                                                                     41
167/// ```
168///
169/// **This was 33 bytes before `delta_base` (PLAN §13).** A tail written by the
170/// older encoding is refused by [`decode_row`] on its length rather than
171/// misread: the tail is a cache the whole of §14 permits dropping and
172/// rebuilding, so a hard refusal is the right failure and a silent
173/// reinterpretation is not.
174const TAIL_ROW_BYTES: usize = 41;
175
176/// Set on the ordinal of every row that came from the tail rather than from the
177/// Arrow projection.
178///
179/// An ordinal is a **row address within one generation**, not an identity — the
180/// oid is the identity. A rebuild re-derives every ordinal as the new
181/// oid-lexicographic rank, so an ordinal held across a rebuild is meaningless.
182/// The high bit makes the dangerous version of that mistake impossible instead
183/// of merely documented: a tail ordinal used to index an Arrow column is ≥ 2^31
184/// and therefore out of bounds — a panic — rather than a silently wrong row.
185pub const TAIL_ORDINAL_BIT: u32 = 0x8000_0000;
186
187/// True when `row` was answered by the redb tail rather than by the projection.
188pub fn is_tail_row(row: &IndexRow) -> bool {
189    is_tail_ordinal(row.ordinal)
190}
191
192/// The same question of a bare ordinal.
193#[inline]
194pub fn is_tail_ordinal(ordinal: u32) -> bool {
195    ordinal & TAIL_ORDINAL_BIT != 0
196}
197
198fn tail_ordinal(seq: u64) -> u32 {
199    TAIL_ORDINAL_BIT | (seq as u32 & !TAIL_ORDINAL_BIT)
200}
201
202fn encode_row(seq: u64, e: &IndexEntry) -> [u8; TAIL_ROW_BYTES] {
203    let mut b = [0u8; TAIL_ROW_BYTES];
204    b[0..8].copy_from_slice(&seq.to_le_bytes());
205    b[8..16].copy_from_slice(&e.offset.to_le_bytes());
206    b[16..24].copy_from_slice(&e.len.to_le_bytes());
207    b[24] = e.obj_type.code();
208    b[25..33].copy_from_slice(&e.uncompressed_size.to_le_bytes());
209    b[33..41].copy_from_slice(&e.delta_base.to_le_bytes());
210    b
211}
212
213/// One decoded tail row. `seq` is the arrival sequence, not a fact about the
214/// object.
215#[derive(Debug, Clone, Copy, PartialEq, Eq)]
216struct TailRow {
217    seq: u64,
218    offset: u64,
219    len: u64,
220    obj_type: ObjType,
221    uncompressed_size: u64,
222    /// Archive offset of the base entry, `0` for none. An offset and not an
223    /// ordinal precisely because of this table: a tail ordinal is
224    /// [`TAIL_ORDINAL_BIT`]-tagged and a rebuild re-derives every ordinal, so an
225    /// ordinal stored here would be meaningless one rebuild later.
226    delta_base: u64,
227}
228
229fn decode_row(b: &[u8]) -> Result<TailRow> {
230    if b.len() != TAIL_ROW_BYTES {
231        bail!("tail row is {} bytes, expected {TAIL_ROW_BYTES}", b.len());
232    }
233    let u64_at = |i: usize| {
234        let mut w = [0u8; 8];
235        w.copy_from_slice(&b[i..i + 8]);
236        u64::from_le_bytes(w)
237    };
238    Ok(TailRow {
239        seq: u64_at(0),
240        offset: u64_at(8),
241        len: u64_at(16),
242        // A code the writer cannot produce must not be invented on the way out:
243        // a wrong type in an index is worse than a failure, because it is
244        // queried and believed.
245        obj_type: ObjType::from_code(b[24])
246            .ok_or_else(|| anyhow!("tail row carries object type code {}", b[24]))?,
247        uncompressed_size: u64_at(25),
248        delta_base: u64_at(33),
249    })
250}
251
252impl TailRow {
253    fn as_index_row(&self) -> IndexRow {
254        IndexRow {
255            ordinal: tail_ordinal(self.seq),
256            offset: self.offset,
257            len: self.len,
258            obj_type: self.obj_type,
259            uncompressed_size: self.uncompressed_size,
260            delta_base: self.delta_base,
261        }
262    }
263
264    /// The same facts as an [`IndexEntry`], for the rebuild scan.
265    fn as_entry(&self, oid: &[u8]) -> IndexEntry {
266        IndexEntry {
267            oid: oid.to_vec(),
268            offset: self.offset,
269            len: self.len,
270            obj_type: self.obj_type,
271            uncompressed_size: self.uncompressed_size,
272            delta_base: self.delta_base,
273        }
274    }
275}
276
277// ── the trigger ───────────────────────────────────────────────────────────────
278
279/// When the Arrow/stree projection is rebuilt from the redb tail. Either
280/// trigger fires; both are per repository and per generation.
281#[derive(Debug, Clone, Copy, PartialEq)]
282pub struct RebuildTriggers {
283    /// The miss trigger, as a multiple of the rows already in the projection.
284    /// See [`DEFAULT_TAIL_HITS_PER_ROW`](Self::DEFAULT_TAIL_HITS_PER_ROW) for why
285    /// it is relative and not a constant.
286    pub tail_hits_per_row: f64,
287    /// Floor and off-switch for the miss trigger: the threshold is never below
288    /// this, and **`0` disables the miss trigger entirely** whatever
289    /// `tail_hits_per_row` says.
290    pub min_tail_hits: u64,
291    /// Rebuild once this many stored object bytes have been appended since the
292    /// last rebuild. `0` disables the volume trigger. On a persisting stack this
293    /// is the **floor** of the volume threshold; see `fold_ratio`.
294    pub tail_bytes: u64,
295    /// **On a persisting stack, the volume threshold scales with the index**:
296    /// a fold fires once the object bytes appended since the last fold reach
297    /// `fold_ratio ×` the persisted index's own size (never below `tail_bytes`).
298    /// `0.0` leaves the fixed floor alone. See
299    /// [`DEFAULT_FOLD_RATIO`](Self::DEFAULT_FOLD_RATIO) for why the value is 1.
300    /// Ignored on a stack that does not persist — there a rebuild writes nothing
301    /// and the fixed 64 MiB is the right cadence.
302    pub fold_ratio: f64,
303}
304
305impl RebuildTriggers {
306    /// **1.0 — one tail-served lookup per row in the projection.**
307    ///
308    /// The miss trigger is relative because the two costs it balances scale
309    /// differently, and a single constant is therefore wrong at one end or the
310    /// other. Both costs MEASURED on oden 2026-08-07, 1-min loadavg 2.1–2.2,
311    /// `examples/read_stack_bench.rs`, sha1 oids, batch 1000, 3 runs per cell,
312    /// worst run-to-run spread 24.9%:
313    ///
314    /// | | 100 000 objects | 1 000 000 objects |
315    /// |---|---:|---:|
316    /// | lookup the projection answers | 80 ns | 221 ns |
317    /// | lookup the tail answers | 463 ns | 937 ns |
318    /// | **the fall-through costs** | **383 ns** | **716 ns** |
319    /// | rebuild (ordered tail scan + projection build) | 40.9 ms | 547.9 ms |
320    /// | **per row in the repository** | **409 ns** | **548 ns** |
321    /// | break-even, tail-served lookups per row | **1.07** | **0.77** |
322    ///
323    /// The rebuild has paid for itself once the fall-through has carried roughly
324    /// one lookup per row, at both sizes and an order of magnitude apart — which
325    /// is why the ratio is the right shape for this trigger and **1.0** is the
326    /// right value in it. A fixed 4096 would rebuild a million-object repository
327    /// (0.55 s) to save 4096 × 716 ns ≈ 2.9 ms — 190× the wrong way.
328    ///
329    /// Re-measure if the tail engine or the projection build changes: this is a
330    /// ratio of two measured costs and nothing else.
331    pub const DEFAULT_TAIL_HITS_PER_ROW: f64 = 1.0;
332
333    /// **4096 — the floor under the relative threshold.**
334    ///
335    /// On an empty or nearly-empty projection the ratio above is ~0 and would
336    /// rebuild on the first miss, over and over, during exactly the period when
337    /// objects are still arriving. 4096 misses is ~1.6 ms of fall-through at the
338    /// measured 100 000-object price — cheap enough to be worth waiting for on
339    /// any repository, and enough that a burst of small pushes coalesces into
340    /// one rebuild.
341    pub const DEFAULT_MIN_TAIL_HITS: u64 = 4096;
342
343    /// **64 MiB of appended object bytes.**
344    ///
345    /// A git push is a packfile, and the pack is the unit that lands in the tail.
346    /// 64 MiB is comfortably more than one ordinary push and less than a big
347    /// one, so a busy repository rebuilds on the order of once per large push
348    /// rather than once per push — while a repository taking a 2 GiB initial
349    /// import rebuilds ~32 times over that import instead of once at the end,
350    /// which is what keeps the fall-through from carrying the whole import.
351    /// It is the trigger that carries the normal case; the miss trigger is the
352    /// backstop for a repository that is read hard and written rarely.
353    pub const DEFAULT_TAIL_BYTES: u64 = 64 * 1024 * 1024;
354
355    /// **1.0 — fold when the bytes appended since the last fold equal the
356    /// index's own size.**
357    ///
358    /// Since 2026-09-04 a rebuild on a persisting stack WRITES the projection
359    /// (`objects.index`), so the cadence above stopped being free. MEASURED on
360    /// the kernel mirror with the fixed 64 MiB floor alone: an 8.72 GB import
361    /// folded ≈136 times, each rewriting the whole growing file — ≈51 GB of
362    /// sequential writes for a 747 MB index (INFERRED from the arithmetic; ≥18
363    /// distinct file sizes OBSERVED at 10 s sampling). The ack did not slow on
364    /// oden's NVMe; on the appliance's disks it would.
365    ///
366    /// A ratio of 1 gives a bound that is easy to say and holds by construction:
367    /// **the fold's total writes never exceed the push's own bytes.** Each fold
368    /// writes an index roughly the size of the bytes that triggered it, so the
369    /// sum of index writes over an import is ≈ the bytes imported — ~8.7 GB for
370    /// the kernel instead of ~51, and the number of folds falls from ~136 to
371    /// O(log n). The price is the redb tail's high-water mark: with the index at
372    /// 53.5 B/object and pack bytes at ~620 B/object, "appended bytes = index
373    /// bytes" is ≈ 8.6 % of the folded rows in redb before a fold, i.e. ~1.2 M
374    /// rows ≈ 250 MB of redb rows on the kernel mirror (redb allocates in
375    /// doublings, so a ~512 MiB file) against 270 MB with the floor alone.
376    /// Re-measure on the rig after changing either number; both are in
377    /// `.nornir/storage-levers-2026-09-03.md`.
378    pub const DEFAULT_FOLD_RATIO: f64 = 1.0;
379
380    /// The miss threshold for a projection of `projection_rows` rows, or `None`
381    /// when the miss trigger is off.
382    pub fn miss_threshold(&self, projection_rows: u64) -> Option<u64> {
383        if self.min_tail_hits == 0 {
384            return None;
385        }
386        let scaled = (self.tail_hits_per_row * projection_rows as f64) as u64;
387        Some(scaled.max(self.min_tail_hits))
388    }
389
390    /// Both triggers off: the projection is rebuilt only when asked.
391    pub fn manual() -> Self {
392        Self {
393            tail_hits_per_row: 0.0,
394            min_tail_hits: 0,
395            tail_bytes: 0,
396            fold_ratio: 0.0,
397        }
398    }
399}
400
401impl Default for RebuildTriggers {
402    fn default() -> Self {
403        Self {
404            tail_hits_per_row: Self::DEFAULT_TAIL_HITS_PER_ROW,
405            fold_ratio: Self::DEFAULT_FOLD_RATIO,
406            min_tail_hits: Self::DEFAULT_MIN_TAIL_HITS,
407            tail_bytes: Self::DEFAULT_TAIL_BYTES,
408        }
409    }
410}
411
412/// Which threshold fired, with the value that fired it. Returned rather than
413/// logged, so a caller can record *why* a rebuild happened.
414#[derive(Debug, Clone, Copy, PartialEq, Eq)]
415pub enum RebuildReason {
416    /// `n` lookups had to be answered by the tail.
417    TailHits(u64),
418    /// `n` bytes of objects were appended since the last rebuild.
419    TailBytes(u64),
420    /// [`ObjectReadStack::rebuild`] was called directly.
421    Explicit,
422}
423
424/// What [`ObjectReadStack::oids_raw`] hands back: the ordinal space and its
425/// provenance.
426#[derive(Debug, Clone, PartialEq, Eq)]
427pub struct OrdinalSpace {
428    /// Every oid, fixed width, back to back, in oid order.
429    pub raw: Vec<u8>,
430    /// Oids that came straight off the persisted projection's column.
431    pub folded_rows: u64,
432    /// Oids that came off a scan of the redb tail — the rows since the last
433    /// fold, or every row on a stack that does not persist.
434    pub tail_rows: u64,
435}
436
437/// A snapshot of the stack's counters. Everything here is applied output — rows
438/// that exist, lookups that happened — not configuration echoed back.
439#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
440pub struct StackStats {
441    /// Objects in the Arrow/stree projection.
442    pub sealed_rows: u64,
443    /// Objects in the repository. Always ≥ `sealed_rows`; the difference is the
444    /// un-absorbed tail.
445    pub total_rows: u64,
446    /// Lookups the stree missed and redb answered, since the last rebuild.
447    pub tail_hits: u64,
448    /// Lookups nothing answered — the object is not in this repository. Never
449    /// drives the trigger.
450    pub absent: u64,
451    /// Rows the projection does not cover yet. **Zero means the projection *is*
452    /// the truth**, which is what lets a miss be answered without a redb round
453    /// trip at all.
454    pub unabsorbed_rows: u64,
455    /// redb read transactions opened by the read path since the last rebuild.
456    /// The number that shows whether the completeness fast path is actually
457    /// being taken; there is no other way to see it from outside.
458    pub tail_txns: u64,
459    /// Object bytes appended since the last rebuild.
460    pub tail_bytes: u64,
461    /// Projections built over this stack's lifetime, including the one at open.
462    pub rebuilds: u64,
463    /// Folds that persisted the projection and emptied redb of what it holds.
464    /// `0` on an in-memory stack and on an arm that cannot be persisted.
465    pub folds: u64,
466    /// `1` when this stack's open mapped `objects.index` and skipped the redb
467    /// scan-and-build; `0` when it had to rebuild. The applied-output proof
468    /// that a warm open is warm.
469    pub persisted_loads: u64,
470    /// Bumped by every rebuild. An [`IndexRow::ordinal`] is only meaningful
471    /// within one generation.
472    pub generation: u64,
473}
474
475// ── the stack ─────────────────────────────────────────────────────────────────
476
477/// The read stack: Ragnar stree → Arrow columns → redb, in one object.
478///
479/// Parameterised by the Arrow layout so it drops into
480/// `examples/index_layout_bench.rs` against
481/// [`FourTables`](crate::index_layout::FourTables) and [`OneTableFourColumns`]
482/// unchanged — it implements the same [`ObjectIndex`] trait, and
483/// [`ObjectIndex::build`] produces a stack whose projection already covers every
484/// entry, so on that benchmark it is those arms plus one redb round trip per
485/// genuine miss.
486pub struct ObjectReadStack<S: ObjectIndex = OneTableFourColumns> {
487    /// The durable truth. Every object ever appended is here.
488    tail: Arc<Database>,
489    /// The projection. Replaced wholesale by a rebuild, never mutated in place —
490    /// which is what lets a reader hold it briefly without coordinating.
491    projection: RwLock<Arc<S>>,
492    triggers: RebuildTriggers,
493    total_rows: AtomicU64,
494    tail_hits: AtomicU64,
495    absent: AtomicU64,
496    tail_bytes: AtomicU64,
497    rebuilds: AtomicU64,
498    generation: AtomicU64,
499    /// Uncompressed bytes of the rows the projection has **not** absorbed.
500    ///
501    /// This is what makes [`ObjectIndex::sum_uncompressed`] exact without a scan.
502    /// The projection is, by definition, every row that existed at the last
503    /// rebuild; append-only means none of them can have changed since; so the
504    /// repository's total is the projection's column scan plus the rows appended
505    /// after it, and those are counted here as they arrive. Reset by a rebuild,
506    /// which is the moment they stop being un-absorbed.
507    unabsorbed_size: AtomicU64,
508    /// The same trick for [`ObjectIndex::count_type`], indexed by
509    /// [`ObjType::code`] (1–4, 6, 7; slots 0 and 5 stay zero).
510    unabsorbed_types: [AtomicU64; 8],
511    /// Rows the projection does not cover. **Zero is the interesting value**:
512    /// see [`ObjectReadStack::projection_is_complete`].
513    unabsorbed_rows: AtomicU64,
514    /// redb read transactions the read path has opened since the last rebuild.
515    tail_txns: AtomicU64,
516    /// **Where the projection is persisted** — `objects.index` beside the redb
517    /// tail — or `None` for an in-memory stack and for an arm whose
518    /// [`ObjectIndex::PERSISTS`] is false. When `Some`, every rebuild is a
519    /// **fold**: the projection is written here, then the rows it holds are
520    /// removed from redb, so redb is the tail in fact and not only in name.
521    persisted: Option<PathBuf>,
522    /// Serialises the fold against `append` and `retain`. Held for the whole
523    /// of a fold, and by `append` around its transaction, so the counters a
524    /// fold resets cannot be bumped underneath it by a row it did not see —
525    /// which would leave `unabsorbed_rows` at 0 over a row the projection
526    /// lacks, and `projection_is_complete` would then answer `absent` for an
527    /// object the repository holds.
528    fold: Mutex<()>,
529    /// Folds that wrote `persisted`, over this stack's lifetime.
530    folds: AtomicU64,
531    /// Opens that took the projection off `persisted` instead of rebuilding
532    /// it from a redb scan. `1` after a warm open, `0` after a cold one.
533    persisted_loads: AtomicU64,
534}
535
536impl<S: ObjectIndex> ObjectReadStack<S> {
537    /// True when this stack folds: it has somewhere to persist to and an arm
538    /// that can be persisted.
539    #[inline]
540    fn persists(&self) -> bool {
541        self.persisted.is_some()
542    }
543
544    /// Open (or create) one repository's stack at `tail_path`, warm-starting the
545    /// projection from everything already in the tail.
546    /// `cache_bytes` is redb's page-cache ceiling for **this** database. A
547    /// parameter and not a `getenv` down here, so the whole store costs one
548    /// environment read at construction and none below it; see
549    /// [`crate::arms::redb_cache_bytes`], and note that the stock
550    /// `Database::create` would take redb's 1 GiB default instead.
551    ///
552    /// A tail written by redb 2 (every store before 2026-10-06) is migrated to
553    /// redb 4 here, once, before it is opened — see [`migrate_redb2_tail`].
554    pub fn open(tail_path: &Path, triggers: RebuildTriggers, cache_bytes: usize) -> Result<Self> {
555        let create = || Database::builder().set_cache_size(cache_bytes).create(tail_path);
556        let db = match create() {
557            Err(redb::DatabaseError::UpgradeRequired(found)) => {
558                migrate_redb2_tail(tail_path, found, cache_bytes)?;
559                create()
560            }
561            other => other,
562        }
563        .with_context(|| format!("opening object tail at {}", tail_path.display()))?;
564        // `objects.tail` → `objects.index`: the persisted projection sits beside
565        // the redb tail it is folded from. Only for an arm that can be written
566        // down; the others keep every row in redb, as before 2026-09-04.
567        let persisted = S::persists().then(|| tail_path.with_extension("index"));
568        Self::from_db(db, triggers, persisted, Some(tail_path.to_path_buf()))
569    }
570
571    /// Where this stack persists its projection, if it does.
572    pub fn persisted_path(&self) -> Option<&Path> {
573        self.persisted.as_deref()
574    }
575
576    /// **The name the built projection reports about itself** — the applied
577    /// output that says which [`crate::arms::IndexArm`] actually got built.
578    ///
579    /// Deliberately not [`ObjectIndex::name`] on the stack, which answers
580    /// `"ObjectReadStack"` for every arm because that is what the stack is. `S`
581    /// is the layout, and only `S` can say which one it is. It is the value
582    /// [`crate::arms::IndexArm::projection_name`] is written to be compared
583    /// against, so a caller that selected an arm can prove the selection took
584    /// rather than echo the selector back to itself.
585    pub fn projection_name(&self) -> &'static str {
586        self.projection.read().expect("projection lock").name()
587    }
588
589    /// A stack whose tail has no file behind it. Used by [`ObjectIndex::build`]
590    /// and by benchmarks; the redb code path is identical, only the backend
591    /// differs, so a test of this stack is a test of the durable one.
592    pub fn in_memory(triggers: RebuildTriggers) -> Result<Self> {
593        let db = Database::builder()
594            .create_with_backend(redb::backends::InMemoryBackend::new())
595            .context("creating an in-memory object tail")?;
596        Self::from_db(db, triggers, None, None)
597    }
598
599    fn from_db(
600        db: Database,
601        triggers: RebuildTriggers,
602        persisted: Option<PathBuf>,
603        tail_path: Option<PathBuf>,
604    ) -> Result<Self> {
605        // Materialise both tables so a read on a brand-new database does not
606        // trip `TableDoesNotExist`.
607        let w = db.begin_write()?;
608        let dropped = {
609            let _ = w.open_table(OBJECTS)?;
610            let mut meta = w.open_table(META)?;
611            let found = meta.get(META_TAIL_FORMAT)?.map(|v| v.value());
612            match found {
613                // Written by this layout, or by the layout in use when the key
614                // was introduced (see `META_TAIL_FORMAT`): nothing to do.
615                Some(TAIL_FORMAT) => 0,
616                None => {
617                    meta.insert(META_TAIL_FORMAT, TAIL_FORMAT)?;
618                    0
619                }
620                // A DIFFERENT layout. Every row in OBJECTS is unreadable by
621                // `decode_row`, so the tail is emptied here — in the same
622                // transaction that stamps the new version, so a crash between
623                // them cannot leave rows of one layout under the mark of
624                // another. `adopt_journal` then sees every pack as unabsorbed
625                // and requeues it.
626                Some(other) => {
627                    let mut objects = w.open_table(OBJECTS)?;
628                    let n = objects.len()?;
629                    objects.retain(|_, _| false)?;
630                    meta.insert(META_TAIL_FORMAT, TAIL_FORMAT)?;
631                    // The oid set changed, so the content epoch moves with it.
632                    let epoch = meta.get(META_TAIL_EPOCH)?.map(|v| v.value()).unwrap_or(0);
633                    meta.insert(META_TAIL_EPOCH, epoch + 1)?;
634                    tracing::warn!(
635                        target: "znippy.git",
636                        found = other,
637                        expected = TAIL_FORMAT,
638                        rows = n,
639                        "the object tail was written in a different layout; dropping it and \
640                         re-indexing from the journal. Reads fall back to the pack until the \
641                         drain catches up — slower, never wrong"
642                    );
643                    n
644                }
645            }
646        };
647        w.commit()?;
648        let _ = dropped;
649        let mut db = db;
650
651        // ── the warm open ────────────────────────────────────────────────────
652        //
653        // If a persisted projection exists, it IS the projection: mapped, not
654        // rebuilt. redb then holds only the rows that arrived after the last
655        // fold, and those are served by the fall-through exactly as an
656        // un-absorbed row always was. The scan below is over that remainder —
657        // small by construction — not over the repository.
658        //
659        // The one thing to reconcile is the crash window a fold leaves: the
660        // file is renamed into place BEFORE redb forgets the rows it holds, so
661        // a crash between the two leaves the same row in both. Rows redb has
662        // that the file also has are removed here, in one transaction, so the
663        // two layers are disjoint again and `len` / `oids_in_order` count each
664        // object once. The other direction — file missing rows redb dropped —
665        // cannot happen, because the delete only ever follows a completed
666        // rename.
667        let folded: Option<(Arc<S>, u128, u128)> = match persisted.as_deref() {
668            Some(path) => {
669                // A `.tmp` left by a fold that died before its rename is not a
670                // projection and must not become one by accident.
671                let _ = fs::remove_file(tmp_path(path));
672                load_persisted::<S>(path)
673            }
674            None => None,
675        };
676        let t_scan = std::time::Instant::now();
677        let mut entries = scan(&db)?;
678        let mut migrated = 0u64;
679        let (projection, unfolded, loaded) = match folded {
680            Some((folded, map_ms, build_ms)) => {
681                let dup: Vec<Vec<u8>> = entries
682                    .iter()
683                    .filter(|e| folded.lookup(&e.oid).is_some())
684                    .map(|e| e.oid.clone())
685                    .collect();
686                if !dup.is_empty() {
687                    let w = db.begin_write()?;
688                    {
689                        let mut objects = w.open_table(OBJECTS)?;
690                        for oid in &dup {
691                            objects.remove(oid.as_slice())?;
692                        }
693                    }
694                    w.commit()?;
695                    tracing::warn!(
696                        target: "znippy.git",
697                        rows = dup.len(),
698                        "the object tail held rows the persisted index already has — a fold \
699                         was interrupted between writing the index and emptying the tail; \
700                         the duplicates are dropped from the tail, nothing is lost"
701                    );
702                    entries.retain(|e| folded.lookup(&e.oid).is_none());
703                }
704                // The three costs of a warm open, separately, so the next lever
705                // (persisting the stree section) is judged on the number it
706                // would actually remove: `build_ms` is the O(n) stree build over
707                // the mapped oid column; `map_ms` is the O(1) open+mmap;
708                // `tail_ms` is the redb scan of the rows since the last fold
709                // plus the reconcile. `stree_bytes` is what that build produces
710                // and what persisting it would have to write.
711                tracing::info!(
712                    target: "znippy.git",
713                    folded = folded.len(),
714                    tail = entries.len(),
715                    map_ms,
716                    build_ms,
717                    tail_ms = t_scan.elapsed().as_millis(),
718                    stree_bytes = folded.resident_bytes().saturating_sub(folded.ipc_bytes()),
719                    "mapped the persisted object index; the redb tail holds only what \
720                     arrived after the last fold"
721                );
722                (folded, entries, 1u64)
723            }
724            None => {
725                let projection = Arc::new(S::build(&entries)?);
726                // ── the cold open of a store that predates persistence ──────
727                //
728                // No file, and redb holds rows: a store written before
729                // 2026-09-04, or one whose index was lost. The projection was
730                // just built from the full scan the old design paid on every
731                // open; fold it now, so this is the LAST time this store pays
732                // it. Nothing else holds `db` yet, so every row it has is a row
733                // the projection was just built from: drop the table rather
734                // than remove 14 M keys one by one. The kernel mirror pays this
735                // once — 14 M rows out of redb, ~850 MB written — and never
736                // again.
737                if let Some(path) = persisted.as_deref()
738                    && !entries.is_empty()
739                {
740                    write_atomic(path, projection.persisted_bytes().expect("PERSISTS arm"))?;
741                    let w = db.begin_write()?;
742                    w.delete_table(OBJECTS)?;
743                    let _ = w.open_table(OBJECTS)?;
744                    w.commit()?;
745                    migrated = 1;
746                    tracing::info!(
747                        target: "znippy.git",
748                        rows = entries.len(),
749                        path = %path.display(),
750                        "folded the object tail into a persisted index on first open; the \
751                         redb tail now holds only rows that arrive after this"
752                    );
753                }
754                (projection, Vec::new(), 0u64)
755            }
756        };
757
758        // ── give redb its file back ──────────────────────────────────────────
759        //
760        // redb reuses freed pages but never shrinks its file. A store that just
761        // migrated has a redb at its old high-water mark with nothing in it —
762        // 4 GiB allocated, 2.9 GB of blocks, for the kernel mirror — and a store
763        // that has been folding for a while sits at one fold window of dead
764        // pages (270 MB on the same repository, over 54 k live rows). This is the
765        // one moment the `Database` is owned and no transaction is open, so
766        // compaction is free of coordination here and nowhere else.
767        compact_if_fragmented(&mut db, tail_path.as_deref());
768
769        // What the projection does not cover, counted the way `append` would
770        // have counted it, so the column-scan aggregates and the completeness
771        // fast path are exact from the first read.
772        let total = projection.len() as u64 + unfolded.len() as u64;
773        let mut unabsorbed_size = 0u64;
774        let mut unabsorbed_types = [0u64; 8];
775        let mut tail_bytes = 0u64;
776        for e in &unfolded {
777            unabsorbed_size += e.uncompressed_size;
778            unabsorbed_types[e.obj_type.code() as usize] += 1;
779            tail_bytes += e.len;
780        }
781        let db = Arc::new(db);
782        let stack = Self {
783            tail: db,
784            projection: RwLock::new(projection),
785            triggers,
786            total_rows: AtomicU64::new(total),
787            tail_hits: AtomicU64::new(0),
788            absent: AtomicU64::new(0),
789            tail_bytes: AtomicU64::new(tail_bytes),
790            rebuilds: AtomicU64::new(1),
791            generation: AtomicU64::new(1),
792            unabsorbed_size: AtomicU64::new(unabsorbed_size),
793            unabsorbed_types: unabsorbed_types.map(AtomicU64::new),
794            unabsorbed_rows: AtomicU64::new(unfolded.len() as u64),
795            tail_txns: AtomicU64::new(0),
796            persisted,
797            fold: Mutex::new(()),
798            folds: AtomicU64::new(migrated),
799            persisted_loads: AtomicU64::new(loaded),
800        };
801
802        Ok(stack)
803    }
804
805    /// **The projection covers every row in the repository**, so a miss in the
806    /// stree *is* an absence and the tail need not be asked.
807    ///
808    /// Sound by construction, not by hope: the projection is built from a scan
809    /// of the tail, so its rows are always a subset of the tail's; the archive
810    /// is append-only, so no row ever leaves; therefore equal counts mean equal
811    /// sets. A concurrent append between this check and the lookup only means
812    /// the lookup answers as of an instant before that append, which is what a
813    /// lookup issued a microsecond earlier would have done anyway.
814    ///
815    /// This is worth the paragraph because of what it buys. MEASURED on oden
816    /// 2026-08-07 (`read_stack_bench`, 100 000 objects, batch 1000): a `have`
817    /// negotiation — 10% hit, so 90% of the oids are in no repository at all —
818    /// cost **544 ns** per oid when every one of those absences took a redb
819    /// round trip, against **77 ns** for the bare Arrow arm. With this fast path
820    /// the same workload costs **68 ns**, i.e. the Arrow arm's own cost inside
821    /// the noise band. A `have` negotiation is *mostly* misses, so without this
822    /// the stack would be the dominant cost of the most common operation a git
823    /// server performs.
824    #[inline]
825    pub fn projection_is_complete(&self) -> bool {
826        self.unabsorbed_rows.load(Ordering::Acquire) == 0
827    }
828
829    /// Append objects to the tail.
830    ///
831    /// **Append-only is enforced here, not assumed.** An oid already present with
832    /// identical facts is a no-op (a re-pushed pack repeats objects, and that is
833    /// normal); an oid already present with *different* facts is an error and
834    /// nothing in the batch is written. Without that refusal a stree hit could
835    /// return a superseded row and the projection would be not merely incomplete
836    /// but wrong, which is the one thing this design must not allow.
837    ///
838    /// Runs a rebuild inline if the batch trips a threshold; the reason is
839    /// returned so the caller can see that it happened.
840    pub fn append(&self, entries: &[IndexEntry]) -> Result<Option<RebuildReason>> {
841        if entries.is_empty() {
842            return Ok(None);
843        }
844        let width = entries[0].oid.len();
845        if width != 20 && width != 32 {
846            bail!("oid width {width} is neither sha1 (20) nor sha256 (32)");
847        }
848
849        // Under the fold lock for the transaction AND the counter updates, so a
850        // fold cannot reset `unabsorbed_rows` between this batch landing in
851        // redb and its count landing in the atomics. Released before the
852        // trigger check below, because `rebuild` takes the same lock.
853        let guard = self.fold.lock().map_err(|_| anyhow!("the fold lock is poisoned"))?;
854        let w = self.tail.begin_write()?;
855        let mut added = 0u64;
856        let mut bytes = 0u64;
857        // Accumulated locally and applied only after the commit: a `bail!`
858        // half-way through the batch rolls redb back, and counters bumped inside
859        // the loop would survive a rollback and put the column-scan aggregates
860        // permanently out of step with the tail.
861        let mut added_size = 0u64;
862        let mut added_types = [0u64; 8];
863        {
864            let mut objects = w.open_table(OBJECTS)?;
865            let mut meta = w.open_table(META)?;
866            let mut seq = meta.get(META_ARRIVAL_SEQ)?.map(|v| v.value()).unwrap_or(0);
867            // The rows already known: the projection first, then redb. On a
868            // persisting stack the projection holds every folded row and redb
869            // does NOT, so a re-pushed or re-indexed object that was folded
870            // would otherwise be inserted into redb as new and exist in both
871            // layers — `len()` doubles, `oids_in_order` repeats, and the next
872            // fold's `validate` refuses the duplicate oid. MEASURED 2026-09-04
873            // on `dropping_the_exploded_table_and_reopening_still_answers`:
874            // 2704 rows for a 1352-object repository, exactly ×2. The stree
875            // lookup is also the cheaper check by an order of magnitude, so the
876            // common case of a re-push now never opens redb for it.
877            let projection = self.projection.read().expect("projection lock").clone();
878            for e in entries {
879                if e.oid.len() != width {
880                    bail!(
881                        "mixed oid widths in one append: {width} and {}",
882                        e.oid.len()
883                    );
884                }
885                let known: Option<TailRow> = match projection.lookup(&e.oid) {
886                    Some(row) => Some(TailRow {
887                        seq: 0,
888                        offset: row.offset,
889                        len: row.len,
890                        obj_type: row.obj_type,
891                        uncompressed_size: row.uncompressed_size,
892                        delta_base: row.delta_base,
893                    }),
894                    None => match objects.get(e.oid.as_slice())? {
895                        Some(existing) => Some(decode_row(existing.value())?),
896                        None => None,
897                    },
898                };
899                if let Some(old) = known {
900                    // ── IDENTITY, not PLACEMENT ─────────────────────────
901                    //
902                    // `offset`, `len` and `delta_base` say WHERE this copy of
903                    // the object landed. They are not what it IS. A re-pushed
904                    // pack legitimately places the same oid somewhere else —
905                    // that is the ordinary case, not a violation — so comparing
906                    // them refused a normal push.
907                    //
908                    // MEASURED 2026-08-14: pushing an object the repository
909                    // already held aborted the ingest and dropped the
910                    // connection with `client_told=false`, so the client saw
911                    // "send-pack: unexpected disconnect" and no reason at all.
912                    // It bit `bare_lifecycle` and `endurance` on all four
913                    // znippy columns of the bench, and it bites any real
914                    // `--force` push that resends a known object.
915                    //
916                    // The guard's own doc three lines up already said what it
917                    // meant to do — "An oid already present with identical
918                    // facts is a no-op (a re-pushed pack repeats objects, and
919                    // that is normal)" — so the code contradicted its contract.
920                    //
921                    // What the original comparison was protecting is still
922                    // protected, and by a stronger argument: we KEEP THE OLD
923                    // ROW. The archive is append-only and `gc` truncates
924                    // nothing, so the offset and delta base already recorded
925                    // still address bytes that are still there. The projection
926                    // therefore never carries a base the archive did not write
927                    // — the first writer's placement stands, forever.
928                    //
929                    // What CANNOT be waved through is a disagreement about the
930                    // object itself. Same oid, different type or different
931                    // inflated size, means either a hash collision or a
932                    // corrupted row, and no push may quietly overwrite that.
933                    // …and `obj_type` is a REPRESENTATION, not the object's
934                    // kind. `ofs-delta` and `ref-delta` say how these bytes are
935                    // ENCODED; what the object IS lives at the end of the delta
936                    // chain. The same oid legitimately arrives whole in one
937                    // pack and delta-encoded in another, so comparing those two
938                    // is comparing encodings and calling them different
939                    // objects. MEASURED here, `gunnar.import_export`, znippy:
940                    //
941                    //   6da81e5e… is already stored as (…, ofs-delta, size 2084)
942                    //   and cannot be redeclared as (…, tree, size 2084)
943                    //
944                    // — the inflated size agrees to the byte, which is the
945                    // identity; only the encoding moved. The refusal poisoned
946                    // the repository (`it stays un-indexed; reads will keep
947                    // falling back`) and every later read answered `the
948                    // repository is unavailable; try again`.
949                    //
950                    // So a type disagreement is a violation only when BOTH
951                    // rows name a real object kind. The inflated size is
952                    // compared unconditionally, and it is the half that
953                    // actually catches a collision or a corrupt row.
954                    let encoded = |t: ObjType| matches!(t, ObjType::OfsDelta | ObjType::RefDelta);
955                    let kind_disagrees = !encoded(old.obj_type)
956                        && !encoded(e.obj_type)
957                        && old.obj_type != e.obj_type;
958                    if kind_disagrees || old.uncompressed_size != e.uncompressed_size {
959                        bail!(
960                            "identity violation: {} is already stored as \
961                             (offset {}, len {}, {}, size {}, delta_base {}) and cannot be \
962                             redeclared as (offset {}, len {}, {}, size {}, delta_base {}) — \
963                             the TYPE or the inflated SIZE differs, so the same oid is \
964                             describing a different object. Placement (offset, len, \
965                             delta_base) may differ freely: a re-pushed pack lands its \
966                             copies elsewhere and the first writer's row stands.",
967                            hex::encode(&e.oid),
968                            old.offset,
969                            old.len,
970                            old.obj_type.as_str(),
971                            old.uncompressed_size,
972                            old.delta_base,
973                            e.offset,
974                            e.len,
975                            e.obj_type.as_str(),
976                            e.uncompressed_size,
977                            e.delta_base,
978                        );
979                    }
980                    continue;
981                }
982                objects.insert(e.oid.as_slice(), encode_row(seq, e).as_slice())?;
983                seq += 1;
984                added += 1;
985                bytes += e.len;
986                added_size += e.uncompressed_size;
987                added_types[e.obj_type.code() as usize] += 1;
988            }
989            meta.insert(META_ARRIVAL_SEQ, seq)?;
990            // Only a genuine insertion changes the oid set, so the content epoch
991            // holds still across a re-pushed pack that adds nothing (added == 0).
992            // The bump rides this same transaction, so the epoch and the rows it
993            // describes commit or roll back together.
994            if added > 0 {
995                let epoch = meta.get(META_TAIL_EPOCH)?.map(|v| v.value()).unwrap_or(0);
996                meta.insert(META_TAIL_EPOCH, epoch + 1)?;
997            }
998        }
999        w.commit()?;
1000
1001        self.total_rows.fetch_add(added, Ordering::AcqRel);
1002        self.unabsorbed_rows.fetch_add(added, Ordering::AcqRel);
1003        self.unabsorbed_size.fetch_add(added_size, Ordering::AcqRel);
1004        for (slot, n) in self.unabsorbed_types.iter().zip(added_types) {
1005            slot.fetch_add(n, Ordering::AcqRel);
1006        }
1007        let total_bytes = self.tail_bytes.fetch_add(bytes, Ordering::AcqRel) + bytes;
1008        drop(guard);
1009        if let Some(threshold) = self.volume_threshold()
1010            && total_bytes >= threshold
1011        {
1012            self.rebuild()?;
1013            return Ok(Some(RebuildReason::TailBytes(total_bytes)));
1014        }
1015        self.maybe_rebuild()
1016    }
1017
1018    /// Which threshold, if any, is currently tripped. Pure read; a caller on a
1019    /// read-only repository can poll this from its own maintenance tick.
1020    pub fn rebuild_due(&self) -> Option<RebuildReason> {
1021        let hits = self.tail_hits.load(Ordering::Acquire);
1022        if let Some(threshold) = self.triggers.miss_threshold(self.projection_len() as u64)
1023            && hits >= threshold
1024        {
1025            return Some(RebuildReason::TailHits(hits));
1026        }
1027        let bytes = self.tail_bytes.load(Ordering::Acquire);
1028        if let Some(threshold) = self.volume_threshold()
1029            && bytes >= threshold
1030        {
1031            return Some(RebuildReason::TailBytes(bytes));
1032        }
1033        None
1034    }
1035
1036    /// The volume trigger's threshold right now, or `None` when it is off.
1037    ///
1038    /// The fixed floor everywhere; on a persisting stack, the larger of the
1039    /// floor and `fold_ratio ×` the persisted index's current size — so a fold
1040    /// on a big repository is paid for by a proportionate amount of new data,
1041    /// and the sum of index writes over an import stays ≈ the bytes imported.
1042    /// See [`RebuildTriggers::DEFAULT_FOLD_RATIO`].
1043    fn volume_threshold(&self) -> Option<u64> {
1044        let floor = self.triggers.tail_bytes;
1045        if floor == 0 {
1046            return None;
1047        }
1048        if !self.persists() || self.triggers.fold_ratio <= 0.0 {
1049            return Some(floor);
1050        }
1051        let index_bytes = self.projection.read().expect("projection lock").ipc_bytes() as f64;
1052        Some(floor.max((self.triggers.fold_ratio * index_bytes) as u64))
1053    }
1054
1055    /// Rebuild if [`rebuild_due`](Self::rebuild_due) says so.
1056    pub fn maybe_rebuild(&self) -> Result<Option<RebuildReason>> {
1057        match self.rebuild_due() {
1058            Some(reason) => {
1059                self.rebuild()?;
1060                Ok(Some(reason))
1061            }
1062            None => Ok(None),
1063        }
1064    }
1065
1066    /// Rebuild the Arrow/stree projection from a full ordered scan of the tail
1067    /// and swap it in, resetting the counters and bumping the generation.
1068    ///
1069    /// The old projection stays live for every reader until the swap; the write
1070    /// lock is held only for the pointer store, not for the build.
1071    pub fn rebuild(&self) -> Result<()> {
1072        let _g = self.fold.lock().map_err(|_| anyhow!("the fold lock is poisoned"))?;
1073        self.fold_locked(None).map(|_| ())
1074    }
1075
1076    /// **The fold** — a rebuild that, on a persisting stack, also writes the
1077    /// projection down and empties redb of what it wrote. Caller holds
1078    /// [`fold`](Self::fold).
1079    ///
1080    /// The merge is of two disjoint sorted runs: the current projection (which,
1081    /// on a persisting stack, IS the file's content — the invariant every fold
1082    /// re-establishes) and the rows redb holds right now. On a non-persisting
1083    /// stack redb holds every row and the projection is not consulted, which is
1084    /// the pre-2026-09-04 rebuild unchanged.
1085    ///
1086    /// `live`, when given, is applied to the **projection's** rows only: those
1087    /// are all older than the reachability set `retain`'s caller computed, so
1088    /// the predicate has an answer for each of them. The redb rows are never
1089    /// filtered here — `retain` has already removed the dead ones under this
1090    /// same lock, and any row that landed after the caller's scan is one the
1091    /// predicate does not know and must not be asked about (`gc`'s closure
1092    /// answers `false` for an oid it never saw).
1093    ///
1094    /// Durability order, and why it is that order: (1) the new index goes to
1095    /// `objects.index.tmp`, is fsynced, and is renamed over `objects.index`;
1096    /// (2) the rows it holds are removed from redb. A crash after (1) and
1097    /// before (2) leaves them in both — reconciled at the next open. A crash
1098    /// during (1) leaves a `.tmp` nobody reads and a redb that still has every
1099    /// row. Nothing is ever lost, because a row leaves redb only after the
1100    /// rename that made it durable elsewhere has returned.
1101    ///
1102    /// Returns how many projection rows `live` rejected.
1103    fn fold_locked(&self, live: Option<&dyn Fn(&[u8]) -> bool>) -> Result<u64> {
1104        let unfolded = scan(&self.tail)?;
1105        let mut dropped = 0u64;
1106        let mut merged: Vec<IndexEntry>;
1107        if self.persists() {
1108            let cur = self.projection.read().expect("projection lock").clone();
1109            merged = Vec::with_capacity(cur.len() + unfolded.len());
1110            for o in 0..cur.len() as u32 {
1111                let e = cur.entry_at(o);
1112                if live.is_none_or(|f| f(&e.oid)) {
1113                    merged.push(e);
1114                } else {
1115                    dropped += 1;
1116                }
1117            }
1118        } else {
1119            merged = Vec::with_capacity(unfolded.len());
1120        }
1121        let forget: Vec<Vec<u8>> = if self.persists() {
1122            unfolded.iter().map(|e| e.oid.clone()).collect()
1123        } else {
1124            Vec::new()
1125        };
1126        merged.extend(unfolded);
1127        let total = merged.len() as u64;
1128        let fresh = Arc::new(S::build(&merged)?);
1129        drop(merged);
1130
1131        if let Some(path) = self.persisted.as_deref() {
1132            write_atomic(path, fresh.persisted_bytes().expect("PERSISTS arm"))?;
1133            if !forget.is_empty() {
1134                let w = self.tail.begin_write()?;
1135                {
1136                    let mut objects = w.open_table(OBJECTS)?;
1137                    for oid in &forget {
1138                        objects.remove(oid.as_slice())?;
1139                    }
1140                }
1141                w.commit()?;
1142            }
1143            self.folds.fetch_add(1, Ordering::AcqRel);
1144        }
1145
1146        *self
1147            .projection
1148            .write()
1149            .map_err(|_| anyhow!("the projection lock is poisoned"))? = fresh;
1150        self.total_rows.store(total, Ordering::Release);
1151        self.tail_hits.store(0, Ordering::Release);
1152        self.tail_bytes.store(0, Ordering::Release);
1153        self.unabsorbed_size.store(0, Ordering::Release);
1154        self.unabsorbed_rows.store(0, Ordering::Release);
1155        self.tail_txns.store(0, Ordering::Release);
1156        for slot in &self.unabsorbed_types {
1157            slot.store(0, Ordering::Release);
1158        }
1159        self.rebuilds.fetch_add(1, Ordering::AcqRel);
1160        self.generation.fetch_add(1, Ordering::AcqRel);
1161        Ok(dropped)
1162    }
1163
1164    pub fn stats(&self) -> StackStats {
1165        StackStats {
1166            sealed_rows: self.projection_len() as u64,
1167            total_rows: self.total_rows.load(Ordering::Acquire),
1168            tail_hits: self.tail_hits.load(Ordering::Acquire),
1169            absent: self.absent.load(Ordering::Acquire),
1170            tail_bytes: self.tail_bytes.load(Ordering::Acquire),
1171            rebuilds: self.rebuilds.load(Ordering::Acquire),
1172            folds: self.folds.load(Ordering::Acquire),
1173            persisted_loads: self.persisted_loads.load(Ordering::Acquire),
1174            generation: self.generation.load(Ordering::Acquire),
1175            unabsorbed_rows: self.unabsorbed_rows.load(Ordering::Acquire),
1176            tail_txns: self.tail_txns.load(Ordering::Acquire),
1177        }
1178    }
1179
1180    /// Rows in the projection right now. `stats().total_rows - this` is the
1181    /// un-absorbed tail.
1182    pub fn projection_len(&self) -> usize {
1183        self.projection.read().expect("projection lock").len()
1184    }
1185
1186    /// Every oid in the repository, in oid order — which is ordinal order, since
1187    /// redb keys the tail by the raw oid.
1188    ///
1189    /// A full scan, and it is here for the one caller that genuinely needs the
1190    /// whole set at once: a GC, which has to name what is *not* live.
1191    pub fn oids_in_order(&self) -> Result<Vec<Vec<u8>>> {
1192        let read = self.tail.begin_read()?;
1193        let objects = read.open_table(OBJECTS)?;
1194        let mut tail = Vec::with_capacity(objects.len()? as usize);
1195        for row in objects.iter()? {
1196            let (k, _) = row?;
1197            tail.push(k.value().to_vec());
1198        }
1199        if !self.persists() {
1200            // redb holds every row, so its key order is the whole answer.
1201            return Ok(tail);
1202        }
1203        // Two sorted runs — the persisted projection and the redb tail — merged
1204        // once. Disjoint by the fold's invariant; the equal-key arm below exists
1205        // for the window inside a fold where the file is renamed and redb has
1206        // not yet forgotten, so a caller in that window counts each oid once.
1207        let cur = self.projection.read().expect("projection lock").clone();
1208        let n = cur.len();
1209        let mut out = Vec::with_capacity(n + tail.len());
1210        let (mut i, mut j) = (0usize, 0usize);
1211        while i < n || j < tail.len() {
1212            let a = (i < n).then(|| cur.oid_at(i as u32));
1213            let b = tail.get(j).map(Vec::as_slice);
1214            match (a, b) {
1215                (Some(a), Some(b)) => match a.as_slice().cmp(b) {
1216                    std::cmp::Ordering::Less => {
1217                        out.push(a);
1218                        i += 1;
1219                    }
1220                    std::cmp::Ordering::Greater => {
1221                        out.push(b.to_vec());
1222                        j += 1;
1223                    }
1224                    std::cmp::Ordering::Equal => {
1225                        out.push(a);
1226                        i += 1;
1227                        j += 1;
1228                    }
1229                },
1230                (Some(a), None) => {
1231                    out.push(a);
1232                    i += 1;
1233                }
1234                (None, Some(b)) => {
1235                    out.push(b.to_vec());
1236                    j += 1;
1237                }
1238                (None, None) => break,
1239            }
1240        }
1241        Ok(out)
1242    }
1243
1244    /// **The ordinal space** — every oid in the repository, back to back, in oid
1245    /// (= ordinal) order — and where each part came from.
1246    ///
1247    /// This replaces the `objects.ordinal` memo (S-082). That file existed to
1248    /// spare a cold open the full redb scan it took to rebuild the sorted oid
1249    /// list; the scan no longer exists to be spared. On a persisting stack the
1250    /// folded oids ARE the projection's oid column — one contiguous copy out of
1251    /// the mapped buffer — and only the redb tail (the rows since the last fold)
1252    /// is scanned and merged in. `folded_rows` / `tail_rows` say which is which,
1253    /// so a test can assert the space came off the index and not off a scan
1254    /// without comparing two byte strings that are equal either way.
1255    ///
1256    /// Same overlap note as [`oids_in_order`](Self::oids_in_order): inside a
1257    /// fold's rename-then-forget window an oid may sit in both layers for a
1258    /// moment; the merge emits it once.
1259    pub fn oids_raw(&self) -> Result<OrdinalSpace> {
1260        let read = self.tail.begin_read()?;
1261        let objects = read.open_table(OBJECTS)?;
1262        let mut tail: Vec<Vec<u8>> = Vec::with_capacity(objects.len()? as usize);
1263        for row in objects.iter()? {
1264            let (k, _) = row?;
1265            tail.push(k.value().to_vec());
1266        }
1267        let tail_rows = tail.len() as u64;
1268        if !self.persists() {
1269            let mut raw = Vec::with_capacity(tail.iter().map(Vec::len).sum());
1270            for oid in &tail {
1271                raw.extend_from_slice(oid);
1272            }
1273            return Ok(OrdinalSpace { raw, folded_rows: 0, tail_rows });
1274        }
1275        let cur = self.projection.read().expect("projection lock").clone();
1276        let folded = cur.oids_raw();
1277        let folded_rows = cur.len() as u64;
1278        if tail.is_empty() {
1279            return Ok(OrdinalSpace { raw: folded, folded_rows, tail_rows: 0 });
1280        }
1281        let w = match tail.first() {
1282            Some(o) => o.len(),
1283            None => unreachable!("tail is non-empty"),
1284        };
1285        let mut raw = Vec::with_capacity(folded.len() + tail.len() * w);
1286        let (mut i, mut j) = (0usize, 0usize);
1287        let n = folded.len() / w;
1288        while i < n || j < tail.len() {
1289            let a = (i < n).then(|| &folded[i * w..(i + 1) * w]);
1290            let b = tail.get(j).map(Vec::as_slice);
1291            match (a, b) {
1292                (Some(a), Some(b)) => match a.cmp(b) {
1293                    std::cmp::Ordering::Less => {
1294                        raw.extend_from_slice(a);
1295                        i += 1;
1296                    }
1297                    std::cmp::Ordering::Greater => {
1298                        raw.extend_from_slice(b);
1299                        j += 1;
1300                    }
1301                    std::cmp::Ordering::Equal => {
1302                        raw.extend_from_slice(a);
1303                        i += 1;
1304                        j += 1;
1305                    }
1306                },
1307                (Some(a), None) => {
1308                    raw.extend_from_slice(a);
1309                    i += 1;
1310                }
1311                (None, Some(b)) => {
1312                    raw.extend_from_slice(b);
1313                    j += 1;
1314                }
1315                (None, None) => break,
1316            }
1317        }
1318        Ok(OrdinalSpace { raw, folded_rows, tail_rows })
1319    }
1320
1321    /// `(tail_epoch, object_count)` in one read transaction — the two cheap facts
1322    /// that say whether the oid set has moved, **without** the ordered scan
1323    /// [`Self::oids_in_order`] pays. (They were the `objects.ordinal` memo's
1324    /// validity key until that file went, 2026-09-04.) `epoch` is [`META_TAIL_EPOCH`] (0 when a
1325    /// store predates it, e.g. was populated by an older binary); the count is
1326    /// redb's own O(1) table length. Both are metadata reads — a point lookup and
1327    /// a stored length — so this is microseconds against the scan's hundreds of
1328    /// milliseconds, which is the whole reason the memo can exist.
1329    pub fn tail_epoch_and_len(&self) -> Result<(u64, u64)> {
1330        let read = self.tail.begin_read()?;
1331        let meta = read.open_table(META)?;
1332        let epoch = meta.get(META_TAIL_EPOCH)?.map(|v| v.value()).unwrap_or(0);
1333        let objects = read.open_table(OBJECTS)?;
1334        // On a persisting stack the repository is the persisted projection plus
1335        // the redb tail, disjoint. Inside a fold's rename-then-forget window the
1336        // two overlap for a moment and this over-counts; the memo it validates
1337        // then reads as stale and re-scans, and the next call is exact again —
1338        // a transient re-scan, never a wrong answer, and not worth taking the
1339        // fold lock on the read path for.
1340        let folded = if self.persists() { self.projection_len() as u64 } else { 0 };
1341        Ok((epoch, objects.len()? + folded))
1342    }
1343
1344    /// **Which of these archive extents already have rows here** — the
1345    /// crash-recovery diff, and the reason §13.12's `indexed` bit is derived
1346    /// rather than stored.
1347    ///
1348    /// A pack is unabsorbed **iff its extent is in the journal and its rows are
1349    /// not in the index**. Both of those are already durable — the journal is
1350    /// fsynced on the ack path, the tail is a redb commit — so the bit is a diff
1351    /// of two durable facts and there is nothing for it to drift from. A *stored*
1352    /// bit would be a third fact that can disagree with the two it describes,
1353    /// which is precisely how a pack ends up marked absorbed with no rows behind
1354    /// it: fast and wrong, in the one direction (`absent`) that loses a client's
1355    /// objects during negotiation.
1356    ///
1357    /// `out[i]` answers `extents[i]`. **The tail and not the projection**, because
1358    /// the projection is a snapshot of a prefix of the tail and the question is
1359    /// about what survived the crash.
1360    ///
1361    /// # Cost
1362    ///
1363    /// One ordered scan of the tail that **stops the moment every extent has been
1364    /// hit**. Rows arrive in oid order, which is uncorrelated with the pack an
1365    /// object came from, so a store whose packs are all absorbed answers after a
1366    /// few rows per pack rather than after a full scan — the coupon-collector
1367    /// case, and the one every clean reopen takes. The scan only runs to the end
1368    /// when some pack genuinely has **no** rows, which is exactly the case where
1369    /// the caller is about to re-resolve that whole pack anyway.
1370    ///
1371    /// MEASURED on oden 2026-08-08, release, file-backed redb tail, 1-minute
1372    /// loadavg 7.1–7.5 (another tenant's work — read these as ratios):
1373    ///
1374    /// | packs | rows | every pack absorbed | one pack un-absorbed |
1375    /// |---:|---:|---:|---:|
1376    /// | 1 000 | 1 000 000 | **0.77 ms** | 120.6 ms |
1377    /// | 10 000 | 1 000 000 | **11.7 ms** | 141.8 ms |
1378    /// | 1 000 | 3 686 | **0.40 ms** | 0.39 ms |
1379    ///
1380    /// The first column is what a clean reopen pays and it is bounded by the
1381    /// **pack** count, not the object count — 1000 packs over a million objects
1382    /// costs the same order as 1000 packs over four thousand. The second column
1383    /// is the crash path, where the scan runs to the end: 120 ms over a million
1384    /// rows, against the ~160 ms a *single* 2687-object pack takes to re-absorb
1385    /// (`a_push_ends_in_object_rows_and_no_read_asked_for_them`). The diff is
1386    /// therefore never the expensive half of a recovery.
1387    /// The third row is the shape
1388    /// [`crate::git_ops::tests::the_derive_on_open_diff_costs_milliseconds_at_a_realistic_pack_count`]
1389    /// asserts on every run, over real pushed packs rather than synthetic rows.
1390    ///
1391    /// A zero-length extent has no rows by construction and is answered `false`
1392    /// without looking.
1393    pub fn extents_with_rows(&self, extents: &[(u64, u64)]) -> Result<Vec<bool>> {
1394        let mut hit = vec![false; extents.len()];
1395        // Sorted by start, so one row is placed with a binary search instead of a
1396        // pass over every extent: the scan below is the hot loop.
1397        let mut order: Vec<usize> = (0..extents.len()).filter(|&i| extents[i].1 > 0).collect();
1398        order.sort_unstable_by_key(|&i| extents[i].0);
1399        let starts: Vec<u64> = order.iter().map(|&i| extents[i].0).collect();
1400        let mut wanted = order.len();
1401        if wanted == 0 {
1402            return Ok(hit);
1403        }
1404
1405        // One row's offset, placed against the sorted extents. `true` when every
1406        // extent has now been hit and the scan can stop.
1407        let mut place = |offset: u64| -> bool {
1408            // The last extent that starts at or before this row.
1409            let p = starts.partition_point(|&s| s <= offset);
1410            if p == 0 {
1411                return false;
1412            }
1413            let i = order[p - 1];
1414            let (start, len) = extents[i];
1415            if offset < start + len && !hit[i] {
1416                hit[i] = true;
1417                wanted -= 1;
1418            }
1419            wanted == 0
1420        };
1421
1422        // The persisted projection first: on a persisting stack it holds every
1423        // row older than the last fold, which is nearly all of them, and its
1424        // offset column is one contiguous `u64` run — the cheapest scan there
1425        // is. Then the redb tail for the rows that arrived since.
1426        if self.persists() {
1427            let cur = self.projection.read().expect("projection lock").clone();
1428            for o in 0..cur.len() as u32 {
1429                if place(cur.extent_at(o).0) {
1430                    return Ok(hit);
1431                }
1432            }
1433        }
1434        let read = self.tail.begin_read()?;
1435        let objects = read.open_table(OBJECTS)?;
1436        for row in objects.iter()? {
1437            let (_, v) = row?;
1438            if place(decode_row(v.value())?.offset) {
1439                break;
1440            }
1441        }
1442        Ok(hit)
1443    }
1444
1445    /// **Drop every row `live` rejects.** Returns how many rows went.
1446    ///
1447    /// This is the one operation that is not append-only, and it exists for
1448    /// exactly one caller: `GitOps::gc`, which computes reachability and then has
1449    /// to remove what is unreachable *before* base znippy compacts the payload —
1450    /// so that base's notion of "live" already means what git means (§13.20).
1451    ///
1452    /// The projection is rebuilt inside the same call. It has to be: it is a
1453    /// snapshot of a tail that no longer says the same thing, and a stree that
1454    /// still answers for a deleted oid would be **wrong** rather than merely
1455    /// incomplete, which is the one failure this design does not tolerate.
1456    pub fn retain(&self, live: &dyn Fn(&[u8]) -> bool) -> Result<u64> {
1457        // The whole of `retain` — the redb pass, the fold that rewrites the
1458        // persisted index without the dead rows, and the epoch bump — under one
1459        // hold of the fold lock, so no fold can interleave and re-persist a row
1460        // this call is removing.
1461        let _g = self.fold.lock().map_err(|_| anyhow!("the fold lock is poisoned"))?;
1462        let w = self.tail.begin_write()?;
1463        let mut dropped = 0u64;
1464        {
1465            let mut objects = w.open_table(OBJECTS)?;
1466            let dead: Vec<Vec<u8>> = objects
1467                .iter()?
1468                .filter_map(|row| row.ok())
1469                .filter(|(k, _)| !live(k.value()))
1470                .map(|(k, _)| k.value().to_vec())
1471                .collect();
1472            for oid in &dead {
1473                objects.remove(oid.as_slice())?;
1474                dropped += 1;
1475            }
1476            // A removal changes the oid set exactly as an insert does, so the
1477            // content epoch must record it: bump it in this same transaction
1478            // when anything actually went. `arrival_seq` is deliberately not
1479            // touched — it is an *arrival* ordinal, not a live count.
1480            if dropped > 0 {
1481                let mut meta = w.open_table(META)?;
1482                let epoch = meta.get(META_TAIL_EPOCH)?.map(|v| v.value()).unwrap_or(0);
1483                meta.insert(META_TAIL_EPOCH, epoch + 1)?;
1484            }
1485        }
1486        w.commit()?;
1487        // The persisted projection's dead rows go in the fold: it rewrites the
1488        // index from `live` rows only and swaps it in, so a stree that answered
1489        // for a deleted oid a moment ago answers `None` now. On a persisting
1490        // stack this is where most of what dies actually goes — those rows were
1491        // never in redb — so the epoch is bumped again for them. (`len` in the
1492        // memo's `(epoch, len)` key would catch it too; the bump keeps the
1493        // contract that a changed oid set is a changed epoch.)
1494        let folded_dead = self.fold_locked(Some(live))?;
1495        if folded_dead > 0 {
1496            let w = self.tail.begin_write()?;
1497            {
1498                let mut meta = w.open_table(META)?;
1499                let epoch = meta.get(META_TAIL_EPOCH)?.map(|v| v.value()).unwrap_or(0);
1500                meta.insert(META_TAIL_EPOCH, epoch + 1)?;
1501            }
1502            w.commit()?;
1503        }
1504        Ok(dropped + folded_dead)
1505    }
1506
1507    /// One tail read transaction serving `oids`, filling only the slots that are
1508    /// still `None`. This is the fall-through, and it is a single transaction for
1509    /// the whole batch on purpose — skade's `resolve_many` phase 2.
1510    ///
1511    /// **Not called at all when the projection is complete** — see
1512    /// [`projection_is_complete`](Self::projection_is_complete). The unfilled
1513    /// slots are then genuine absences and are counted as such by
1514    /// [`absences_only`](Self::absences_only).
1515    fn fill_from_tail(&self, oids: &[&[u8]], out: &mut [Option<IndexRow>]) -> Result<()> {
1516        self.tail_txns.fetch_add(1, Ordering::AcqRel);
1517        let read = self.tail.begin_read()?;
1518        let objects = read.open_table(OBJECTS)?;
1519        let mut hits = 0u64;
1520        let mut absent = 0u64;
1521        for (slot, oid) in out.iter_mut().zip(oids) {
1522            if slot.is_some() {
1523                continue;
1524            }
1525            match objects.get(*oid)? {
1526                Some(v) => {
1527                    *slot = Some(decode_row(v.value())?.as_index_row());
1528                    hits += 1;
1529                }
1530                None => absent += 1,
1531            }
1532        }
1533        self.tail_hits.fetch_add(hits, Ordering::AcqRel);
1534        self.absent.fetch_add(absent, Ordering::AcqRel);
1535        Ok(())
1536    }
1537
1538    /// Count the unfilled slots as absences without asking the tail. Only ever
1539    /// called when [`projection_is_complete`](Self::projection_is_complete),
1540    /// where "the projection does not have it" and "the repository does not have
1541    /// it" are the same statement.
1542    fn absences_only(&self, out: &[Option<IndexRow>]) {
1543        let n = out.iter().filter(|s| s.is_none()).count() as u64;
1544        self.absent.fetch_add(n, Ordering::AcqRel);
1545    }
1546}
1547
1548/// Every row in the tail, in oid order, as index entries. redb's B-tree is
1549/// already ordered by the raw oid key, so this hands `S::build` its rows in the
1550/// order it wants them and the sort inside is a no-op scan.
1551fn scan(db: &Database) -> Result<Vec<IndexEntry>> {
1552    let read = db.begin_read()?;
1553    let objects = read.open_table(OBJECTS)?;
1554    let mut out = Vec::with_capacity(objects.len()? as usize);
1555    for row in objects.iter()? {
1556        let (k, v) = row?;
1557        out.push(decode_row(v.value())?.as_entry(k.value()));
1558    }
1559    Ok(out)
1560}
1561
1562// ── the redb 2 tail, migrated once ───────────────────────────────────────────
1563
1564/// `objects.tail` → `objects.tail.redb4-migrating`: where [`migrate_redb2_tail`]
1565/// builds the redb 4 copy before it is renamed over the old file.
1566fn migrating_path(tail: &Path) -> PathBuf {
1567    let mut name = tail.file_name().map(|n| n.to_os_string()).unwrap_or_default();
1568    name.push(".redb4-migrating");
1569    tail.with_file_name(name)
1570}
1571
1572/// Rows copied per redb 4 write transaction while migrating. The staging file
1573/// is not the tail until its rename, so a crash between chunks leaves the old
1574/// tail untouched and the next open starts the copy again.
1575const MIGRATE_CHUNK_ROWS: usize = 1 << 20;
1576
1577/// **The tail on redb 4, and the redb 2 tail every store before 2026-10-06 has.**
1578///
1579/// # Why redb 4
1580///
1581/// MEASURED 2026-10-06 (oden, XFS — the appliance's filesystem; gunnar's
1582/// `tests/repo_overhead.rs`): a repository's whole fixed cost on disk was this
1583/// one file. redb 2.6.3 allocates **540 KB** when it creates a database and
1584/// **1.6 MB** (3.7 MB apparent) once that database has been closed, in file
1585/// format v2 and v3 alike, so a small repository cost 1 654 784 B of disk — about
1586/// 2 MB per repository on the load test's estate, ~20 GB at 10 000 accounts of
1587/// three small repositories. redb 4.3.0, same tables, same rows: **4 KB** at
1588/// create, 36-57 KB after use, and it gives trailing free pages back to the
1589/// filesystem when it closes. The same probe found redb 2 turning a
1590/// `retain`-everything over 20 000 rows into a 205 MB file (redb 4: 4 MB).
1591///
1592/// # The migration
1593///
1594/// redb 4 refuses a redb 2 file with `UpgradeRequired` before writing a byte of
1595/// it. Then, once per store:
1596///
1597/// 1. a staging file left by a migration that died is removed;
1598/// 2. the old tail is opened with redb 2 and both tables are copied, in chunks
1599///    of [`MIGRATE_CHUNK_ROWS`], into a fresh redb 4 database at
1600///    [`migrating_path`] — `objects` and `meta` verbatim, so `arrival_seq`,
1601///    `tail_epoch` and `tail_format` carry over and nothing downstream can tell;
1602/// 3. the last commit is durable (redb's default), the staging file is renamed
1603///    over `objects.tail`, and the directory is fsynced.
1604///
1605/// A crash before the rename leaves the redb 2 tail exactly as it was; after
1606/// it, the redb 4 tail is complete. There is no state in between to repair.
1607/// A file redb 2 cannot read either is reported, not dropped: it was
1608/// unreadable before this existed too.
1609fn migrate_redb2_tail(tail_path: &Path, found_format: u8, cache_bytes: usize) -> Result<()> {
1610    const OBJECTS2: redb2::TableDefinition<'static, &[u8], &[u8]> =
1611        redb2::TableDefinition::new("objects");
1612    const META2: redb2::TableDefinition<'static, &str, u64> = redb2::TableDefinition::new("meta");
1613    use redb2::{ReadableTable as _, ReadableTableMetadata as _};
1614
1615    let t = std::time::Instant::now();
1616    let staging = migrating_path(tail_path);
1617    match fs::remove_file(&staging) {
1618        Ok(()) => tracing::warn!(
1619            target: "znippy.git",
1620            path = %staging.display(),
1621            "removed a half-built redb 4 tail left by an interrupted migration; starting again"
1622        ),
1623        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
1624        Err(e) => return Err(e).with_context(|| format!("removing {}", staging.display())),
1625    }
1626    let (before_len, before_alloc) = file_sizes(tail_path);
1627
1628    let old = redb2::Database::builder()
1629        .set_cache_size(cache_bytes)
1630        .create(tail_path)
1631        .with_context(|| {
1632            format!(
1633                "opening the redb 2 object tail at {} (file format {found_format}) to migrate it",
1634                tail_path.display()
1635            )
1636        })?;
1637    let read = old.begin_read()?;
1638    let mut rows = 0u64;
1639    let copied = (|| -> Result<()> {
1640        let new = Database::builder()
1641            .set_cache_size(cache_bytes)
1642            .create(&staging)
1643            .with_context(|| format!("creating {}", staging.display()))?;
1644        {
1645            let w = new.begin_write()?;
1646            {
1647                let _ = w.open_table(OBJECTS)?;
1648                let mut meta = w.open_table(META)?;
1649                match read.open_table(META2) {
1650                    Ok(old_meta) => {
1651                        for kv in old_meta.iter()? {
1652                            let (k, v) = kv?;
1653                            meta.insert(k.value(), v.value())?;
1654                        }
1655                    }
1656                    Err(redb2::TableError::TableDoesNotExist(_)) => {}
1657                    Err(e) => return Err(e.into()),
1658                }
1659            }
1660            w.commit()?;
1661        }
1662        match read.open_table(OBJECTS2) {
1663            Ok(old_objects) => {
1664                let mut it = old_objects.iter()?;
1665                loop {
1666                    let w = new.begin_write()?;
1667                    let mut n = 0usize;
1668                    {
1669                        let mut objects = w.open_table(OBJECTS)?;
1670                        for kv in it.by_ref() {
1671                            let (k, v) = kv?;
1672                            objects.insert(k.value(), v.value())?;
1673                            n += 1;
1674                            if n == MIGRATE_CHUNK_ROWS {
1675                                break;
1676                            }
1677                        }
1678                    }
1679                    w.commit()?;
1680                    rows += n as u64;
1681                    if n < MIGRATE_CHUNK_ROWS {
1682                        break;
1683                    }
1684                }
1685                anyhow::ensure!(
1686                    rows == old_objects.len()?,
1687                    "the migration copied {rows} rows of {}",
1688                    old_objects.len()?
1689                );
1690            }
1691            Err(redb2::TableError::TableDoesNotExist(_)) => {}
1692            Err(e) => return Err(e.into()),
1693        }
1694        Ok(())
1695    })();
1696    drop(read);
1697    drop(old);
1698    if let Err(e) = copied {
1699        let _ = fs::remove_file(&staging);
1700        return Err(e.context(format!(
1701            "migrating the object tail at {} from redb 2 to redb 4; the redb 2 tail is untouched",
1702            tail_path.display()
1703        )));
1704    }
1705    fs::rename(&staging, tail_path).with_context(|| {
1706        format!("renaming {} over {}", staging.display(), tail_path.display())
1707    })?;
1708    if let Some(dir) = tail_path.parent().filter(|d| !d.as_os_str().is_empty()) {
1709        File::open(dir)
1710            .and_then(|d| d.sync_all())
1711            .with_context(|| format!("syncing the directory of {}", tail_path.display()))?;
1712    }
1713    let (after_len, after_alloc) = file_sizes(tail_path);
1714    tracing::info!(
1715        target: "znippy.git",
1716        tail = %tail_path.display(),
1717        rows,
1718        from_format = found_format,
1719        before_bytes = before_len,
1720        before_allocated = before_alloc,
1721        after_bytes = after_len,
1722        after_allocated = after_alloc,
1723        ms = t.elapsed().as_millis(),
1724        "migrated the object tail from redb 2 to redb 4 (once per store)"
1725    );
1726    Ok(())
1727}
1728
1729/// `(apparent, allocated)` bytes of `path`, `(0, 0)` when it cannot be read —
1730/// for the migration's log line, never for a decision.
1731fn file_sizes(path: &Path) -> (u64, u64) {
1732    match fs::metadata(path) {
1733        #[cfg(unix)]
1734        Ok(m) => {
1735            use std::os::unix::fs::MetadataExt;
1736            (m.len(), m.blocks() * 512)
1737        }
1738        #[cfg(not(unix))]
1739        Ok(m) => (m.len(), m.len()),
1740        Err(_) => (0, 0),
1741    }
1742}
1743
1744// ── the persisted projection on disk ─────────────────────────────────────────
1745
1746/// `objects.index` → `objects.index.tmp`: where a fold writes before its rename.
1747fn tmp_path(path: &Path) -> PathBuf {
1748    let mut name = path.file_name().map(|n| n.to_os_string()).unwrap_or_default();
1749    name.push(".tmp");
1750    path.with_file_name(name)
1751}
1752
1753/// Write `bytes` so that `path` either holds all of them or is untouched:
1754/// sibling temp file, fsync, rename, fsync the directory. The same idiom
1755/// every durable writer in this crate uses, spelled once here for the index.
1756fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
1757    let tmp = tmp_path(path);
1758    {
1759        let mut f = File::create(&tmp)
1760            .with_context(|| format!("creating {}", tmp.display()))?;
1761        f.write_all(bytes)
1762            .with_context(|| format!("writing {} bytes to {}", bytes.len(), tmp.display()))?;
1763        f.sync_all()
1764            .with_context(|| format!("syncing {}", tmp.display()))?;
1765    }
1766    fs::rename(&tmp, path)
1767        .with_context(|| format!("renaming {} over {}", tmp.display(), path.display()))?;
1768    if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
1769        File::open(dir)
1770            .and_then(|d| d.sync_all())
1771            .with_context(|| format!("syncing the directory of {}", path.display()))?;
1772    }
1773    Ok(())
1774}
1775
1776/// Dead redb pages worth compacting for: below this an open leaves the file
1777/// alone, because a small repository's redb is a few pages either way and a
1778/// rewrite of it on every open would be churn for nothing. Compaction costs
1779/// what is LIVE, not what is dead — after a fold that is little — so the floor
1780/// is set by the file lying about the store, not by the work: 16 MiB is a redb
1781/// that has doubled itself four times past a few thousand live rows.
1782const COMPACT_FRAGMENTED_FLOOR: u64 = 16 << 20;
1783
1784/// Shrink redb's file when it holds at least [`COMPACT_FRAGMENTED_FLOOR`] of dead
1785/// pages. Needs the `Database` owned and no transaction open, which is true at
1786/// exactly one place — the open — and nowhere else; that is why it lives here
1787/// rather than behind a maintain verb. Any failure is logged and swallowed: the
1788/// file being large is not a fault, and a store must open over it regardless.
1789fn compact_if_fragmented(db: &mut Database, tail_path: Option<&Path>) {
1790    let Some(tail_path) = tail_path else { return }; // in-memory: nothing on disk to shrink
1791    let file_len = match fs::metadata(tail_path) {
1792        Ok(m) => m.len(),
1793        Err(_) => return,
1794    };
1795    // `stats()` lives on the write transaction (redb 2 and 4); this one is opened to
1796    // be read and aborted, never committed. The dead-page figure is taken from
1797    // the FILE, not from `fragmented_bytes()`: right after a commit that freed
1798    // pages (the migration fold's `delete_table`) redb still has them PENDING
1799    // — two-phase free — and reports them as neither stored nor fragmented.
1800    // MEASURED: the same 34 MB file read `fragmented = 12.8 MiB` inside the
1801    // open and `33 MB` from a fresh handle a moment later. The file length
1802    // minus what is stored is what the store is actually lying by, and
1803    // `compact()` begins with the two commits that turn pending into free.
1804    let (stored, metadata) = {
1805        let w = match db.begin_write() {
1806            Ok(w) => w,
1807            Err(e) => {
1808                tracing::debug!(target: "znippy.git", error = %e, "redb stats unavailable; not compacting");
1809                return;
1810            }
1811        };
1812        let s = w.stats().map(|s| (s.stored_bytes(), s.metadata_bytes()));
1813        let _ = w.abort();
1814        match s {
1815            Ok(s) => s,
1816            Err(e) => {
1817                tracing::debug!(target: "znippy.git", error = %e, "redb stats unavailable; not compacting");
1818                return;
1819            }
1820        }
1821    };
1822    let dead = file_len.saturating_sub(stored + metadata);
1823    if dead < COMPACT_FRAGMENTED_FLOOR {
1824        return;
1825    }
1826    let t = std::time::Instant::now();
1827    match db.compact() {
1828        Ok(did) => tracing::info!(
1829            target: "znippy.git",
1830            file_before = file_len,
1831            file_after = fs::metadata(tail_path).map(|m| m.len()).unwrap_or(0),
1832            stored_bytes = stored,
1833            compacted = did,
1834            ms = t.elapsed().as_millis(),
1835            tail = %tail_path.display(),
1836            "compacted the redb object tail: it was carrying dead pages from before a fold"
1837        ),
1838        Err(e) => tracing::warn!(
1839            target: "znippy.git",
1840            file_len,
1841            stored_bytes = stored,
1842            error = %e,
1843            "could not compact the redb object tail; it keeps its size, nothing else changes"
1844        ),
1845    }
1846}
1847
1848/// The persisted index as an Arrow [`Buffer`] — `None` when there is none.
1849///
1850/// Memory-mapped where the platform allows it, so the columns
1851/// [`ObjectIndex::from_persisted`] decodes are views into the page cache: the
1852/// index costs resident memory only for the pages a lookup touches, and none
1853/// of it is a copy. Elsewhere the file is read into an aligned buffer, which
1854/// keeps every other property (no rebuild, no redb scan) and gives up only
1855/// the sharing.
1856fn map_persisted(path: &Path) -> Result<Option<Buffer>> {
1857    let f = match File::open(path) {
1858        Ok(f) => f,
1859        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1860        Err(e) => return Err(e).with_context(|| format!("opening {}", path.display())),
1861    };
1862    let len = f
1863        .metadata()
1864        .with_context(|| format!("stat {}", path.display()))?
1865        .len();
1866    if len == 0 {
1867        // A persisted projection is never zero bytes — even an empty index
1868        // carries its schema — so this is a truncated write, not an index.
1869        return Ok(None);
1870    }
1871    #[cfg(target_os = "linux")]
1872    {
1873        // SAFETY: the file is replaced only by `write_atomic`'s rename, which
1874        // swaps the directory entry and never writes into the inode this
1875        // mapping is over; the old inode lives until the last mapping drops.
1876        let map = unsafe { memmap2::Mmap::map(&f) }
1877            .with_context(|| format!("mapping {}", path.display()))?;
1878        let map = Arc::new(map);
1879        let ptr = std::ptr::NonNull::new(map.as_ptr() as *mut u8)
1880            .ok_or_else(|| anyhow!("mmap of {} returned a null pointer", path.display()))?;
1881        // SAFETY: `ptr` and `len` describe exactly the mapping `map` owns, and
1882        // `map` is kept alive inside the buffer as its allocation owner.
1883        let buf = unsafe { Buffer::from_custom_allocation(ptr, map.len(), map) };
1884        Ok(Some(buf))
1885    }
1886    #[cfg(not(target_os = "linux"))]
1887    {
1888        use std::io::Read;
1889        let mut f = f;
1890        let mut mb = znippy_common::arrow::buffer::MutableBuffer::with_capacity(len as usize);
1891        let mut v = Vec::with_capacity(len as usize);
1892        f.read_to_end(&mut v)
1893            .with_context(|| format!("reading {}", path.display()))?;
1894        mb.extend_from_slice(&v);
1895        Ok(Some(mb.into()))
1896    }
1897}
1898
1899/// The projection at `path`, or `None` if there is none — or if what is there
1900/// cannot be read as one. The second case is a warning and not an error on
1901/// purpose: the index is a cache the journal can rebuild, and a store that
1902/// refuses to open over a damaged cache would be a store that has stopped
1903/// answering over something it knows how to recover from. The rows redb still
1904/// holds are served; the rest are re-indexed from the journal by
1905/// `adopt_journal`, which sees them as "extent journaled, rows absent".
1906/// Returns the projection and two wall-clock figures in milliseconds: the map
1907/// (open + mmap, which is O(1)) and the decode-plus-stree build (which is O(n)
1908/// in the oid count and is the whole of a warm open's real cost). They are
1909/// logged by the open, so the number the next lever has to beat is in the
1910/// server log rather than in a bench nobody reruns.
1911fn load_persisted<S: ObjectIndex>(path: &Path) -> Option<(Arc<S>, u128, u128)> {
1912    let t0 = std::time::Instant::now();
1913    match map_persisted(path) {
1914        Ok(None) => None,
1915        Ok(Some(buf)) => {
1916            let map_ms = t0.elapsed().as_millis();
1917            let t1 = std::time::Instant::now();
1918            match S::from_persisted(buf) {
1919                Ok(s) => Some((Arc::new(s), map_ms, t1.elapsed().as_millis())),
1920                Err(e) => {
1921                tracing::warn!(
1922                    target: "znippy.git",
1923                    path = %path.display(),
1924                    error = %format!("{e:#}"),
1925                    "the persisted object index could not be read; opening without it and \
1926                     re-indexing from the journal — slower until the drain catches up, \
1927                     never wrong"
1928                );
1929                    None
1930                }
1931            }
1932        }
1933        Err(e) => {
1934            tracing::warn!(
1935                target: "znippy.git",
1936                path = %path.display(),
1937                error = %format!("{e:#}"),
1938                "the persisted object index could not be opened; opening without it"
1939            );
1940            None
1941        }
1942    }
1943}
1944
1945impl<S: ObjectIndex> ObjectIndex for ObjectReadStack<S> {
1946    /// Build a stack over `entries` with an in-memory tail. Every entry lands in
1947    /// redb and the projection is built from it, so the stack starts fully
1948    /// absorbed — which is what makes it directly comparable with the two Arrow
1949    /// arms on the layout benchmark.
1950    fn build(entries: &[IndexEntry]) -> Result<Self> {
1951        let stack = Self::in_memory(RebuildTriggers::default())?;
1952        stack.append(entries)?;
1953        stack.rebuild()?;
1954        Ok(stack)
1955    }
1956
1957    /// Projection first; **a miss falls through to redb**, which always knows.
1958    /// `None` here means the tail said no, never that the stree said no.
1959    fn lookup(&self, oid: &[u8]) -> Option<IndexRow> {
1960        if let Some(row) = self.projection.read().expect("projection lock").lookup(oid) {
1961            return Some(row);
1962        }
1963        if self.projection_is_complete() {
1964            self.absent.fetch_add(1, Ordering::AcqRel);
1965            return None;
1966        }
1967        let mut out = [None];
1968        // A tail read that errors is reported as absent to this signature, which
1969        // cannot carry an error. Callers that need the distinction use
1970        // `stats().absent` against their own miss count, or `fill_from_tail`
1971        // through `lookup_batch`'s caller. A corrupt tail is a `rebuild()`
1972        // failure long before it is a wrong lookup.
1973        let _ = self.fill_from_tail(&[oid], &mut out);
1974        out[0]
1975    }
1976
1977    /// Two phases, and the second is not optional: one pipelined stree pass, then
1978    /// **one** redb transaction for every slot the projection left empty.
1979    fn lookup_batch(&self, oids: &[&[u8]]) -> Vec<Option<IndexRow>> {
1980        let mut out = self
1981            .projection
1982            .read()
1983            .expect("projection lock")
1984            .lookup_batch(oids);
1985        if out.iter().any(Option::is_none) {
1986            if self.projection_is_complete() {
1987                self.absences_only(&out);
1988            } else {
1989                let _ = self.fill_from_tail(oids, &mut out);
1990            }
1991        }
1992        out
1993    }
1994
1995    /// The floor path — oid → ordinal, no payload column touched. The tail is
1996    /// still consulted for the misses, because a miss is still not an answer;
1997    /// a tail-served ordinal carries [`TAIL_ORDINAL_BIT`].
1998    fn ordinals_batch(&self, oids: &[&[u8]]) -> Vec<Option<u32>> {
1999        let mut out = self
2000            .projection
2001            .read()
2002            .expect("projection lock")
2003            .ordinals_batch(oids);
2004        if out.iter().any(Option::is_none) {
2005            if self.projection_is_complete() {
2006                let n = out.iter().filter(|s| s.is_none()).count() as u64;
2007                self.absent.fetch_add(n, Ordering::AcqRel);
2008                return out;
2009            }
2010            let mut rows: Vec<Option<IndexRow>> = out
2011                .iter()
2012                .map(|o| {
2013                    o.map(|ordinal| IndexRow {
2014                        ordinal,
2015                        offset: 0,
2016                        len: 0,
2017                        obj_type: ObjType::Blob,
2018                        uncompressed_size: 0,
2019                        delta_base: 0,
2020                    })
2021                })
2022                .collect();
2023            let _ = self.fill_from_tail(oids, &mut rows);
2024            for (slot, row) in out.iter_mut().zip(&rows) {
2025                *slot = row.map(|r| r.ordinal);
2026            }
2027        }
2028        out
2029    }
2030
2031    /// The partial-row path — byte extent only.
2032    fn extents_batch(&self, oids: &[&[u8]]) -> Vec<Option<(u64, u64)>> {
2033        let mut out = self
2034            .projection
2035            .read()
2036            .expect("projection lock")
2037            .extents_batch(oids);
2038        if out.iter().any(Option::is_none) {
2039            if self.projection_is_complete() {
2040                let n = out.iter().filter(|s| s.is_none()).count() as u64;
2041                self.absent.fetch_add(n, Ordering::AcqRel);
2042                return out;
2043            }
2044            let mut rows: Vec<Option<IndexRow>> = out
2045                .iter()
2046                .map(|e| {
2047                    e.map(|(offset, len)| IndexRow {
2048                        ordinal: 0,
2049                        offset,
2050                        len,
2051                        obj_type: ObjType::Blob,
2052                        uncompressed_size: 0,
2053                        delta_base: 0,
2054                    })
2055                })
2056                .collect();
2057            let _ = self.fill_from_tail(oids, &mut rows);
2058            for (slot, row) in out.iter_mut().zip(&rows) {
2059                *slot = row.map(|r| (r.offset, r.len));
2060            }
2061        }
2062        out
2063    }
2064
2065    /// The quota gate, over the whole **repository** and without a scan of the
2066    /// tail: the projection's column scan plus the rows appended after it.
2067    /// Exact because the archive is append-only — a row the projection already
2068    /// holds can never gain or lose bytes, so the two terms cannot overlap.
2069    fn sum_uncompressed(&self) -> u64 {
2070        self.projection
2071            .read()
2072            .expect("projection lock")
2073            .sum_uncompressed()
2074            + self.unabsorbed_size.load(Ordering::Acquire)
2075    }
2076
2077    /// [`sum_uncompressed`](Self::sum_uncompressed)'s argument, per type.
2078    fn count_type(&self, t: ObjType) -> usize {
2079        self.projection
2080            .read()
2081            .expect("projection lock")
2082            .count_type(t)
2083            + self.unabsorbed_types[t.code() as usize].load(Ordering::Acquire) as usize
2084    }
2085
2086    fn name(&self) -> &'static str {
2087        "ObjectReadStack"
2088    }
2089
2090    /// Positional rows are the **projection's** rows. A tail ordinal carries
2091    /// [`TAIL_ORDINAL_BIT`] and is a row address in no table — asking for it
2092    /// positionally is the same programming error as indexing past `len()`,
2093    /// and fails the same way, out loud.
2094    fn oid_at(&self, ordinal: u32) -> Vec<u8> {
2095        assert!(!is_tail_ordinal(ordinal), "ordinal {ordinal:#x} is tail-served, not positional");
2096        self.projection.read().expect("projection lock").oid_at(ordinal)
2097    }
2098
2099    fn entry_at(&self, ordinal: u32) -> IndexEntry {
2100        assert!(!is_tail_ordinal(ordinal), "ordinal {ordinal:#x} is tail-served, not positional");
2101        self.projection.read().expect("projection lock").entry_at(ordinal)
2102    }
2103
2104    fn extent_at(&self, ordinal: u32) -> (u64, u64) {
2105        assert!(!is_tail_ordinal(ordinal), "ordinal {ordinal:#x} is tail-served, not positional");
2106        self.projection.read().expect("projection lock").extent_at(ordinal)
2107    }
2108
2109    /// Objects in the **repository**, not in the projection — the stack answers
2110    /// for the whole repository and this has to agree with what `lookup` will
2111    /// resolve. [`projection_len`](Self::projection_len) is the other number.
2112    fn len(&self) -> usize {
2113        self.total_rows.load(Ordering::Acquire) as usize
2114    }
2115
2116    fn ipc_bytes(&self) -> usize {
2117        self.projection.read().expect("projection lock").ipc_bytes()
2118    }
2119
2120    /// The projection's resident bytes. The tail is on disk (or in redb's own
2121    /// page cache) and is not counted here — counting it would make this
2122    /// incomparable with the two Arrow arms, which is the number's only use.
2123    fn resident_bytes(&self) -> usize {
2124        self.projection
2125            .read()
2126            .expect("projection lock")
2127            .resident_bytes()
2128    }
2129}
2130
2131#[cfg(test)]
2132mod tests {
2133    use super::*;
2134    use crate::arms::DEFAULT_REDB_CACHE_BYTES;
2135    use crate::index_layout::{FourTables, synthetic_entries};
2136
2137    type Stack = ObjectReadStack<OneTableFourColumns>;
2138
2139    /// A stack with the thresholds off, so a test decides when a rebuild happens.
2140    fn manual() -> Stack {
2141        Stack::in_memory(RebuildTriggers::manual()).expect("in-memory stack")
2142    }
2143
2144    /// Seal `sealed` into the projection, then append `tail` without absorbing
2145    /// it. The returned stack is deliberately in the state the whole design is
2146    /// about: a projection that is a strict subset of the truth.
2147    fn split_stack(sealed: &[IndexEntry], tail: &[IndexEntry]) -> Stack {
2148        let s = manual();
2149        s.append(sealed).expect("seal half");
2150        s.rebuild().expect("rebuild");
2151        s.append(tail).expect("append tail");
2152        s
2153    }
2154
2155    /// **The property the whole stack rests on: incomplete, never wrong.**
2156    ///
2157    /// The projection covers 300 of 500 objects. Asserted on applied output —
2158    /// every one of the five facts of all 500 rows, through both the serial and
2159    /// the batch path — plus the two things that would make the guard hollow:
2160    /// that the projection really is short (its own `lookup` misses all 200 tail
2161    /// objects, so the fall-through is genuinely being exercised) and that every
2162    /// projection hit is byte-identical to what a complete index built over all
2163    /// 500 returns.
2164    ///
2165    /// Seen RED by making `lookup_batch`'s phase 2 conditional on
2166    /// `out.iter().all(Option::is_none)` instead of `any` — i.e. falling through
2167    /// only when the projection answered nothing: "the stack lost tail object
2168    /// e50298d4cb83164fcbbd2a0d4fd99ebb43111e93, batch path". Restored.
2169    ///
2170    /// Seen RED a second time by returning `None` from `lookup` on a projection
2171    /// miss with no fall-through: "the stack lost tail object
2172    /// e50298d4cb83164fcbbd2a0d4fd99ebb43111e93, serial path". Restored.
2173    ///
2174    /// Seen RED a third time, for `delta_base`, by having [`encode_row`] write
2175    /// a literal `0` into bytes 33..41 — the shape of a column that exists in
2176    /// the struct and is never persisted: "the delta base of
2177    /// b2af964cd8a34795ce5ad114d4e2a2144cf2a6b1 did not survive the projection
2178    /// path / left: 0 / right: 6186". **Six** of this module's tests went red
2179    /// on that one edit, on both sides of the fall-through, which is what says
2180    /// the tail encoding is load-bearing rather than incidental. Restored.
2181    #[test]
2182    fn the_projection_is_incomplete_but_never_wrong() {
2183        let all = synthetic_entries(500, 20, 0xC0FFEE);
2184        let (sealed, tail) = all.split_at(300);
2185        let stack = split_stack(sealed, tail);
2186
2187        assert_eq!(stack.projection_len(), 300, "the projection must be short");
2188        assert_eq!(stack.len(), 500, "the stack answers for the repository");
2189        // The tail half has to carry delta bases, or the `delta_base` assertion
2190        // below is an assertion about zero on both sides of the fall-through.
2191        assert!(
2192            tail.iter().filter(|e| e.delta_base != 0).count() >= 10,
2193            "the un-absorbed half carries no delta bases — the tail encoding would be untested"
2194        );
2195
2196        // Two references. `sealed_only` is what the projection is *supposed* to
2197        // be — an index over exactly the 300 sealed objects, so its ordinals are
2198        // the ranks the projection must reproduce. `complete` is the index the
2199        // stack must be indistinguishable from on the five facts.
2200        let sealed_only = FourTables::build(sealed).expect("sealed-only index");
2201        let complete = FourTables::build(&all).expect("complete index");
2202
2203        // The projection alone must MISS every tail object — otherwise the
2204        // fall-through below proves nothing.
2205        {
2206            let proj = stack.projection.read().unwrap();
2207            for e in tail {
2208                assert!(
2209                    proj.lookup(&e.oid).is_none(),
2210                    "the projection already holds {} — this test would be vacuous",
2211                    hex::encode(&e.oid)
2212                );
2213            }
2214            for e in sealed {
2215                assert!(
2216                    proj.lookup(&e.oid).is_some(),
2217                    "the projection lost sealed object {}",
2218                    hex::encode(&e.oid)
2219                );
2220            }
2221        }
2222
2223        let refs: Vec<&[u8]> = all.iter().map(|e| e.oid.as_slice()).collect();
2224        let batched = stack.lookup_batch(&refs);
2225        for (i, e) in all.iter().enumerate() {
2226            let via_serial = stack.lookup(&e.oid).unwrap_or_else(|| {
2227                panic!(
2228                    "the stack lost {} object {}, serial path",
2229                    if i < 300 { "sealed" } else { "tail" },
2230                    hex::encode(&e.oid)
2231                )
2232            });
2233            let via_batch = batched[i].unwrap_or_else(|| {
2234                panic!(
2235                    "the stack lost {} object {}, batch path",
2236                    if i < 300 { "sealed" } else { "tail" },
2237                    hex::encode(&e.oid)
2238                )
2239            });
2240            assert_eq!(via_serial, via_batch, "serial and batch disagree");
2241
2242            // The five facts, against the entry that was appended.
2243            assert_eq!(via_serial.offset, e.offset);
2244            assert_eq!(via_serial.len, e.len);
2245            assert_eq!(via_serial.obj_type, e.obj_type);
2246            assert_eq!(via_serial.uncompressed_size, e.uncompressed_size);
2247            assert_eq!(
2248                via_serial.delta_base,
2249                e.delta_base,
2250                "the delta base of {} did not survive the {} path",
2251                hex::encode(&e.oid),
2252                if i < 300 { "projection" } else { "tail" }
2253            );
2254
2255            // A HIT in the projection is valid: the five facts are the complete
2256            // index's, and the ordinal is the rank within the generation the
2257            // projection covers — which is what an ordinal means.
2258            let truth = complete.lookup(&e.oid).expect("complete index has it");
2259            assert_eq!(via_serial.offset, truth.offset);
2260            assert_eq!(via_serial.len, truth.len);
2261            assert_eq!(via_serial.obj_type, truth.obj_type);
2262            assert_eq!(via_serial.uncompressed_size, truth.uncompressed_size);
2263            assert_eq!(via_serial.delta_base, truth.delta_base);
2264            if i < 300 {
2265                assert!(
2266                    !is_tail_row(&via_serial),
2267                    "sealed row wearing a tail ordinal"
2268                );
2269                assert_eq!(
2270                    via_serial.ordinal,
2271                    sealed_only.lookup(&e.oid).unwrap().ordinal,
2272                    "the projection's ordinal for {} is not its rank in the generation the \
2273                     projection covers",
2274                    hex::encode(&e.oid)
2275                );
2276            } else {
2277                assert!(
2278                    is_tail_row(&via_serial),
2279                    "tail row {} has no tail ordinal",
2280                    hex::encode(&e.oid)
2281                );
2282            }
2283        }
2284    }
2285
2286    /// A miss in the tree is not an answer, and an absence is.
2287    ///
2288    /// Seen RED by having `fill_from_tail` skip its `objects.get` and count every
2289    /// unfilled slot as absent: the tail-served block came back empty —
2290    /// "assertion `left == right` failed / left: 0 / right: 80". Restored.
2291    #[test]
2292    fn only_a_tail_miss_is_absent_and_only_a_tail_hit_counts() {
2293        let all = synthetic_entries(200, 20, 7);
2294        let (sealed, tail) = all.split_at(120);
2295        let stack = split_stack(sealed, tail);
2296        let nowhere = synthetic_entries(64, 20, 0x00AB_5E47);
2297
2298        let mut refs: Vec<&[u8]> = Vec::new();
2299        refs.extend(sealed.iter().map(|e| e.oid.as_slice()));
2300        refs.extend(tail.iter().map(|e| e.oid.as_slice()));
2301        refs.extend(nowhere.iter().map(|e| e.oid.as_slice()));
2302        let rows = stack.lookup_batch(&refs);
2303
2304        assert_eq!(rows[..120].iter().filter(|r| r.is_some()).count(), 120);
2305        assert_eq!(rows[120..200].iter().filter(|r| r.is_some()).count(), 80);
2306        assert!(
2307            rows[200..].iter().all(Option::is_none),
2308            "an object in no repository resolved to a row"
2309        );
2310
2311        let st = stack.stats();
2312        assert_eq!(st.tail_hits, 80, "tail-served lookups miscounted");
2313        assert_eq!(st.absent, 64, "genuine absences miscounted");
2314    }
2315
2316    /// **Append-only, enforced.** The refusal is what makes a stree hit
2317    /// trustworthy; if a fact could be rewritten, a stale projection would return
2318    /// the old one.
2319    ///
2320    /// **Identity is refused; placement is not.** REWRITTEN 2026-08-14 — the
2321    /// old form compared `offset`, `len` and `delta_base` too, and those say
2322    /// where a copy landed rather than what the object is. A re-pushed pack
2323    /// places the same oid at a new offset, which is the ordinary case, so the
2324    /// guard refused normal pushes: the ingest aborted and the connection
2325    /// dropped with `client_told=false`, leaving the client with
2326    /// "send-pack: unexpected disconnect" and no reason. It failed
2327    /// `bare_lifecycle` and `endurance` on all four znippy bench columns and
2328    /// bit any `--force` push that resent a known object.
2329    ///
2330    /// The old comparison's stated fear — a projection carrying "a base offset
2331    /// the archive never wrote" — is answered better by keeping the OLD row:
2332    /// the archive is append-only and `gc` truncates nothing, so the first
2333    /// writer's offset and base still address bytes that are still there.
2334    ///
2335    /// Seen RED by comparing `offset` again: "a re-pushed pack at a new offset
2336    /// was refused" — which is precisely the production failure, reproduced.
2337    ///
2338    /// Seen RED the other way by dropping `old.obj_type != e.obj_type`: "an oid
2339    /// that changed type was accepted" — the same oid describing a different
2340    /// object, waved through.
2341    #[test]
2342    fn an_append_only_violation_is_refused() {
2343        let entries = synthetic_entries(16, 20, 11);
2344        let stack = manual();
2345        stack.append(&entries).expect("first append");
2346
2347        // Re-appending the identical batch is a no-op, not an error: a re-pushed
2348        // pack repeats objects.
2349        stack
2350            .append(&entries)
2351            .expect("identical re-append is idempotent");
2352        assert_eq!(
2353            stack.len(),
2354            16,
2355            "an idempotent re-append changed the row count"
2356        );
2357
2358        // ★ THE PRODUCTION CASE: the same oid, re-pushed, lands elsewhere.
2359        // This must be a NO-OP, and the first writer's placement must stand.
2360        let mut moved = entries[3].clone();
2361        moved.offset += 1;
2362        stack
2363            .append(std::slice::from_ref(&moved))
2364            .expect("a re-pushed pack at a new offset was refused");
2365        let row = stack.lookup(&entries[3].oid).expect("still there");
2366        assert_eq!(
2367            row.offset, entries[3].offset,
2368            "the second placement overwrote the first — the projection now names \
2369             an offset that is not where the first writer put the bytes"
2370        );
2371        assert_eq!(stack.len(), 16, "a re-placement added a row");
2372
2373        // ★ IDENTITY still refuses: same oid, different object.
2374        let mut retyped = entries[4].clone();
2375        retyped.uncompressed_size += 1;
2376        let err = stack
2377            .append(std::slice::from_ref(&retyped))
2378            .expect_err("an oid that changed size was accepted");
2379        assert!(
2380            err.to_string().contains("identity violation"),
2381            "wrong error: {err}"
2382        );
2383
2384        // ★ A DIFFERENT DELTA BASE IS PLACEMENT TOO, and the first row stands.
2385        //
2386        // The old form refused this, on the reasoning that a row naming a base
2387        // the archive never wrote would be left behind. Keeping the OLD row
2388        // answers that completely: the base the FIRST writer recorded is still
2389        // in the archive, because the blob is append-only and `gc` truncates
2390        // nothing. The second pack's base is simply not adopted.
2391        //
2392        // Refusing it instead is what broke real pushes — a re-pushed pack
2393        // re-deltas against whatever is in ITS window, so a differing base is
2394        // the ordinary case, not evidence of corruption.
2395        let based = entries
2396            .iter()
2397            .find(|e| e.delta_base != 0)
2398            .expect("the fixture must carry a delta");
2399        let mut rebased = based.clone();
2400        rebased.delta_base += 8;
2401        stack
2402            .append(std::slice::from_ref(&rebased))
2403            .expect("a re-pushed pack that re-deltaed was refused");
2404        assert_eq!(
2405            stack.lookup(&based.oid).unwrap().delta_base,
2406            based.delta_base,
2407            "the second pack's base was adopted — the projection now names a base \
2408             the FIRST writer never recorded, which is the thing the old guard \
2409             was right to fear"
2410        );
2411    }
2412
2413    /// The volume trigger fires on appended bytes and the miss trigger on
2414    /// tail-served lookups, and neither fires on a genuine absence.
2415    ///
2416    /// Seen RED by counting `absent` into `tail_hits` in `fill_from_tail`:
2417    /// "assertion `left == right` failed: absences tripped the miss trigger /
2418    /// left: Some(TailHits(1000)) / right: None". Restored.
2419    #[test]
2420    fn each_trigger_fires_on_its_own_signal() {
2421        // ── volume ──
2422        let entries = synthetic_entries(64, 20, 21);
2423        let bytes: u64 = entries.iter().map(|e| e.len).sum();
2424        let stack = Stack::in_memory(RebuildTriggers {
2425            tail_bytes: bytes,
2426            ..RebuildTriggers::manual()
2427        })
2428        .unwrap();
2429        let g0 = stack.stats().generation;
2430        let reason = stack.append(&entries).unwrap();
2431        assert!(
2432            matches!(reason, Some(RebuildReason::TailBytes(_))),
2433            "the volume trigger did not fire at exactly its threshold: {reason:?}"
2434        );
2435        assert_eq!(stack.projection_len(), 64, "the rebuild absorbed nothing");
2436        assert_eq!(stack.stats().generation, g0 + 1);
2437        assert_eq!(
2438            stack.stats().tail_bytes,
2439            0,
2440            "the byte counter was not reset"
2441        );
2442
2443        // ── misses ──
2444        let all = synthetic_entries(200, 20, 22);
2445        let (sealed, tail) = all.split_at(100);
2446        // A flat floor of 100 misses: `tail_hits_per_row` 0 means the threshold
2447        // is exactly the floor, which makes the arithmetic in this test the
2448        // trigger's and not the ratio's.
2449        let stack = Stack::in_memory(RebuildTriggers {
2450            tail_hits_per_row: 0.0,
2451            min_tail_hits: 100,
2452            tail_bytes: 0,
2453            fold_ratio: 0.0,
2454        })
2455        .unwrap();
2456        stack.append(sealed).unwrap();
2457        stack.rebuild().unwrap();
2458        stack.append(tail).unwrap();
2459        let g1 = stack.stats().generation;
2460        assert_eq!(stack.projection_len(), 100);
2461
2462        // 1000 lookups of oids in NO repository: 1000 tail misses, zero
2463        // tail-served hits, and no rebuild may follow.
2464        let nowhere = synthetic_entries(1000, 20, 23);
2465        let refs: Vec<&[u8]> = nowhere.iter().map(|e| e.oid.as_slice()).collect();
2466        assert!(stack.lookup_batch(&refs).iter().all(Option::is_none));
2467        assert_eq!(stack.stats().absent, 1000);
2468        assert_eq!(
2469            stack.rebuild_due(),
2470            None,
2471            "absences tripped the miss trigger"
2472        );
2473        stack.maybe_rebuild().unwrap();
2474        assert_eq!(
2475            stack.stats().generation,
2476            g1,
2477            "1000 absences must not rebuild"
2478        );
2479        assert_eq!(stack.projection_len(), 100);
2480
2481        // 100 lookups the tail DOES answer: the trigger fires.
2482        let refs: Vec<&[u8]> = tail.iter().map(|e| e.oid.as_slice()).collect();
2483        assert_eq!(stack.lookup_batch(&refs).iter().flatten().count(), 100);
2484        assert_eq!(
2485            stack.rebuild_due(),
2486            Some(RebuildReason::TailHits(100)),
2487            "100 tail-served lookups did not trip a threshold of 100"
2488        );
2489        assert!(stack.maybe_rebuild().unwrap().is_some());
2490        assert_eq!(
2491            stack.projection_len(),
2492            200,
2493            "the rebuild did not absorb the tail"
2494        );
2495        assert_eq!(stack.stats().generation, g1 + 1);
2496    }
2497
2498    /// The two column scans answer for the **repository**, not for the
2499    /// projection — including the rows the projection has not absorbed, and
2500    /// without scanning the tail to find them.
2501    ///
2502    /// Seen RED by dropping the `unabsorbed_size` term from `sum_uncompressed`:
2503    /// "assertion `left == right` failed: sum_uncompressed answered for the
2504    /// projection, not the repository / left: 1179629 / right: 1915881".
2505    /// Restored.
2506    ///
2507    /// The rejected-append half of this test is what pins the *ordering* in
2508    /// `append`: the counters are accumulated locally and applied after the
2509    /// commit, because a `bail!` half-way through a batch rolls redb back and
2510    /// counters bumped inside the loop would not roll back with it. That one is
2511    /// fixed by construction rather than watched — there is one place the
2512    /// counters are written and it is after `commit()`.
2513    #[test]
2514    fn the_column_scans_answer_for_the_repository_not_the_projection() {
2515        let all = synthetic_entries(300, 20, 71);
2516        let (sealed, tail) = all.split_at(180);
2517        let stack = split_stack(sealed, tail);
2518        let complete = OneTableFourColumns::build(&all).unwrap();
2519
2520        assert!(
2521            stack.projection.read().unwrap().sum_uncompressed() < complete.sum_uncompressed(),
2522            "the projection already sums to the whole repository — this test would be vacuous"
2523        );
2524        assert_eq!(
2525            stack.sum_uncompressed(),
2526            complete.sum_uncompressed(),
2527            "sum_uncompressed answered for the projection, not the repository"
2528        );
2529        for t in ObjType::ALL {
2530            assert_eq!(
2531                stack.count_type(t),
2532                complete.count_type(t),
2533                "count_type({}) answered for the projection",
2534                t.as_str()
2535            );
2536        }
2537
2538        // A rejected append must not move either aggregate.
2539        let mut rewritten = all[0].clone();
2540        rewritten.uncompressed_size += 1_000_000;
2541        let batch = [all[7].clone(), rewritten];
2542        assert!(stack.append(&batch).is_err());
2543        assert_eq!(stack.sum_uncompressed(), complete.sum_uncompressed());
2544
2545        // And a rebuild leaves them where they were.
2546        stack.rebuild().unwrap();
2547        assert_eq!(stack.sum_uncompressed(), complete.sum_uncompressed());
2548        for t in ObjType::ALL {
2549            assert_eq!(stack.count_type(t), complete.count_type(t));
2550        }
2551    }
2552
2553    /// The two narrow batch paths fall through to the tail exactly as the
2554    /// full-row one does. A miss is not an answer on those either.
2555    ///
2556    /// Seen RED by returning the projection's `ordinals_batch` unchanged:
2557    /// "assertion `left == right` failed: the ordinal path lost 120 of 300
2558    /// objects / left: 180 / right: 300". Restored.
2559    #[test]
2560    fn the_narrow_batch_paths_also_fall_through() {
2561        let all = synthetic_entries(300, 20, 81);
2562        let (sealed, tail) = all.split_at(180);
2563        let stack = split_stack(sealed, tail);
2564        let refs: Vec<&[u8]> = all.iter().map(|e| e.oid.as_slice()).collect();
2565
2566        let ordinals = stack.ordinals_batch(&refs);
2567        assert_eq!(
2568            ordinals.iter().filter(|o| o.is_some()).count(),
2569            300,
2570            "the ordinal path lost {} of 300 objects",
2571            300 - ordinals.iter().filter(|o| o.is_some()).count()
2572        );
2573        for (o, e) in ordinals[180..].iter().zip(tail) {
2574            assert!(
2575                o.unwrap() & TAIL_ORDINAL_BIT != 0,
2576                "tail object {} got a projection ordinal",
2577                hex::encode(&e.oid)
2578            );
2579        }
2580
2581        let extents = stack.extents_batch(&refs);
2582        for (x, e) in extents.iter().zip(&all) {
2583            assert_eq!(
2584                *x,
2585                Some((e.offset, e.len)),
2586                "extent lost or wrong for {}",
2587                hex::encode(&e.oid)
2588            );
2589        }
2590    }
2591
2592    /// A rebuild re-derives every ordinal as the new lexicographic rank, and the
2593    /// tail's high-bit ordinals disappear as their rows are absorbed. This is the
2594    /// generation caveat, asserted rather than only documented.
2595    ///
2596    /// Seen RED by having `rebuild` compute the fresh projection and never swap
2597    /// it in: "an absorbed row kept its tail ordinal". Restored.
2598    #[test]
2599    fn a_rebuild_re_derives_ordinals_and_clears_the_tail_bit() {
2600        let all = synthetic_entries(120, 32, 31);
2601        let (sealed, tail) = all.split_at(60);
2602        let stack = split_stack(sealed, tail);
2603
2604        let before = stack.lookup(&tail[0].oid).unwrap();
2605        assert!(is_tail_row(&before));
2606
2607        stack.rebuild().unwrap();
2608        let after = stack.lookup(&tail[0].oid).unwrap();
2609        assert!(
2610            !is_tail_row(&after),
2611            "an absorbed row kept its tail ordinal"
2612        );
2613        assert_eq!(after.offset, before.offset, "absorption changed a fact");
2614        assert_eq!(after.len, before.len);
2615        assert_eq!(after.obj_type, before.obj_type);
2616        assert_eq!(after.uncompressed_size, before.uncompressed_size);
2617        assert_eq!(after.delta_base, before.delta_base);
2618
2619        // The delta base is an ARCHIVE OFFSET and absorption must not touch it.
2620        // An ordinal in this column would have had to be re-derived by the
2621        // rebuild — silently, and to a different number, because the rebuild
2622        // re-ranks every row. That is §13's argument, asserted on a row that
2623        // actually carries a base rather than on the 0 sentinel.
2624        let based = tail
2625            .iter()
2626            .find(|e| e.delta_base != 0)
2627            .expect("the tail half must carry at least one delta");
2628        let row = stack.lookup(&based.oid).unwrap();
2629        assert_eq!(
2630            row.delta_base,
2631            based.delta_base,
2632            "the rebuild moved {}'s delta base from {} to {}",
2633            hex::encode(&based.oid),
2634            based.delta_base,
2635            row.delta_base
2636        );
2637        assert_ne!(
2638            row.delta_base, row.ordinal as u64,
2639            "a delta base that equals a row ordinal is the mistake §13 forbids"
2640        );
2641
2642        let complete = OneTableFourColumns::build(&all).unwrap();
2643        assert_eq!(
2644            after.ordinal,
2645            complete.lookup(&tail[0].oid).unwrap().ordinal,
2646            "the rebuilt ordinal is not the lexicographic rank"
2647        );
2648    }
2649
2650    /// **The crash-recovery diff is exact at the pack boundary.**
2651    ///
2652    /// Three packs' worth of rows laid end to end, the middle one never appended:
2653    /// [`ObjectReadStack::extents_with_rows`] must answer `true, false, true`.
2654    /// The middle pack's extent is the interesting one — it is bounded on both
2655    /// sides by rows that *are* in the tail, so an off-by-one in either direction
2656    /// reports it absorbed, its objects are never re-queued after a restart, and a
2657    /// durable pack becomes unreadable for ever.
2658    ///
2659    /// Asserted per extent, not as a count.
2660    ///
2661    /// Seen RED by `let p = starts.partition_point(|&s| s <= offset)` →
2662    /// `partition_point(|&s| s < offset)`, i.e. placing a row against the
2663    /// *previous* extent when it lands on a pack's first byte: "left: [false] /
2664    /// right: [true]" on the single-row extent. Restored.
2665    ///
2666    /// Seen RED a second time by `offset < start + len` → `offset <= start + len`:
2667    /// "a row one byte past pack B's extent was counted as B's — left: [true,
2668    /// true], right: [true, false]". **That mutation stayed green against the
2669    /// four-pack question**, and the reason is worth writing down: when every
2670    /// extent is in the question and the packs are adjacent, the row at B's end
2671    /// offset is also C's first row, and the binary search assigns it to C before
2672    /// the bound is ever consulted. The bound only bites when a pack's successor
2673    /// is *not* in the question — a journal whose tail was torn, which is the
2674    /// state this whole diff exists to survive. The `[..2]` case below is
2675    /// therefore not an extra assertion, it is the one that tests the bound at
2676    /// all. Restored.
2677    #[test]
2678    fn the_crash_recovery_diff_is_exact_at_the_pack_boundary() {
2679        let all = synthetic_entries(400, 20, 0xB17_5E7);
2680        // Four packs, back to back, the way `SafeWriter` appends them.
2681        let bounds = [(0usize, 100usize), (100, 200), (200, 300), (300, 400)];
2682        let extents: Vec<(u64, u64)> = bounds
2683            .iter()
2684            .map(|&(a, b)| {
2685                let start = all[a].offset;
2686                let end = all[b - 1].offset + all[b - 1].len;
2687                (start, end - start)
2688            })
2689            .collect();
2690        assert_eq!(
2691            extents[1].0,
2692            extents[0].0 + extents[0].1,
2693            "the fixture's packs must be adjacent or the boundary is not under test"
2694        );
2695
2696        let stack = manual();
2697        for (i, &(a, b)) in bounds.iter().enumerate() {
2698            if i != 1 {
2699                stack.append(&all[a..b]).unwrap();
2700            }
2701        }
2702        assert_eq!(stack.len(), 300, "the middle pack must be the missing one");
2703
2704        let hit = stack.extents_with_rows(&extents).unwrap();
2705        assert_eq!(
2706            hit,
2707            vec![true, false, true, true],
2708            "pack B has no rows in the tail and was reported absorbed"
2709        );
2710
2711        // **The containment bound, made observable.** Asking only about A and B
2712        // is the torn-journal case: pack C's rows are in the tail but its extent
2713        // is not in the question, and C's first row sits at exactly B's end
2714        // offset. That row belongs to C and must not answer for B.
2715        assert_eq!(
2716            stack.extents_with_rows(&extents[..2]).unwrap(),
2717            vec![true, false],
2718            "a row one byte past pack B's extent was counted as B's"
2719        );
2720
2721        // A zero-length extent has no rows by construction, and an extent past
2722        // everything the tail knows about has none either.
2723        let past = all[399].offset + all[399].len;
2724        assert_eq!(
2725            stack.extents_with_rows(&[(0, 0), (past, 4096)]).unwrap(),
2726            vec![false, false]
2727        );
2728        // And an extent that covers exactly one row is that row's pack.
2729        assert_eq!(
2730            stack
2731                .extents_with_rows(&[(all[7].offset, all[7].len)])
2732                .unwrap(),
2733            vec![true]
2734        );
2735    }
2736
2737    /// A file-backed tail survives being closed and reopened, and the reopened
2738    /// stack warm-starts a projection over everything — including rows that were
2739    /// only in the tail when the process went away.
2740    ///
2741    /// Seen RED by having `from_db` build the projection from an empty slice
2742    /// instead of `scan(&db)`: "reopened projection covers 0 of 90". Restored.
2743    #[test]
2744    fn a_file_backed_tail_reopens_warm() {
2745        let dir = tempfile::tempdir().unwrap();
2746        let path = dir.path().join("objects.tail.redb");
2747        let all = synthetic_entries(90, 20, 41);
2748        {
2749            let stack =
2750                ObjectReadStack::<OneTableFourColumns>::open(
2751                    &path,
2752                    RebuildTriggers::manual(),
2753                    DEFAULT_REDB_CACHE_BYTES,
2754                )
2755                    .unwrap();
2756            stack.append(&all[..40]).unwrap();
2757            stack.rebuild().unwrap();
2758            stack.append(&all[40..]).unwrap();
2759            assert_eq!(stack.projection_len(), 40);
2760        }
2761        let stack =
2762            ObjectReadStack::<OneTableFourColumns>::open(
2763                    &path,
2764                    RebuildTriggers::manual(),
2765                    DEFAULT_REDB_CACHE_BYTES,
2766                ).unwrap();
2767        // **What "warm" means since 2026-09-04.** This used to assert
2768        // `projection_len() == 90`: the old open rebuilt the projection from a
2769        // full redb scan, so everything was in it. Now the open MAPS the folded
2770        // index (the 40 rows the rebuild persisted) and leaves the 50 that
2771        // arrived after it in redb, where the fall-through serves them — the
2772        // same routing an un-absorbed row always had. The repository is still
2773        // 90 rows and every one of them answers; what changed is that nothing
2774        // was rebuilt to make that so, and `persisted_loads` is the applied
2775        // output that says so.
2776        assert_eq!(stack.len(), 90, "the repository is {} of 90 rows", stack.len());
2777        assert_eq!(stack.projection_len(), 40, "the mapped index holds what the fold wrote");
2778        assert_eq!(stack.stats().persisted_loads, 1, "the open must have mapped, not rebuilt");
2779        assert_eq!(stack.stats().unabsorbed_rows, 50, "the 50 later rows are redb's to serve");
2780        for e in &all {
2781            let row = stack
2782                .lookup(&e.oid)
2783                .unwrap_or_else(|| panic!("reopen lost {}", hex::encode(&e.oid)));
2784            assert_eq!(row.offset, e.offset);
2785            assert_eq!(row.uncompressed_size, e.uncompressed_size);
2786            assert_eq!(row.delta_base, e.delta_base, "reopen lost a delta base");
2787        }
2788        assert!(
2789            all.iter().filter(|e| e.delta_base != 0).count() >= 5,
2790            "the fixture must carry delta bases across the reopen"
2791        );
2792    }
2793
2794    // ── the fold ─────────────────────────────────────────────────────────────
2795
2796    /// A file-backed stack over `all[..40]` folded, `all[40..]` appended after.
2797    /// Returns the tail path and the index path.
2798    fn folded_then_appended(
2799        dir: &Path,
2800        all: &[IndexEntry],
2801        split: usize,
2802    ) -> (PathBuf, PathBuf, ObjectReadStack<OneTableFourColumns>) {
2803        let tail = dir.join("objects.tail");
2804        let stack = ObjectReadStack::<OneTableFourColumns>::open(
2805            &tail,
2806            RebuildTriggers::manual(),
2807            DEFAULT_REDB_CACHE_BYTES,
2808        )
2809        .unwrap();
2810        stack.append(&all[..split]).unwrap();
2811        stack.rebuild().unwrap();
2812        stack.append(&all[split..]).unwrap();
2813        let index = stack.persisted_path().expect("the default arm persists").to_path_buf();
2814        (tail, index, stack)
2815    }
2816
2817    /// How many rows redb holds right now — the thing the fold exists to keep
2818    /// small, read straight off the table so the test cannot be fooled by the
2819    /// stack's own bookkeeping.
2820    fn redb_rows(stack: &ObjectReadStack<OneTableFourColumns>) -> u64 {
2821        let r = stack.tail.begin_read().unwrap();
2822        r.open_table(OBJECTS).unwrap().len().unwrap()
2823    }
2824
2825    /// **A rebuild on a file-backed stack is a fold**: the projection lands in
2826    /// `objects.index`, and redb forgets the rows it holds. Asserted on the
2827    /// file and on redb's own row count, not on the stack's counters.
2828    ///
2829    /// Seen RED by skipping the `objects.remove` loop in `fold_locked`: redb
2830    /// kept 40 rows after the fold ("redb must hold only what arrived after the
2831    /// fold / left: 40 / right: 0"). Seen RED by skipping `write_atomic`: the
2832    /// index file was absent. Restored.
2833    #[test]
2834    fn a_rebuild_on_a_file_backed_stack_is_a_fold() {
2835        let dir = tempfile::tempdir().unwrap();
2836        let all = synthetic_entries(90, 20, 43);
2837        let (_tail, index, stack) = folded_then_appended(dir.path(), &all, 40);
2838
2839        assert!(index.is_file(), "the fold must persist the projection at {}", index.display());
2840        assert!(
2841            fs::metadata(&index).unwrap().len() > 40 * 61,
2842            "the persisted index must carry at least the rows' own bytes"
2843        );
2844        assert_eq!(stack.stats().folds, 1);
2845        assert_eq!(redb_rows(&stack), 50, "redb must hold only what arrived after the fold");
2846        assert_eq!(stack.projection_len(), 40);
2847        assert_eq!(stack.len(), 90);
2848        assert!(!stack.projection_is_complete());
2849        for e in &all {
2850            let row = stack.lookup(&e.oid).unwrap_or_else(|| panic!("lost {}", hex::encode(&e.oid)));
2851            assert_eq!((row.offset, row.len, row.delta_base), (e.offset, e.len, e.delta_base));
2852        }
2853        // The tail rows are the tail's: served with the tail bit, as before.
2854        assert!(is_tail_row(&stack.lookup(&all[80].oid).unwrap()));
2855        assert!(!is_tail_row(&stack.lookup(&all[10].oid).unwrap()));
2856
2857        // A second fold takes the 50 with it and redb is empty.
2858        stack.rebuild().unwrap();
2859        assert_eq!(redb_rows(&stack), 0);
2860        assert_eq!(stack.projection_len(), 90);
2861        assert!(stack.projection_is_complete());
2862        assert_eq!(stack.stats().folds, 2);
2863    }
2864
2865    /// **Re-appending a folded row creates nothing.** `append` dedupes against
2866    /// the projection AND redb, because on a persisting stack the folded rows
2867    /// are in the projection only — a check against redb alone would insert
2868    /// every re-pushed or re-indexed object a second time, into redb, and the
2869    /// two layers would each hold it.
2870    ///
2871    /// Seen RED before the projection check existed, on the real store:
2872    /// `git_ops::tests::dropping_the_exploded_table_and_reopening_still_answers`
2873    /// re-indexed a pack from the journal after a reopen and the row count
2874    /// read **2704 for 1352 objects** — exactly twice. Seen RED here by
2875    /// consulting redb only: `redb_rows` read 40 where 0 was expected and
2876    /// `len()` read 80. Restored.
2877    #[test]
2878    fn re_appending_a_folded_row_creates_no_second_row() {
2879        let dir = tempfile::tempdir().unwrap();
2880        let all = synthetic_entries(40, 20, 50);
2881        let tail = dir.path().join("objects.tail");
2882        let stack = ObjectReadStack::<OneTableFourColumns>::open(
2883            &tail,
2884            RebuildTriggers::manual(),
2885            DEFAULT_REDB_CACHE_BYTES,
2886        )
2887        .unwrap();
2888        stack.append(&all).unwrap();
2889        stack.rebuild().unwrap();
2890        assert_eq!(redb_rows(&stack), 0, "precondition: everything folded");
2891
2892        // The same objects again — a re-push, or the journal re-indexing a
2893        // pack after a reopen. Placement may differ; identity must agree.
2894        let mut again = all.clone();
2895        for e in &mut again {
2896            e.offset += 1_000_000;
2897        }
2898        stack.append(&again).unwrap();
2899        assert_eq!(redb_rows(&stack), 0, "a folded row re-appended must not land in redb");
2900        assert_eq!(stack.len(), 40);
2901        assert_eq!(stack.oids_in_order().unwrap().len(), 40);
2902        // And the first writer's placement stands, as `append`'s contract says.
2903        assert_eq!(stack.lookup(&all[3].oid).unwrap().offset, all[3].offset);
2904
2905        // The identity check still bites through the projection path: a
2906        // different inflated size for a folded oid is refused, not inserted.
2907        let mut lie = all[7].clone();
2908        lie.uncompressed_size += 1;
2909        let err = stack.append(&[lie]).unwrap_err().to_string();
2910        assert!(err.contains("identity violation"), "{err}");
2911        assert_eq!(redb_rows(&stack), 0);
2912    }
2913
2914    /// **A warm open maps the index and rebuilds nothing.** The proof is
2915    /// `persisted_loads == 1` plus a projection whose size is exactly what the
2916    /// fold wrote — a rebuilt projection would hold all 90.
2917    ///
2918    /// Seen RED by making `load_persisted` return `None` unconditionally: the
2919    /// open fell back to the scan-and-build, `persisted_loads` read 0, and the
2920    /// projection held 90. Restored.
2921    #[test]
2922    fn a_warm_open_maps_the_folded_index_and_does_not_rebuild() {
2923        let dir = tempfile::tempdir().unwrap();
2924        let all = synthetic_entries(90, 20, 44);
2925        let (tail, _index, stack) = folded_then_appended(dir.path(), &all, 60);
2926        drop(stack);
2927
2928        let again = ObjectReadStack::<OneTableFourColumns>::open(
2929            &tail,
2930            RebuildTriggers::manual(),
2931            DEFAULT_REDB_CACHE_BYTES,
2932        )
2933        .unwrap();
2934        let s = again.stats();
2935        assert_eq!(s.persisted_loads, 1, "the open must have taken the index off disk");
2936        assert_eq!(s.folds, 0, "a warm open folds nothing — there is nothing to migrate");
2937        assert_eq!(again.projection_len(), 60, "the mapped projection is what the fold wrote");
2938        assert_eq!(again.len(), 90);
2939        assert_eq!(s.unabsorbed_rows, 30);
2940        assert_eq!(redb_rows(&again), 30);
2941        // The column-scan aggregates are exact from the first read — computed
2942        // over the redb remainder at open, not left at zero.
2943        let expect_size: u64 = all.iter().map(|e| e.uncompressed_size).sum();
2944        assert_eq!(again.sum_uncompressed(), expect_size);
2945        for t in [ObjType::Commit, ObjType::Tree, ObjType::Blob, ObjType::OfsDelta] {
2946            assert_eq!(
2947                again.count_type(t),
2948                all.iter().filter(|e| e.obj_type == t).count(),
2949                "count_type({t:?}) over both layers"
2950            );
2951        }
2952        for e in &all {
2953            let row = again.lookup(&e.oid).unwrap_or_else(|| panic!("lost {}", hex::encode(&e.oid)));
2954            assert_eq!(row.uncompressed_size, e.uncompressed_size);
2955        }
2956    }
2957
2958    /// **The crash window a fold leaves is reconciled at open.** The fold
2959    /// renames the index into place and THEN forgets the rows in redb; a crash
2960    /// between the two leaves every folded row in both. Staged here by putting
2961    /// the folded rows back into redb by hand after a fold, exactly as that
2962    /// crash would leave them.
2963    ///
2964    /// Seen RED by removing the `dup` pass in `from_db`: `oids_in_order` came
2965    /// back with 90 entries for a 60-object repository and `len()` read 90.
2966    /// Restored.
2967    #[test]
2968    fn an_interrupted_fold_is_reconciled_at_the_next_open() {
2969        let dir = tempfile::tempdir().unwrap();
2970        let all = synthetic_entries(60, 20, 45);
2971        let (tail, _index, stack) = folded_then_appended(dir.path(), &all, 30);
2972        // Put the 30 folded rows back — the state after "renamed, then died".
2973        {
2974            let w = stack.tail.begin_write().unwrap();
2975            {
2976                let mut objects = w.open_table(OBJECTS).unwrap();
2977                for (i, e) in all[..30].iter().enumerate() {
2978                    objects.insert(e.oid.as_slice(), encode_row(1000 + i as u64, e).as_slice()).unwrap();
2979                }
2980            }
2981            w.commit().unwrap();
2982        }
2983        assert_eq!(redb_rows(&stack), 60, "precondition: every row is now in both layers");
2984        drop(stack);
2985
2986        let again = ObjectReadStack::<OneTableFourColumns>::open(
2987            &tail,
2988            RebuildTriggers::manual(),
2989            DEFAULT_REDB_CACHE_BYTES,
2990        )
2991        .unwrap();
2992        assert_eq!(redb_rows(&again), 30, "the duplicates must leave redb at open");
2993        assert_eq!(again.len(), 60);
2994        assert_eq!(again.tail_epoch_and_len().unwrap().1, 60);
2995        let oids = again.oids_in_order().unwrap();
2996        assert_eq!(oids.len(), 60, "each object once");
2997        assert!(oids.windows(2).all(|w| w[0] < w[1]), "oid order, no repeats");
2998    }
2999
3000    /// **The tail-wide answers cover both layers.** `oids_in_order` is the
3001    /// merge of the persisted projection and redb; `tail_epoch_and_len` counts
3002    /// both; `extents_with_rows` finds a row whichever layer holds it.
3003    ///
3004    /// Seen RED by returning only redb's keys from `oids_in_order` on a
3005    /// persisting stack: 50 of 90. Seen RED by scanning only redb in
3006    /// `extents_with_rows`: the folded extent read `false`. Restored.
3007    #[test]
3008    fn the_tail_wide_answers_cover_both_layers() {
3009        let dir = tempfile::tempdir().unwrap();
3010        let all = synthetic_entries(90, 20, 46);
3011        let (_tail, _index, stack) = folded_then_appended(dir.path(), &all, 40);
3012
3013        let mut expect: Vec<Vec<u8>> = all.iter().map(|e| e.oid.clone()).collect();
3014        expect.sort();
3015        assert_eq!(stack.oids_in_order().unwrap(), expect, "the merge of both layers, in oid order");
3016        assert_eq!(stack.tail_epoch_and_len().unwrap().1, 90);
3017
3018        // One extent that only the persisted half can answer, one only redb can,
3019        // and one nobody can.
3020        let folded = &all[3];
3021        let tailed = &all[77];
3022        let nowhere = (u64::MAX / 2, 7u64);
3023        let hit = stack
3024            .extents_with_rows(&[(folded.offset, folded.len), (tailed.offset, tailed.len), nowhere])
3025            .unwrap();
3026        assert_eq!(hit, vec![true, true, false], "folded / tail / absent");
3027    }
3028
3029    /// **The ordinal space is the merge of both layers, and says which is which.**
3030    /// After a fold with nothing appended, every oid comes off the index and
3031    /// none off redb; after more appends the tail rows are counted as such; in
3032    /// every state the bytes equal `oids_in_order` concatenated.
3033    ///
3034    /// Seen RED by returning the redb tail alone on a persisting stack:
3035    /// `folded_rows` read 0 where 40 was expected and the space held 50 of 90
3036    /// oids. Restored.
3037    #[test]
3038    fn the_ordinal_space_is_both_layers_with_provenance() {
3039        let dir = tempfile::tempdir().unwrap();
3040        let all = synthetic_entries(90, 20, 51);
3041        let (_tail, _index, stack) = folded_then_appended(dir.path(), &all, 40);
3042
3043        let expect: Vec<u8> = stack.oids_in_order().unwrap().concat();
3044        let space = stack.oids_raw().unwrap();
3045        assert_eq!(space.raw, expect, "the space must be oids_in_order, concatenated");
3046        assert_eq!((space.folded_rows, space.tail_rows), (40, 50));
3047        assert_eq!(space.raw.len(), 90 * 20);
3048
3049        stack.rebuild().unwrap();
3050        let space = stack.oids_raw().unwrap();
3051        assert_eq!(space.raw, expect, "a fold does not change the space");
3052        assert_eq!((space.folded_rows, space.tail_rows), (90, 0), "everything off the index now");
3053    }
3054
3055    /// **On a persisting stack the volume trigger scales with the index.** With
3056    /// a 1-byte floor and `fold_ratio = 1`, appending fewer object bytes than the
3057    /// persisted index is long does not fold; appending past it does. On an
3058    /// in-memory stack the same triggers fold at the floor, because there a
3059    /// rebuild writes nothing and the fixed cadence is right.
3060    ///
3061    /// Seen RED by making `volume_threshold` return the floor unconditionally:
3062    /// the small append folded ("a small append must not fold a big index /
3063    /// left: 2 / right: 1"). Restored.
3064    #[test]
3065    fn the_volume_trigger_scales_with_the_persisted_index() {
3066        let dir = tempfile::tempdir().unwrap();
3067        let mut all = synthetic_entries(400, 20, 52);
3068        for e in &mut all {
3069            e.len = 100;
3070        }
3071        let triggers = RebuildTriggers {
3072            tail_bytes: 1,
3073            fold_ratio: 1.0,
3074            ..RebuildTriggers::manual()
3075        };
3076        let stack = ObjectReadStack::<OneTableFourColumns>::open(
3077            &dir.path().join("objects.tail"),
3078            triggers,
3079            DEFAULT_REDB_CACHE_BYTES,
3080        )
3081        .unwrap();
3082        // The first append trips the 1-byte floor at once: there is no index yet
3083        // to scale against. After it the index is 300 rows ≈ 16 kB of IPC.
3084        stack.append(&all[..300]).unwrap();
3085        let folds = stack.stats().folds;
3086        assert_eq!(folds, 1, "the first batch folds at the floor");
3087        let index_bytes = stack.ipc_bytes() as u64;
3088        assert!(index_bytes > 10_000, "precondition: a real index, got {index_bytes} B");
3089
3090        // 50 rows × 100 B = 5 000 B of object bytes: well under the index size.
3091        stack.append(&all[300..350]).unwrap();
3092        assert_eq!(stack.stats().folds, folds, "a small append must not fold a big index");
3093        assert_eq!(stack.stats().unabsorbed_rows, 50, "the 50 rows wait in redb");
3094
3095        // Enough more to pass the index size: 50 rows × 100 B on top → 10 000 B
3096        // is still under 16 kB, so lift the lens for the last batch instead.
3097        let mut big: Vec<IndexEntry> = all[350..400].to_vec();
3098        for e in &mut big {
3099            e.len = index_bytes; // one row is enough to cross it
3100        }
3101        stack.append(&big[..1]).unwrap();
3102        assert_eq!(stack.stats().folds, folds + 1, "crossing the index size folds");
3103        assert!(stack.projection_is_complete());
3104
3105        // The same triggers on an in-memory stack: the floor alone decides.
3106        let mem = ObjectReadStack::<OneTableFourColumns>::in_memory(triggers).unwrap();
3107        mem.append(&all[..300]).unwrap();
3108        let g = mem.stats().generation;
3109        mem.append(&all[300..350]).unwrap();
3110        assert!(
3111            mem.stats().generation > g,
3112            "without persistence the fixed floor still rebuilds on every batch"
3113        );
3114        assert_eq!(mem.stats().folds, 0, "and nothing is written");
3115    }
3116
3117    /// **`retain` rewrites the persisted index without the dead rows**, whichever
3118    /// layer they were in, and the count it returns is both halves.
3119    ///
3120    /// Seen RED by passing `None` instead of `Some(live)` to `fold_locked` from
3121    /// `retain`: the dead folded oid was still answered after the call, and the
3122    /// count was 1 where 2 rows had died. Restored.
3123    #[test]
3124    fn retain_rewrites_the_persisted_index_without_the_dead_rows() {
3125        let dir = tempfile::tempdir().unwrap();
3126        let all = synthetic_entries(90, 20, 47);
3127        let (tail, index, stack) = folded_then_appended(dir.path(), &all, 40);
3128        let dead_folded = all[5].oid.clone();
3129        let dead_tailed = all[70].oid.clone();
3130        let before = fs::metadata(&index).unwrap().len();
3131        let epoch_before = stack.tail_epoch_and_len().unwrap().0;
3132
3133        let dropped = stack
3134            .retain(&|oid: &[u8]| oid != dead_folded.as_slice() && oid != dead_tailed.as_slice())
3135            .unwrap();
3136        assert_eq!(dropped, 2, "one from each layer");
3137        assert!(stack.lookup(&dead_folded).is_none(), "the folded dead row must be gone");
3138        assert!(stack.lookup(&dead_tailed).is_none(), "the tail dead row must be gone");
3139        assert_eq!(stack.len(), 88);
3140        assert_eq!(stack.projection_len(), 88, "retain folds: everything live is in the index");
3141        assert_eq!(redb_rows(&stack), 0);
3142        assert!(
3143            fs::metadata(&index).unwrap().len() > before,
3144            "the index was rewritten (88 rows now, was 40)"
3145        );
3146        assert!(
3147            stack.tail_epoch_and_len().unwrap().0 > epoch_before,
3148            "a changed oid set is a changed epoch"
3149        );
3150        for e in all.iter().filter(|e| e.oid != dead_folded && e.oid != dead_tailed) {
3151            assert!(stack.lookup(&e.oid).is_some(), "live row {} lost", hex::encode(&e.oid));
3152        }
3153
3154        // And it survives a reopen off the rewritten file.
3155        drop(stack);
3156        let again = ObjectReadStack::<OneTableFourColumns>::open(
3157            &tail,
3158            RebuildTriggers::manual(),
3159            DEFAULT_REDB_CACHE_BYTES,
3160        )
3161        .unwrap();
3162        assert_eq!(again.stats().persisted_loads, 1);
3163        assert_eq!(again.len(), 88);
3164        assert!(again.lookup(&dead_folded).is_none());
3165    }
3166
3167    /// **A damaged index is ignored, loudly, and the store still opens.** The
3168    /// rows redb has are served; the rest are the journal's to re-index. What
3169    /// this asserts is the shape of the failure: open succeeds, the projection
3170    /// is empty, redb's rows answer, and nothing panicked on a half-written file.
3171    ///
3172    /// Seen RED by making `load_persisted` propagate the decode error: the open
3173    /// failed. Restored.
3174    #[test]
3175    fn a_damaged_persisted_index_is_ignored_and_redb_still_answers() {
3176        let dir = tempfile::tempdir().unwrap();
3177        let all = synthetic_entries(60, 20, 48);
3178        let (tail, index, stack) = folded_then_appended(dir.path(), &all, 30);
3179        drop(stack);
3180        // Truncate the index mid-stream: an IPC prefix, not an index.
3181        let bytes = fs::read(&index).unwrap();
3182        fs::write(&index, &bytes[..bytes.len() / 2]).unwrap();
3183
3184        let again = ObjectReadStack::<OneTableFourColumns>::open(
3185            &tail,
3186            RebuildTriggers::manual(),
3187            DEFAULT_REDB_CACHE_BYTES,
3188        )
3189        .unwrap();
3190        assert_eq!(again.stats().persisted_loads, 0, "a damaged index must not count as loaded");
3191        assert_eq!(again.len(), 30, "the 30 redb rows are what the store knows");
3192        for e in &all[30..] {
3193            assert!(again.lookup(&e.oid).is_some(), "redb row {} lost", hex::encode(&e.oid));
3194        }
3195        for e in &all[..30] {
3196            assert!(
3197                again.lookup(&e.oid).is_none(),
3198                "a row only the damaged index held must read absent, so the journal re-indexes it"
3199            );
3200        }
3201        // And the store keeps working: the next fold overwrites the damage.
3202        again.append(&all[..30]).unwrap();
3203        again.rebuild().unwrap();
3204        assert_eq!(again.len(), 60);
3205        drop(again);
3206        let third = ObjectReadStack::<OneTableFourColumns>::open(
3207            &tail,
3208            RebuildTriggers::manual(),
3209            DEFAULT_REDB_CACHE_BYTES,
3210        )
3211        .unwrap();
3212        assert_eq!(third.stats().persisted_loads, 1);
3213        assert_eq!(third.len(), 60);
3214    }
3215
3216    /// **A store from before persistence folds once, at its first open, and
3217    /// never pays the scan again.** Staged by building a redb-only tail (the
3218    /// old shape: every row in redb, no index file) and opening it.
3219    ///
3220    /// Seen RED by removing the cold-open fold in `from_db`: `folds` read 0,
3221    /// the index file was absent, and a second open rebuilt from redb again
3222    /// (`persisted_loads == 0`). Restored.
3223    #[test]
3224    fn a_cold_open_of_a_pre_fold_store_folds_it_once() {
3225        let dir = tempfile::tempdir().unwrap();
3226        let tail = dir.path().join("objects.tail");
3227        let all = synthetic_entries(70, 20, 49);
3228        // The pre-2026-09-04 shape, written by hand: rows in redb, stamped with
3229        // this layout, no index anywhere.
3230        {
3231            let db = Database::create(&tail).unwrap();
3232            let w = db.begin_write().unwrap();
3233            {
3234                let mut objects = w.open_table(OBJECTS).unwrap();
3235                for (i, e) in all.iter().enumerate() {
3236                    objects.insert(e.oid.as_slice(), encode_row(i as u64, e).as_slice()).unwrap();
3237                }
3238                let mut meta = w.open_table(META).unwrap();
3239                meta.insert(META_TAIL_FORMAT, TAIL_FORMAT).unwrap();
3240                meta.insert(META_ARRIVAL_SEQ, all.len() as u64).unwrap();
3241            }
3242            w.commit().unwrap();
3243        }
3244        let index = tail.with_extension("index");
3245        assert!(!index.exists(), "precondition: no persisted index");
3246
3247        let stack = ObjectReadStack::<OneTableFourColumns>::open(
3248            &tail,
3249            RebuildTriggers::manual(),
3250            DEFAULT_REDB_CACHE_BYTES,
3251        )
3252        .unwrap();
3253        assert_eq!(stack.stats().persisted_loads, 0, "a cold open has nothing to map");
3254        assert_eq!(stack.stats().folds, 1, "and folds exactly once");
3255        assert!(index.is_file(), "the fold must have written {}", index.display());
3256        assert_eq!(redb_rows(&stack), 0, "redb is emptied by the migration fold");
3257        assert_eq!(stack.len(), 70);
3258        assert!(stack.projection_is_complete());
3259        for e in &all {
3260            assert!(stack.lookup(&e.oid).is_some(), "migrated row {} lost", hex::encode(&e.oid));
3261        }
3262        drop(stack);
3263
3264        let again = ObjectReadStack::<OneTableFourColumns>::open(
3265            &tail,
3266            RebuildTriggers::manual(),
3267            DEFAULT_REDB_CACHE_BYTES,
3268        )
3269        .unwrap();
3270        assert_eq!(again.stats().persisted_loads, 1, "the second open is warm");
3271        assert_eq!(again.stats().folds, 0);
3272        assert_eq!(again.len(), 70);
3273    }
3274
3275    // ── the redb 2 tail, migrated (2026-10-06) ───────────────────────────────
3276
3277    /// Both tables of a tail, as bytes: `(objects, meta)`.
3278    type TailTables = (Vec<(Vec<u8>, Vec<u8>)>, Vec<(String, u64)>);
3279
3280    fn read_tail(db: &Database) -> TailTables {
3281        let r = db.begin_read().unwrap();
3282        let objects = r
3283            .open_table(OBJECTS)
3284            .unwrap()
3285            .iter()
3286            .unwrap()
3287            .map(|kv| {
3288                let (k, v) = kv.unwrap();
3289                (k.value().to_vec(), v.value().to_vec())
3290            })
3291            .collect();
3292        let meta = r
3293            .open_table(META)
3294            .unwrap()
3295            .iter()
3296            .unwrap()
3297            .map(|kv| {
3298                let (k, v) = kv.unwrap();
3299                (k.value().to_owned(), v.value())
3300            })
3301            .collect();
3302        (objects, meta)
3303    }
3304
3305    /// Rewrite the redb 4 tail at `path` as the redb 2 file every store made
3306    /// before 2026-10-06 has: same tables, same rows, redb 2.6's own writer.
3307    fn rewrite_as_redb2(path: &Path) -> TailTables {
3308        let tables = read_tail(&Database::open(path).unwrap());
3309        fs::remove_file(path).unwrap();
3310        let db = redb2::Database::create(path).unwrap();
3311        let w = db.begin_write().unwrap();
3312        {
3313            let objects_def: redb2::TableDefinition<&[u8], &[u8]> =
3314                redb2::TableDefinition::new("objects");
3315            let meta_def: redb2::TableDefinition<&str, u64> = redb2::TableDefinition::new("meta");
3316            let mut objects = w.open_table(objects_def).unwrap();
3317            for (k, v) in &tables.0 {
3318                objects.insert(k.as_slice(), v.as_slice()).unwrap();
3319            }
3320            let mut meta = w.open_table(meta_def).unwrap();
3321            for (k, v) in &tables.1 {
3322                meta.insert(k.as_str(), *v).unwrap();
3323            }
3324        }
3325        w.commit().unwrap();
3326        tables
3327    }
3328
3329    fn allocated(path: &Path) -> u64 {
3330        file_sizes(path).1
3331    }
3332
3333    /// **An old store opens, answers every object it answered, and gives the
3334    /// disk back.** The tail is in the shape a busy store leaves it: 40 rows
3335    /// folded into `objects.index`, 50 that arrived after the fold still in the
3336    /// tail — written by redb 2, as every tail before 2026-10-06 was.
3337    #[test]
3338    fn a_redb2_tail_is_migrated_once_and_answers_the_same() {
3339        let dir = tempfile::tempdir().unwrap();
3340        let tail = dir.path().join("objects.tail");
3341        let all = synthetic_entries(91, 20, 41);
3342        {
3343            let stack = ObjectReadStack::<OneTableFourColumns>::open(
3344                &tail,
3345                RebuildTriggers::manual(),
3346                DEFAULT_REDB_CACHE_BYTES,
3347            )
3348            .unwrap();
3349            stack.append(&all[..40]).unwrap();
3350            stack.rebuild().unwrap();
3351            stack.append(&all[40..90]).unwrap();
3352        }
3353        let before = rewrite_as_redb2(&tail);
3354        assert_eq!(before.0.len(), 50, "fixture: the 50 rows after the fold are in the tail");
3355        let old_alloc = allocated(&tail);
3356        // Opened and closed once, as a running server does to every store.
3357        drop(redb2::Database::create(&tail).unwrap());
3358        let old_closed = allocated(&tail);
3359        assert!(
3360            old_closed > 1_000_000,
3361            "fixture: a closed redb 2 tail allocates {old_closed} B (create: {old_alloc} B) — \
3362             the cost this migration exists to remove"
3363        );
3364
3365        let stack = ObjectReadStack::<OneTableFourColumns>::open(
3366            &tail,
3367            RebuildTriggers::manual(),
3368            DEFAULT_REDB_CACHE_BYTES,
3369        )
3370        .expect("an old store must open");
3371        assert!(!migrating_path(&tail).exists(), "the staging file is renamed, not left");
3372        assert_eq!(read_tail(&stack.tail), before, "every row and every counter carried over");
3373        assert_eq!(stack.len(), 90);
3374        assert_eq!(stack.stats().persisted_loads, 1, "the folded index is still mapped");
3375        assert_eq!(stack.stats().unabsorbed_rows, 50);
3376        for e in &all[..90] {
3377            let row = stack
3378                .lookup(&e.oid)
3379                .unwrap_or_else(|| panic!("the migration lost {}", hex::encode(&e.oid)));
3380            assert_eq!(row.offset, e.offset);
3381            assert_eq!(row.delta_base, e.delta_base);
3382        }
3383        // It keeps working: a new row lands after the old ones.
3384        stack.append(&all[90..]).unwrap();
3385        assert_eq!(stack.len(), 91);
3386        drop(stack);
3387
3388        let new_closed = allocated(&tail);
3389        assert!(
3390            new_closed <= 100 * 1024,
3391            "the migrated tail allocates {new_closed} B (redb 2: {old_closed} B)"
3392        );
3393        // Once: the second open is a plain redb 4 open.
3394        let again = ObjectReadStack::<OneTableFourColumns>::open(
3395            &tail,
3396            RebuildTriggers::manual(),
3397            DEFAULT_REDB_CACHE_BYTES,
3398        )
3399        .unwrap();
3400        assert_eq!(again.len(), 91);
3401        assert!(again.lookup(&all[90].oid).is_some());
3402    }
3403
3404    /// A migration that died before its rename left a half-built redb 4 file
3405    /// beside the untouched redb 2 tail. The next open throws the half away and
3406    /// migrates again; nothing is read from it.
3407    #[test]
3408    fn an_interrupted_migration_is_started_again_from_the_redb2_tail() {
3409        let dir = tempfile::tempdir().unwrap();
3410        let tail = dir.path().join("objects.tail");
3411        let all = synthetic_entries(30, 32, 7);
3412        {
3413            let stack = ObjectReadStack::<OneTableFourColumns>::open(
3414                &tail,
3415                RebuildTriggers::manual(),
3416                DEFAULT_REDB_CACHE_BYTES,
3417            )
3418            .unwrap();
3419            stack.append(&all).unwrap();
3420        }
3421        let before = rewrite_as_redb2(&tail);
3422        fs::write(migrating_path(&tail), b"half a redb 4 file, cut by a power loss").unwrap();
3423
3424        let stack = ObjectReadStack::<OneTableFourColumns>::open(
3425            &tail,
3426            RebuildTriggers::manual(),
3427            DEFAULT_REDB_CACHE_BYTES,
3428        )
3429        .expect("the open must migrate over the leftover");
3430        assert!(!migrating_path(&tail).exists());
3431        assert_eq!(stack.len(), 30);
3432        for e in &all {
3433            assert!(stack.lookup(&e.oid).is_some(), "lost {}", hex::encode(&e.oid));
3434        }
3435        let _ = before;
3436    }
3437
3438    /// **A brand-new tail is small.** The per-repository floor this crate puts
3439    /// on a disk, created and closed, then after one small append.
3440    #[test]
3441    fn a_new_tail_allocates_kilobytes_not_megabytes() {
3442        let dir = tempfile::tempdir().unwrap();
3443        let tail = dir.path().join("objects.tail");
3444        let open = || {
3445            ObjectReadStack::<OneTableFourColumns>::open(
3446                &tail,
3447                RebuildTriggers::manual(),
3448                DEFAULT_REDB_CACHE_BYTES,
3449            )
3450            .unwrap()
3451        };
3452        drop(open());
3453        let empty = allocated(&tail);
3454        let stack = open();
3455        stack.append(&synthetic_entries(3, 20, 3)).unwrap();
3456        drop(stack);
3457        drop(open());
3458        let small = allocated(&tail);
3459        assert!(empty <= 64 * 1024, "an empty tail allocates {empty} B");
3460        assert!(small <= 64 * 1024, "a three-object tail allocates {small} B");
3461    }
3462
3463    /// **A migrated store gets its redb file back.** A pre-fold store big enough
3464    /// to matter (here 200 k rows, ~34 MB of redb) is opened once: the migration
3465    /// fold empties redb, and the open compacts the file because it now carries
3466    /// more than [`COMPACT_FRAGMENTED_FLOOR`] of dead pages. Asserted on the
3467    /// file's size on disk, not on a flag. A small store (the 70-row fixture next
3468    /// door) is deliberately left alone — that case is covered by the floor, and
3469    /// this test's precondition asserts the fixture is above it.
3470    ///
3471    /// Seen RED by making `compact_if_fragmented` return before `db.compact()`:
3472    /// "the migrated redb must shrink" with equal before/after sizes. Restored.
3473    #[test]
3474    fn a_migrated_store_gets_its_redb_file_back() {
3475        let dir = tempfile::tempdir().unwrap();
3476        let tail = dir.path().join("objects.tail");
3477        let n = 200_000usize;
3478        let all = synthetic_entries(n, 20, 53);
3479        {
3480            let db = Database::create(&tail).unwrap();
3481            let w = db.begin_write().unwrap();
3482            {
3483                let mut objects = w.open_table(OBJECTS).unwrap();
3484                for (i, e) in all.iter().enumerate() {
3485                    objects.insert(e.oid.as_slice(), encode_row(i as u64, e).as_slice()).unwrap();
3486                }
3487                let mut meta = w.open_table(META).unwrap();
3488                meta.insert(META_TAIL_FORMAT, TAIL_FORMAT).unwrap();
3489                meta.insert(META_ARRIVAL_SEQ, n as u64).unwrap();
3490            }
3491            w.commit().unwrap();
3492        }
3493        let before = fs::metadata(&tail).unwrap().len();
3494        assert!(
3495            before >= COMPACT_FRAGMENTED_FLOOR,
3496            "precondition: the fixture must be above the compaction floor, is {before} B"
3497        );
3498
3499        let stack = ObjectReadStack::<OneTableFourColumns>::open(
3500            &tail,
3501            RebuildTriggers::manual(),
3502            DEFAULT_REDB_CACHE_BYTES,
3503        )
3504        .unwrap();
3505        assert_eq!(stack.stats().folds, 1, "the migration fold ran");
3506        assert_eq!(redb_rows(&stack), 0);
3507        let after = fs::metadata(&tail).unwrap().len();
3508        assert!(
3509            after < before / 4,
3510            "the migrated redb must shrink: {after} >= {before} / 4"
3511        );
3512        // And it still works, in both directions.
3513        assert!(stack.lookup(&all[123].oid).is_some());
3514        stack.append(&synthetic_entries(10, 20, 54)).unwrap();
3515        assert_eq!(redb_rows(&stack), 10);
3516    }
3517
3518    /// The packed tail row rejects the two things that would make it silently
3519    /// wrong: a short buffer and a type code git does not use.
3520    ///
3521    /// Seen RED by dropping the length check from `decode_row`: the short-buffer
3522    /// case panicked — "range end index 33 out of range for slice of length 32"
3523    /// — instead of returning an error. Restored.
3524    ///
3525    /// Seen RED for the `delta_base` field by decoding it from byte 25 (where
3526    /// `uncompressed_size` starts) instead of byte 33: "the delta base did not
3527    /// survive the tail row / left: 2448 / right: 4242" — it came back holding
3528    /// the *size*. Both are plausible u64s, so only a comparison against the
3529    /// entry that was encoded can tell them apart. Restored.
3530    ///
3531    /// A row written by the 33-byte encoding is a **length** error here rather
3532    /// than a misread: `decode_row` refuses anything that is not exactly
3533    /// `TAIL_ROW_BYTES`, and the assertion below is the same check the old width
3534    /// now trips.
3535    #[test]
3536    fn a_packed_tail_row_round_trips_and_refuses_nonsense() {
3537        let mut e = synthetic_entries(1, 20, 51)[0].clone();
3538        // A one-entry workload has nothing to delta against, so the base is set
3539        // here: a round trip that only ever saw 0 would not test the field.
3540        e.delta_base = 4242;
3541        let e = &e;
3542        let packed = encode_row(9, e);
3543        let back = decode_row(&packed).unwrap();
3544        assert_eq!(back.seq, 9);
3545        assert_eq!(back.offset, e.offset);
3546        assert_eq!(back.len, e.len);
3547        assert_eq!(back.obj_type, e.obj_type);
3548        assert_eq!(back.uncompressed_size, e.uncompressed_size);
3549        assert_eq!(
3550            back.delta_base, e.delta_base,
3551            "the delta base did not survive the tail row"
3552        );
3553        assert_ne!(
3554            back.delta_base, back.uncompressed_size,
3555            "the fixture must not let the two u64 fields alias"
3556        );
3557
3558        assert!(decode_row(&packed[..TAIL_ROW_BYTES - 1]).is_err());
3559        // 33 bytes is exactly the pre-`delta_base` row width, and it is refused
3560        // rather than read as a row with the field missing.
3561        assert!(
3562            decode_row(&packed[..33]).is_err(),
3563            "a row in the old 33-byte encoding must be refused, not reinterpreted"
3564        );
3565        let mut bad = packed;
3566        bad[24] = 5; // git uses 1-4, 6, 7; 5 is unassigned
3567        assert!(
3568            decode_row(&bad).is_err(),
3569            "an unassigned object type code was accepted"
3570        );
3571    }
3572
3573    /// The stack is the same index as the two Arrow arms once it is fully
3574    /// absorbed, which is what lets it be dropped into
3575    /// `examples/index_layout_bench.rs` unchanged.
3576    ///
3577    /// Seen RED by having `ObjectIndex::build` skip its final `rebuild()`, so
3578    /// every row was served from the tail instead of from the projection:
3579    /// "assertion `left == right` failed: build left rows in the tail / left: 0
3580    /// / right: 400". Restored.
3581    #[test]
3582    fn a_fully_absorbed_stack_agrees_with_both_arrow_arms() {
3583        let entries = synthetic_entries(400, 20, 61);
3584        let absent = synthetic_entries(100, 20, 62);
3585        let stack = Stack::build(&entries).unwrap();
3586        let a = FourTables::build(&entries).unwrap();
3587        let b = OneTableFourColumns::build(&entries).unwrap();
3588
3589        assert_eq!(stack.len(), 400);
3590        assert_eq!(stack.projection_len(), 400, "build left rows in the tail");
3591
3592        let mut refs: Vec<&[u8]> = entries.iter().map(|e| e.oid.as_slice()).collect();
3593        refs.extend(absent.iter().map(|e| e.oid.as_slice()));
3594        let rs = stack.lookup_batch(&refs);
3595        assert_eq!(
3596            rs,
3597            a.lookup_batch(&refs),
3598            "the stack disagrees with FourTables"
3599        );
3600        assert_eq!(
3601            rs,
3602            b.lookup_batch(&refs),
3603            "the stack disagrees with OneTableFourColumns"
3604        );
3605        assert_eq!(
3606            stack.stats().tail_hits,
3607            0,
3608            "a fully absorbed stack still went to the tail for a row"
3609        );
3610        assert_eq!(stack.stats().absent, 100);
3611    }
3612
3613    /// **A complete projection answers an absence without opening a redb
3614    /// transaction at all** — and the moment one row is appended, it stops doing
3615    /// that.
3616    ///
3617    /// This is the fast path the bench found was worth having: a `have`
3618    /// negotiation is mostly misses, and paying a redb round trip for each of
3619    /// them made the stack six times the cost of the bare Arrow arm on the most
3620    /// common git operation. `tail_txns` is the applied output — an assertion on
3621    /// timing would be a flake, and an assertion on the returned rows cannot
3622    /// distinguish the two paths, because both return the same rows. That is the
3623    /// point: the fast path is only allowed to exist because it is
3624    /// indistinguishable in its answers.
3625    ///
3626    /// Seen RED by deleting the `projection_is_complete()` branch from
3627    /// `lookup_batch`: "assertion `left == right` failed: a complete projection
3628    /// opened 1 redb transactions to say 'no' / left: 1 / right: 0". Restored.
3629    ///
3630    /// Seen RED the other way — the dangerous way — by making
3631    /// `projection_is_complete` return `true` unconditionally: the
3632    /// `the_projection_is_incomplete_but_never_wrong` test failed with "the
3633    /// stack lost tail object …, batch path", because an incomplete projection
3634    /// then declared its misses absent. Restored.
3635    #[test]
3636    fn a_complete_projection_answers_absent_without_asking_the_tail() {
3637        let entries = synthetic_entries(200, 20, 91);
3638        let nowhere = synthetic_entries(500, 20, 92);
3639        let stack = Stack::build(&entries).unwrap();
3640        assert!(stack.projection_is_complete());
3641
3642        let refs: Vec<&[u8]> = nowhere.iter().map(|e| e.oid.as_slice()).collect();
3643        assert!(stack.lookup_batch(&refs).iter().all(Option::is_none));
3644        assert!(stack.lookup(&nowhere[0].oid).is_none());
3645        assert!(stack.ordinals_batch(&refs).iter().all(Option::is_none));
3646        assert!(stack.extents_batch(&refs).iter().all(Option::is_none));
3647
3648        let st = stack.stats();
3649        assert_eq!(st.unabsorbed_rows, 0);
3650        assert_eq!(
3651            st.tail_txns, 0,
3652            "a complete projection opened {} redb transactions to say 'no'",
3653            st.tail_txns
3654        );
3655        assert_eq!(st.absent, 500 + 1 + 500 + 500, "absences went uncounted");
3656
3657        // One appended row and the fall-through is on again — the fast path is
3658        // conditional on completeness and not on having been complete once.
3659        let more = synthetic_entries(1, 20, 93);
3660        stack.append(&more).unwrap();
3661        assert!(!stack.projection_is_complete());
3662        assert!(stack.lookup_batch(&refs).iter().all(Option::is_none));
3663        assert!(
3664            stack.stats().tail_txns > 0,
3665            "an incomplete projection did not consult the tail"
3666        );
3667        assert!(
3668            stack.lookup(&more[0].oid).is_some(),
3669            "the appended row is not reachable"
3670        );
3671    }
3672
3673    /// **A tail written in a different layout is DROPPED and rebuilt, not
3674    /// refused for ever.**
3675    ///
3676    /// `decode_row` refuses an unknown row width on purpose — reinterpreting one
3677    /// puts a wrong offset into an index that is then queried and believed. But
3678    /// until `META_TAIL_FORMAT` existed the refusal had nothing behind it: every
3679    /// read `bail`ed and the only way back was deleting the repository.
3680    ///
3681    /// The tail is a cache, so the way back is to empty it and let
3682    /// `Absorber::adopt_journal` requeue every pack — the same path a crash
3683    /// mid-absorb already takes.
3684    ///
3685    /// RED-FIRST: with the version check removed, the reopened stack keeps the
3686    /// 90 foreign rows and the first `get` through them fails on the row width
3687    /// instead. The assertion is on the APPLIED state — the row count and a
3688    /// working `append`/`get` afterwards — not on a returned flag.
3689    #[test]
3690    fn a_tail_in_a_foreign_layout_is_dropped_and_the_store_keeps_working() {
3691        let dir = tempfile::tempdir().unwrap();
3692        let path = dir.path().join("objects.tail.redb");
3693        let all = synthetic_entries(90, 20, 41);
3694        {
3695            let stack = ObjectReadStack::<OneTableFourColumns>::open(
3696                &path,
3697                RebuildTriggers::manual(),
3698                DEFAULT_REDB_CACHE_BYTES,
3699            )
3700            .unwrap();
3701            stack.append(&all).unwrap();
3702            assert_eq!(stack.tail_epoch_and_len().unwrap().1, 90);
3703        }
3704
3705        // Stamp a version this build does not know, exactly as a future layout
3706        // change would leave behind.
3707        {
3708            let db = Database::create(&path).unwrap();
3709            let w = db.begin_write().unwrap();
3710            {
3711                let mut meta = w.open_table(META).unwrap();
3712                meta.insert(META_TAIL_FORMAT, TAIL_FORMAT + 7).unwrap();
3713            }
3714            w.commit().unwrap();
3715        }
3716
3717        let stack = ObjectReadStack::<OneTableFourColumns>::open(
3718            &path,
3719            RebuildTriggers::manual(),
3720            DEFAULT_REDB_CACHE_BYTES,
3721        )
3722        .unwrap();
3723        assert_eq!(
3724            stack.tail_epoch_and_len().unwrap().1,
3725            0,
3726            "a foreign-layout tail was kept; the next read would bail on the row width"
3727        );
3728        assert_eq!(stack.projection_len(), 0);
3729
3730        // And the store is USABLE, which is the half that says "dropped" and not
3731        // "broken": the same oids re-appended come back.
3732        stack.append(&all).unwrap();
3733        assert_eq!(stack.tail_epoch_and_len().unwrap().1, 90);
3734        let got = stack.lookup(&all[7].oid).expect("re-appended row");
3735        assert_eq!(got.offset, all[7].offset);
3736
3737        // The stamp is now this build's, so a second reopen drops nothing.
3738        drop(stack);
3739        let again = ObjectReadStack::<OneTableFourColumns>::open(
3740            &path,
3741            RebuildTriggers::manual(),
3742            DEFAULT_REDB_CACHE_BYTES,
3743        )
3744        .unwrap();
3745        assert_eq!(
3746            again.tail_epoch_and_len().unwrap().1,
3747            90,
3748            "the rewritten version did not stick, so every open would drop the tail"
3749        );
3750    }
3751
3752    /// A tail written BEFORE the version key existed carries no stamp, and must
3753    /// be adopted rather than dropped: it holds rows of exactly this layout, and
3754    /// rebuilding every deployed store for a key that was merely absent would be
3755    /// a self-inflicted re-index of the whole fleet.
3756    #[test]
3757    fn a_tail_with_no_version_stamp_is_adopted_and_stamped_not_dropped() {
3758        let dir = tempfile::tempdir().unwrap();
3759        let path = dir.path().join("objects.tail.redb");
3760        let all = synthetic_entries(50, 20, 41);
3761        {
3762            let stack = ObjectReadStack::<OneTableFourColumns>::open(
3763                &path,
3764                RebuildTriggers::manual(),
3765                DEFAULT_REDB_CACHE_BYTES,
3766            )
3767            .unwrap();
3768            stack.append(&all).unwrap();
3769        }
3770        // Remove the stamp: this is what every tail on disk looked like before
3771        // the key existed.
3772        {
3773            let db = Database::create(&path).unwrap();
3774            let w = db.begin_write().unwrap();
3775            {
3776                let mut meta = w.open_table(META).unwrap();
3777                meta.remove(META_TAIL_FORMAT).unwrap();
3778            }
3779            w.commit().unwrap();
3780        }
3781        let stack = ObjectReadStack::<OneTableFourColumns>::open(
3782            &path,
3783            RebuildTriggers::manual(),
3784            DEFAULT_REDB_CACHE_BYTES,
3785        )
3786        .unwrap();
3787        assert_eq!(stack.tail_epoch_and_len().unwrap().1, 50, "an unstamped tail was dropped");
3788        let r = stack.tail.begin_read().unwrap();
3789        let meta = r.open_table(META).unwrap();
3790        assert_eq!(
3791            meta.get(META_TAIL_FORMAT).unwrap().map(|v| v.value()),
3792            Some(TAIL_FORMAT),
3793            "the tail was adopted but not stamped, so it is re-examined for ever"
3794        );
3795    }
3796}