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::path::Path;
86use std::sync::atomic::{AtomicU64, Ordering};
87use std::sync::{Arc, RwLock};
88
89use anyhow::{Context, Result, anyhow, bail};
90use redb::{Database, ReadableTable, ReadableTableMetadata, TableDefinition};
91
92use crate::index_layout::{IndexEntry, IndexRow, ObjType, ObjectIndex, OneTableFourColumns};
93
94// ── the durable tail ──────────────────────────────────────────────────────────
95
96/// `oid → packed row`. The key is the raw oid (20 or 32 bytes), so redb's own
97/// B-tree order is oid-lexicographic order — the same order the stree and the
98/// Arrow columns are in, which makes a rebuild a straight ordered scan.
99const OBJECTS: TableDefinition<&[u8], &[u8]> = TableDefinition::new("objects");
100
101/// Small counters. `arrival_seq` is the only one that must survive a reopen: it
102/// is what gives a tail row an ordinal that no other tail row shares.
103const META: TableDefinition<&str, u64> = TableDefinition::new("meta");
104const META_ARRIVAL_SEQ: &str = "arrival_seq";
105
106/// `seq | offset | len | type | uncompressed_size | delta_base`, little-endian,
107/// fixed width.
108///
109/// ```text
110///   0  u64  seq (arrival, not a fact about the object)
111///   8  u64  offset
112///  16  u64  len
113///  24  u8   object type code
114///  25  u64  uncompressed_size
115///  33  u64  delta_base — archive offset of the base entry, 0 for none
116///                                                                     ══
117///                                                                     41
118/// ```
119///
120/// **This was 33 bytes before `delta_base` (PLAN §13).** A tail written by the
121/// older encoding is refused by [`decode_row`] on its length rather than
122/// misread: the tail is a cache the whole of §14 permits dropping and
123/// rebuilding, so a hard refusal is the right failure and a silent
124/// reinterpretation is not.
125const TAIL_ROW_BYTES: usize = 41;
126
127/// Set on the ordinal of every row that came from the tail rather than from the
128/// Arrow projection.
129///
130/// An ordinal is a **row address within one generation**, not an identity — the
131/// oid is the identity. A rebuild re-derives every ordinal as the new
132/// oid-lexicographic rank, so an ordinal held across a rebuild is meaningless.
133/// The high bit makes the dangerous version of that mistake impossible instead
134/// of merely documented: a tail ordinal used to index an Arrow column is ≥ 2^31
135/// and therefore out of bounds — a panic — rather than a silently wrong row.
136pub const TAIL_ORDINAL_BIT: u32 = 0x8000_0000;
137
138/// True when `row` was answered by the redb tail rather than by the projection.
139pub fn is_tail_row(row: &IndexRow) -> bool {
140    row.ordinal & TAIL_ORDINAL_BIT != 0
141}
142
143fn tail_ordinal(seq: u64) -> u32 {
144    TAIL_ORDINAL_BIT | (seq as u32 & !TAIL_ORDINAL_BIT)
145}
146
147fn encode_row(seq: u64, e: &IndexEntry) -> [u8; TAIL_ROW_BYTES] {
148    let mut b = [0u8; TAIL_ROW_BYTES];
149    b[0..8].copy_from_slice(&seq.to_le_bytes());
150    b[8..16].copy_from_slice(&e.offset.to_le_bytes());
151    b[16..24].copy_from_slice(&e.len.to_le_bytes());
152    b[24] = e.obj_type.code();
153    b[25..33].copy_from_slice(&e.uncompressed_size.to_le_bytes());
154    b[33..41].copy_from_slice(&e.delta_base.to_le_bytes());
155    b
156}
157
158/// One decoded tail row. `seq` is the arrival sequence, not a fact about the
159/// object.
160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
161struct TailRow {
162    seq: u64,
163    offset: u64,
164    len: u64,
165    obj_type: ObjType,
166    uncompressed_size: u64,
167    /// Archive offset of the base entry, `0` for none. An offset and not an
168    /// ordinal precisely because of this table: a tail ordinal is
169    /// [`TAIL_ORDINAL_BIT`]-tagged and a rebuild re-derives every ordinal, so an
170    /// ordinal stored here would be meaningless one rebuild later.
171    delta_base: u64,
172}
173
174fn decode_row(b: &[u8]) -> Result<TailRow> {
175    if b.len() != TAIL_ROW_BYTES {
176        bail!("tail row is {} bytes, expected {TAIL_ROW_BYTES}", b.len());
177    }
178    let u64_at = |i: usize| {
179        let mut w = [0u8; 8];
180        w.copy_from_slice(&b[i..i + 8]);
181        u64::from_le_bytes(w)
182    };
183    Ok(TailRow {
184        seq: u64_at(0),
185        offset: u64_at(8),
186        len: u64_at(16),
187        // A code the writer cannot produce must not be invented on the way out:
188        // a wrong type in an index is worse than a failure, because it is
189        // queried and believed.
190        obj_type: ObjType::from_code(b[24])
191            .ok_or_else(|| anyhow!("tail row carries object type code {}", b[24]))?,
192        uncompressed_size: u64_at(25),
193        delta_base: u64_at(33),
194    })
195}
196
197impl TailRow {
198    fn as_index_row(&self) -> IndexRow {
199        IndexRow {
200            ordinal: tail_ordinal(self.seq),
201            offset: self.offset,
202            len: self.len,
203            obj_type: self.obj_type,
204            uncompressed_size: self.uncompressed_size,
205            delta_base: self.delta_base,
206        }
207    }
208
209    /// The same facts as an [`IndexEntry`], for the rebuild scan.
210    fn as_entry(&self, oid: &[u8]) -> IndexEntry {
211        IndexEntry {
212            oid: oid.to_vec(),
213            offset: self.offset,
214            len: self.len,
215            obj_type: self.obj_type,
216            uncompressed_size: self.uncompressed_size,
217            delta_base: self.delta_base,
218        }
219    }
220}
221
222// ── the trigger ───────────────────────────────────────────────────────────────
223
224/// When the Arrow/stree projection is rebuilt from the redb tail. Either
225/// trigger fires; both are per repository and per generation.
226#[derive(Debug, Clone, Copy, PartialEq)]
227pub struct RebuildTriggers {
228    /// The miss trigger, as a multiple of the rows already in the projection.
229    /// See [`DEFAULT_TAIL_HITS_PER_ROW`](Self::DEFAULT_TAIL_HITS_PER_ROW) for why
230    /// it is relative and not a constant.
231    pub tail_hits_per_row: f64,
232    /// Floor and off-switch for the miss trigger: the threshold is never below
233    /// this, and **`0` disables the miss trigger entirely** whatever
234    /// `tail_hits_per_row` says.
235    pub min_tail_hits: u64,
236    /// Rebuild once this many stored object bytes have been appended since the
237    /// last rebuild. `0` disables the volume trigger.
238    pub tail_bytes: u64,
239}
240
241impl RebuildTriggers {
242    /// **1.0 — one tail-served lookup per row in the projection.**
243    ///
244    /// The miss trigger is relative because the two costs it balances scale
245    /// differently, and a single constant is therefore wrong at one end or the
246    /// other. Both costs MEASURED on oden 2026-08-07, 1-min loadavg 2.1–2.2,
247    /// `examples/read_stack_bench.rs`, sha1 oids, batch 1000, 3 runs per cell,
248    /// worst run-to-run spread 24.9%:
249    ///
250    /// | | 100 000 objects | 1 000 000 objects |
251    /// |---|---:|---:|
252    /// | lookup the projection answers | 80 ns | 221 ns |
253    /// | lookup the tail answers | 463 ns | 937 ns |
254    /// | **the fall-through costs** | **383 ns** | **716 ns** |
255    /// | rebuild (ordered tail scan + projection build) | 40.9 ms | 547.9 ms |
256    /// | **per row in the repository** | **409 ns** | **548 ns** |
257    /// | break-even, tail-served lookups per row | **1.07** | **0.77** |
258    ///
259    /// The rebuild has paid for itself once the fall-through has carried roughly
260    /// one lookup per row, at both sizes and an order of magnitude apart — which
261    /// is why the ratio is the right shape for this trigger and **1.0** is the
262    /// right value in it. A fixed 4096 would rebuild a million-object repository
263    /// (0.55 s) to save 4096 × 716 ns ≈ 2.9 ms — 190× the wrong way.
264    ///
265    /// Re-measure if the tail engine or the projection build changes: this is a
266    /// ratio of two measured costs and nothing else.
267    pub const DEFAULT_TAIL_HITS_PER_ROW: f64 = 1.0;
268
269    /// **4096 — the floor under the relative threshold.**
270    ///
271    /// On an empty or nearly-empty projection the ratio above is ~0 and would
272    /// rebuild on the first miss, over and over, during exactly the period when
273    /// objects are still arriving. 4096 misses is ~1.6 ms of fall-through at the
274    /// measured 100 000-object price — cheap enough to be worth waiting for on
275    /// any repository, and enough that a burst of small pushes coalesces into
276    /// one rebuild.
277    pub const DEFAULT_MIN_TAIL_HITS: u64 = 4096;
278
279    /// **64 MiB of appended object bytes.**
280    ///
281    /// A git push is a packfile, and the pack is the unit that lands in the tail.
282    /// 64 MiB is comfortably more than one ordinary push and less than a big
283    /// one, so a busy repository rebuilds on the order of once per large push
284    /// rather than once per push — while a repository taking a 2 GiB initial
285    /// import rebuilds ~32 times over that import instead of once at the end,
286    /// which is what keeps the fall-through from carrying the whole import.
287    /// It is the trigger that carries the normal case; the miss trigger is the
288    /// backstop for a repository that is read hard and written rarely.
289    pub const DEFAULT_TAIL_BYTES: u64 = 64 * 1024 * 1024;
290
291    /// The miss threshold for a projection of `projection_rows` rows, or `None`
292    /// when the miss trigger is off.
293    pub fn miss_threshold(&self, projection_rows: u64) -> Option<u64> {
294        if self.min_tail_hits == 0 {
295            return None;
296        }
297        let scaled = (self.tail_hits_per_row * projection_rows as f64) as u64;
298        Some(scaled.max(self.min_tail_hits))
299    }
300
301    /// Both triggers off: the projection is rebuilt only when asked.
302    pub fn manual() -> Self {
303        Self {
304            tail_hits_per_row: 0.0,
305            min_tail_hits: 0,
306            tail_bytes: 0,
307        }
308    }
309}
310
311impl Default for RebuildTriggers {
312    fn default() -> Self {
313        Self {
314            tail_hits_per_row: Self::DEFAULT_TAIL_HITS_PER_ROW,
315            min_tail_hits: Self::DEFAULT_MIN_TAIL_HITS,
316            tail_bytes: Self::DEFAULT_TAIL_BYTES,
317        }
318    }
319}
320
321/// Which threshold fired, with the value that fired it. Returned rather than
322/// logged, so a caller can record *why* a rebuild happened.
323#[derive(Debug, Clone, Copy, PartialEq, Eq)]
324pub enum RebuildReason {
325    /// `n` lookups had to be answered by the tail.
326    TailHits(u64),
327    /// `n` bytes of objects were appended since the last rebuild.
328    TailBytes(u64),
329    /// [`ObjectReadStack::rebuild`] was called directly.
330    Explicit,
331}
332
333/// A snapshot of the stack's counters. Everything here is applied output — rows
334/// that exist, lookups that happened — not configuration echoed back.
335#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
336pub struct StackStats {
337    /// Objects in the Arrow/stree projection.
338    pub sealed_rows: u64,
339    /// Objects in the repository. Always ≥ `sealed_rows`; the difference is the
340    /// un-absorbed tail.
341    pub total_rows: u64,
342    /// Lookups the stree missed and redb answered, since the last rebuild.
343    pub tail_hits: u64,
344    /// Lookups nothing answered — the object is not in this repository. Never
345    /// drives the trigger.
346    pub absent: u64,
347    /// Rows the projection does not cover yet. **Zero means the projection *is*
348    /// the truth**, which is what lets a miss be answered without a redb round
349    /// trip at all.
350    pub unabsorbed_rows: u64,
351    /// redb read transactions opened by the read path since the last rebuild.
352    /// The number that shows whether the completeness fast path is actually
353    /// being taken; there is no other way to see it from outside.
354    pub tail_txns: u64,
355    /// Object bytes appended since the last rebuild.
356    pub tail_bytes: u64,
357    /// Projections built over this stack's lifetime, including the one at open.
358    pub rebuilds: u64,
359    /// Bumped by every rebuild. An [`IndexRow::ordinal`] is only meaningful
360    /// within one generation.
361    pub generation: u64,
362}
363
364// ── the stack ─────────────────────────────────────────────────────────────────
365
366/// The read stack: Ragnar stree → Arrow columns → redb, in one object.
367///
368/// Parameterised by the Arrow layout so it drops into
369/// `examples/index_layout_bench.rs` against
370/// [`FourTables`](crate::index_layout::FourTables) and [`OneTableFourColumns`]
371/// unchanged — it implements the same [`ObjectIndex`] trait, and
372/// [`ObjectIndex::build`] produces a stack whose projection already covers every
373/// entry, so on that benchmark it is those arms plus one redb round trip per
374/// genuine miss.
375pub struct ObjectReadStack<S: ObjectIndex = OneTableFourColumns> {
376    /// The durable truth. Every object ever appended is here.
377    tail: Arc<Database>,
378    /// The projection. Replaced wholesale by a rebuild, never mutated in place —
379    /// which is what lets a reader hold it briefly without coordinating.
380    projection: RwLock<Arc<S>>,
381    triggers: RebuildTriggers,
382    total_rows: AtomicU64,
383    tail_hits: AtomicU64,
384    absent: AtomicU64,
385    tail_bytes: AtomicU64,
386    rebuilds: AtomicU64,
387    generation: AtomicU64,
388    /// Uncompressed bytes of the rows the projection has **not** absorbed.
389    ///
390    /// This is what makes [`ObjectIndex::sum_uncompressed`] exact without a scan.
391    /// The projection is, by definition, every row that existed at the last
392    /// rebuild; append-only means none of them can have changed since; so the
393    /// repository's total is the projection's column scan plus the rows appended
394    /// after it, and those are counted here as they arrive. Reset by a rebuild,
395    /// which is the moment they stop being un-absorbed.
396    unabsorbed_size: AtomicU64,
397    /// The same trick for [`ObjectIndex::count_type`], indexed by
398    /// [`ObjType::code`] (1–4, 6, 7; slots 0 and 5 stay zero).
399    unabsorbed_types: [AtomicU64; 8],
400    /// Rows the projection does not cover. **Zero is the interesting value**:
401    /// see [`ObjectReadStack::projection_is_complete`].
402    unabsorbed_rows: AtomicU64,
403    /// redb read transactions the read path has opened since the last rebuild.
404    tail_txns: AtomicU64,
405}
406
407impl<S: ObjectIndex> ObjectReadStack<S> {
408    /// Open (or create) one repository's stack at `tail_path`, warm-starting the
409    /// projection from everything already in the tail.
410    /// `cache_bytes` is redb's page-cache ceiling for **this** database. A
411    /// parameter and not a `getenv` down here, so the whole store costs one
412    /// environment read at construction and none below it; see
413    /// [`crate::arms::redb_cache_bytes`], and note that the stock
414    /// `Database::create` would take redb's 1 GiB default instead.
415    pub fn open(tail_path: &Path, triggers: RebuildTriggers, cache_bytes: usize) -> Result<Self> {
416        let db = Database::builder()
417            .set_cache_size(cache_bytes)
418            .create(tail_path)
419            .with_context(|| format!("opening object tail at {}", tail_path.display()))?;
420        Self::from_db(db, triggers)
421    }
422
423    /// **The name the built projection reports about itself** — the applied
424    /// output that says which [`crate::arms::IndexArm`] actually got built.
425    ///
426    /// Deliberately not [`ObjectIndex::name`] on the stack, which answers
427    /// `"ObjectReadStack"` for every arm because that is what the stack is. `S`
428    /// is the layout, and only `S` can say which one it is. It is the value
429    /// [`crate::arms::IndexArm::projection_name`] is written to be compared
430    /// against, so a caller that selected an arm can prove the selection took
431    /// rather than echo the selector back to itself.
432    pub fn projection_name(&self) -> &'static str {
433        self.projection.read().expect("projection lock").name()
434    }
435
436    /// A stack whose tail has no file behind it. Used by [`ObjectIndex::build`]
437    /// and by benchmarks; the redb code path is identical, only the backend
438    /// differs, so a test of this stack is a test of the durable one.
439    pub fn in_memory(triggers: RebuildTriggers) -> Result<Self> {
440        let db = Database::builder()
441            .create_with_backend(redb::backends::InMemoryBackend::new())
442            .context("creating an in-memory object tail")?;
443        Self::from_db(db, triggers)
444    }
445
446    fn from_db(db: Database, triggers: RebuildTriggers) -> Result<Self> {
447        // Materialise both tables so a read on a brand-new database does not
448        // trip `TableDoesNotExist`.
449        let w = db.begin_write()?;
450        {
451            let _ = w.open_table(OBJECTS)?;
452            let _ = w.open_table(META)?;
453        }
454        w.commit()?;
455
456        let db = Arc::new(db);
457        let entries = scan(&db)?;
458        let total = entries.len() as u64;
459        let projection = Arc::new(S::build(&entries)?);
460        Ok(Self {
461            tail: db,
462            projection: RwLock::new(projection),
463            triggers,
464            total_rows: AtomicU64::new(total),
465            tail_hits: AtomicU64::new(0),
466            absent: AtomicU64::new(0),
467            tail_bytes: AtomicU64::new(0),
468            rebuilds: AtomicU64::new(1),
469            generation: AtomicU64::new(1),
470            unabsorbed_size: AtomicU64::new(0),
471            unabsorbed_types: Default::default(),
472            unabsorbed_rows: AtomicU64::new(0),
473            tail_txns: AtomicU64::new(0),
474        })
475    }
476
477    /// **The projection covers every row in the repository**, so a miss in the
478    /// stree *is* an absence and the tail need not be asked.
479    ///
480    /// Sound by construction, not by hope: the projection is built from a scan
481    /// of the tail, so its rows are always a subset of the tail's; the archive
482    /// is append-only, so no row ever leaves; therefore equal counts mean equal
483    /// sets. A concurrent append between this check and the lookup only means
484    /// the lookup answers as of an instant before that append, which is what a
485    /// lookup issued a microsecond earlier would have done anyway.
486    ///
487    /// This is worth the paragraph because of what it buys. MEASURED on oden
488    /// 2026-08-07 (`read_stack_bench`, 100 000 objects, batch 1000): a `have`
489    /// negotiation — 10% hit, so 90% of the oids are in no repository at all —
490    /// cost **544 ns** per oid when every one of those absences took a redb
491    /// round trip, against **77 ns** for the bare Arrow arm. With this fast path
492    /// the same workload costs **68 ns**, i.e. the Arrow arm's own cost inside
493    /// the noise band. A `have` negotiation is *mostly* misses, so without this
494    /// the stack would be the dominant cost of the most common operation a git
495    /// server performs.
496    #[inline]
497    pub fn projection_is_complete(&self) -> bool {
498        self.unabsorbed_rows.load(Ordering::Acquire) == 0
499    }
500
501    /// Append objects to the tail.
502    ///
503    /// **Append-only is enforced here, not assumed.** An oid already present with
504    /// identical facts is a no-op (a re-pushed pack repeats objects, and that is
505    /// normal); an oid already present with *different* facts is an error and
506    /// nothing in the batch is written. Without that refusal a stree hit could
507    /// return a superseded row and the projection would be not merely incomplete
508    /// but wrong, which is the one thing this design must not allow.
509    ///
510    /// Runs a rebuild inline if the batch trips a threshold; the reason is
511    /// returned so the caller can see that it happened.
512    pub fn append(&self, entries: &[IndexEntry]) -> Result<Option<RebuildReason>> {
513        if entries.is_empty() {
514            return Ok(None);
515        }
516        let width = entries[0].oid.len();
517        if width != 20 && width != 32 {
518            bail!("oid width {width} is neither sha1 (20) nor sha256 (32)");
519        }
520
521        let w = self.tail.begin_write()?;
522        let mut added = 0u64;
523        let mut bytes = 0u64;
524        // Accumulated locally and applied only after the commit: a `bail!`
525        // half-way through the batch rolls redb back, and counters bumped inside
526        // the loop would survive a rollback and put the column-scan aggregates
527        // permanently out of step with the tail.
528        let mut added_size = 0u64;
529        let mut added_types = [0u64; 8];
530        {
531            let mut objects = w.open_table(OBJECTS)?;
532            let mut meta = w.open_table(META)?;
533            let mut seq = meta.get(META_ARRIVAL_SEQ)?.map(|v| v.value()).unwrap_or(0);
534            for e in entries {
535                if e.oid.len() != width {
536                    bail!(
537                        "mixed oid widths in one append: {width} and {}",
538                        e.oid.len()
539                    );
540                }
541                if let Some(existing) = objects.get(e.oid.as_slice())? {
542                    let old = decode_row(existing.value())?;
543                    // ── IDENTITY, not PLACEMENT ─────────────────────────
544                    //
545                    // `offset`, `len` and `delta_base` say WHERE this copy of
546                    // the object landed. They are not what it IS. A re-pushed
547                    // pack legitimately places the same oid somewhere else —
548                    // that is the ordinary case, not a violation — so comparing
549                    // them refused a normal push.
550                    //
551                    // MEASURED 2026-08-14: pushing an object the repository
552                    // already held aborted the ingest and dropped the
553                    // connection with `client_told=false`, so the client saw
554                    // "send-pack: unexpected disconnect" and no reason at all.
555                    // It bit `bare_lifecycle` and `endurance` on all four
556                    // znippy columns of the bench, and it bites any real
557                    // `--force` push that resends a known object.
558                    //
559                    // The guard's own doc three lines up already said what it
560                    // meant to do — "An oid already present with identical
561                    // facts is a no-op (a re-pushed pack repeats objects, and
562                    // that is normal)" — so the code contradicted its contract.
563                    //
564                    // What the original comparison was protecting is still
565                    // protected, and by a stronger argument: we KEEP THE OLD
566                    // ROW. The archive is append-only and `gc` truncates
567                    // nothing, so the offset and delta base already recorded
568                    // still address bytes that are still there. The projection
569                    // therefore never carries a base the archive did not write
570                    // — the first writer's placement stands, forever.
571                    //
572                    // What CANNOT be waved through is a disagreement about the
573                    // object itself. Same oid, different type or different
574                    // inflated size, means either a hash collision or a
575                    // corrupted row, and no push may quietly overwrite that.
576                    // …and `obj_type` is a REPRESENTATION, not the object's
577                    // kind. `ofs-delta` and `ref-delta` say how these bytes are
578                    // ENCODED; what the object IS lives at the end of the delta
579                    // chain. The same oid legitimately arrives whole in one
580                    // pack and delta-encoded in another, so comparing those two
581                    // is comparing encodings and calling them different
582                    // objects. MEASURED here, `gunnar.import_export`, znippy:
583                    //
584                    //   6da81e5e… is already stored as (…, ofs-delta, size 2084)
585                    //   and cannot be redeclared as (…, tree, size 2084)
586                    //
587                    // — the inflated size agrees to the byte, which is the
588                    // identity; only the encoding moved. The refusal poisoned
589                    // the repository (`it stays un-indexed; reads will keep
590                    // falling back`) and every later read answered `the
591                    // repository is unavailable; try again`.
592                    //
593                    // So a type disagreement is a violation only when BOTH
594                    // rows name a real object kind. The inflated size is
595                    // compared unconditionally, and it is the half that
596                    // actually catches a collision or a corrupt row.
597                    let encoded = |t: ObjType| matches!(t, ObjType::OfsDelta | ObjType::RefDelta);
598                    let kind_disagrees = !encoded(old.obj_type)
599                        && !encoded(e.obj_type)
600                        && old.obj_type != e.obj_type;
601                    if kind_disagrees || old.uncompressed_size != e.uncompressed_size {
602                        bail!(
603                            "identity violation: {} is already stored as \
604                             (offset {}, len {}, {}, size {}, delta_base {}) and cannot be \
605                             redeclared as (offset {}, len {}, {}, size {}, delta_base {}) — \
606                             the TYPE or the inflated SIZE differs, so the same oid is \
607                             describing a different object. Placement (offset, len, \
608                             delta_base) may differ freely: a re-pushed pack lands its \
609                             copies elsewhere and the first writer's row stands.",
610                            hex::encode(&e.oid),
611                            old.offset,
612                            old.len,
613                            old.obj_type.as_str(),
614                            old.uncompressed_size,
615                            old.delta_base,
616                            e.offset,
617                            e.len,
618                            e.obj_type.as_str(),
619                            e.uncompressed_size,
620                            e.delta_base,
621                        );
622                    }
623                    continue;
624                }
625                objects.insert(e.oid.as_slice(), encode_row(seq, e).as_slice())?;
626                seq += 1;
627                added += 1;
628                bytes += e.len;
629                added_size += e.uncompressed_size;
630                added_types[e.obj_type.code() as usize] += 1;
631            }
632            meta.insert(META_ARRIVAL_SEQ, seq)?;
633        }
634        w.commit()?;
635
636        self.total_rows.fetch_add(added, Ordering::AcqRel);
637        self.unabsorbed_rows.fetch_add(added, Ordering::AcqRel);
638        self.unabsorbed_size.fetch_add(added_size, Ordering::AcqRel);
639        for (slot, n) in self.unabsorbed_types.iter().zip(added_types) {
640            slot.fetch_add(n, Ordering::AcqRel);
641        }
642        let total_bytes = self.tail_bytes.fetch_add(bytes, Ordering::AcqRel) + bytes;
643        if self.triggers.tail_bytes != 0 && total_bytes >= self.triggers.tail_bytes {
644            self.rebuild()?;
645            return Ok(Some(RebuildReason::TailBytes(total_bytes)));
646        }
647        self.maybe_rebuild()
648    }
649
650    /// Which threshold, if any, is currently tripped. Pure read; a caller on a
651    /// read-only repository can poll this from its own maintenance tick.
652    pub fn rebuild_due(&self) -> Option<RebuildReason> {
653        let hits = self.tail_hits.load(Ordering::Acquire);
654        if let Some(threshold) = self.triggers.miss_threshold(self.projection_len() as u64)
655            && hits >= threshold
656        {
657            return Some(RebuildReason::TailHits(hits));
658        }
659        let bytes = self.tail_bytes.load(Ordering::Acquire);
660        if self.triggers.tail_bytes != 0 && bytes >= self.triggers.tail_bytes {
661            return Some(RebuildReason::TailBytes(bytes));
662        }
663        None
664    }
665
666    /// Rebuild if [`rebuild_due`](Self::rebuild_due) says so.
667    pub fn maybe_rebuild(&self) -> Result<Option<RebuildReason>> {
668        match self.rebuild_due() {
669            Some(reason) => {
670                self.rebuild()?;
671                Ok(Some(reason))
672            }
673            None => Ok(None),
674        }
675    }
676
677    /// Rebuild the Arrow/stree projection from a full ordered scan of the tail
678    /// and swap it in, resetting the counters and bumping the generation.
679    ///
680    /// The old projection stays live for every reader until the swap; the write
681    /// lock is held only for the pointer store, not for the build.
682    pub fn rebuild(&self) -> Result<()> {
683        let entries = scan(&self.tail)?;
684        let total = entries.len() as u64;
685        let fresh = Arc::new(S::build(&entries)?);
686        *self
687            .projection
688            .write()
689            .map_err(|_| anyhow!("the projection lock is poisoned"))? = fresh;
690        self.total_rows.store(total, Ordering::Release);
691        self.tail_hits.store(0, Ordering::Release);
692        self.tail_bytes.store(0, Ordering::Release);
693        self.unabsorbed_size.store(0, Ordering::Release);
694        self.unabsorbed_rows.store(0, Ordering::Release);
695        self.tail_txns.store(0, Ordering::Release);
696        for slot in &self.unabsorbed_types {
697            slot.store(0, Ordering::Release);
698        }
699        self.rebuilds.fetch_add(1, Ordering::AcqRel);
700        self.generation.fetch_add(1, Ordering::AcqRel);
701        Ok(())
702    }
703
704    pub fn stats(&self) -> StackStats {
705        StackStats {
706            sealed_rows: self.projection_len() as u64,
707            total_rows: self.total_rows.load(Ordering::Acquire),
708            tail_hits: self.tail_hits.load(Ordering::Acquire),
709            absent: self.absent.load(Ordering::Acquire),
710            tail_bytes: self.tail_bytes.load(Ordering::Acquire),
711            rebuilds: self.rebuilds.load(Ordering::Acquire),
712            generation: self.generation.load(Ordering::Acquire),
713            unabsorbed_rows: self.unabsorbed_rows.load(Ordering::Acquire),
714            tail_txns: self.tail_txns.load(Ordering::Acquire),
715        }
716    }
717
718    /// Rows in the projection right now. `stats().total_rows - this` is the
719    /// un-absorbed tail.
720    pub fn projection_len(&self) -> usize {
721        self.projection.read().expect("projection lock").len()
722    }
723
724    /// Every oid in the repository, in oid order — which is ordinal order, since
725    /// redb keys the tail by the raw oid.
726    ///
727    /// A full scan, and it is here for the one caller that genuinely needs the
728    /// whole set at once: a GC, which has to name what is *not* live.
729    pub fn oids_in_order(&self) -> Result<Vec<Vec<u8>>> {
730        let read = self.tail.begin_read()?;
731        let objects = read.open_table(OBJECTS)?;
732        let mut out = Vec::with_capacity(objects.len()? as usize);
733        for row in objects.iter()? {
734            let (k, _) = row?;
735            out.push(k.value().to_vec());
736        }
737        Ok(out)
738    }
739
740    /// **Which of these archive extents already have rows here** — the
741    /// crash-recovery diff, and the reason §13.12's `indexed` bit is derived
742    /// rather than stored.
743    ///
744    /// A pack is unabsorbed **iff its extent is in the journal and its rows are
745    /// not in the index**. Both of those are already durable — the journal is
746    /// fsynced on the ack path, the tail is a redb commit — so the bit is a diff
747    /// of two durable facts and there is nothing for it to drift from. A *stored*
748    /// bit would be a third fact that can disagree with the two it describes,
749    /// which is precisely how a pack ends up marked absorbed with no rows behind
750    /// it: fast and wrong, in the one direction (`absent`) that loses a client's
751    /// objects during negotiation.
752    ///
753    /// `out[i]` answers `extents[i]`. **The tail and not the projection**, because
754    /// the projection is a snapshot of a prefix of the tail and the question is
755    /// about what survived the crash.
756    ///
757    /// # Cost
758    ///
759    /// One ordered scan of the tail that **stops the moment every extent has been
760    /// hit**. Rows arrive in oid order, which is uncorrelated with the pack an
761    /// object came from, so a store whose packs are all absorbed answers after a
762    /// few rows per pack rather than after a full scan — the coupon-collector
763    /// case, and the one every clean reopen takes. The scan only runs to the end
764    /// when some pack genuinely has **no** rows, which is exactly the case where
765    /// the caller is about to re-resolve that whole pack anyway.
766    ///
767    /// MEASURED on oden 2026-08-08, release, file-backed redb tail, 1-minute
768    /// loadavg 7.1–7.5 (another tenant's work — read these as ratios):
769    ///
770    /// | packs | rows | every pack absorbed | one pack un-absorbed |
771    /// |---:|---:|---:|---:|
772    /// | 1 000 | 1 000 000 | **0.77 ms** | 120.6 ms |
773    /// | 10 000 | 1 000 000 | **11.7 ms** | 141.8 ms |
774    /// | 1 000 | 3 686 | **0.40 ms** | 0.39 ms |
775    ///
776    /// The first column is what a clean reopen pays and it is bounded by the
777    /// **pack** count, not the object count — 1000 packs over a million objects
778    /// costs the same order as 1000 packs over four thousand. The second column
779    /// is the crash path, where the scan runs to the end: 120 ms over a million
780    /// rows, against the ~160 ms a *single* 2687-object pack takes to re-absorb
781    /// (`a_push_ends_in_object_rows_and_no_read_asked_for_them`). The diff is
782    /// therefore never the expensive half of a recovery.
783    /// The third row is the shape
784    /// [`crate::git_ops::tests::the_derive_on_open_diff_costs_milliseconds_at_a_realistic_pack_count`]
785    /// asserts on every run, over real pushed packs rather than synthetic rows.
786    ///
787    /// A zero-length extent has no rows by construction and is answered `false`
788    /// without looking.
789    pub fn extents_with_rows(&self, extents: &[(u64, u64)]) -> Result<Vec<bool>> {
790        let mut hit = vec![false; extents.len()];
791        // Sorted by start, so one row is placed with a binary search instead of a
792        // pass over every extent: the scan below is the hot loop.
793        let mut order: Vec<usize> = (0..extents.len()).filter(|&i| extents[i].1 > 0).collect();
794        order.sort_unstable_by_key(|&i| extents[i].0);
795        let starts: Vec<u64> = order.iter().map(|&i| extents[i].0).collect();
796        let mut wanted = order.len();
797        if wanted == 0 {
798            return Ok(hit);
799        }
800
801        let read = self.tail.begin_read()?;
802        let objects = read.open_table(OBJECTS)?;
803        for row in objects.iter()? {
804            let (_, v) = row?;
805            let offset = decode_row(v.value())?.offset;
806            // The last extent that starts at or before this row.
807            let p = starts.partition_point(|&s| s <= offset);
808            if p == 0 {
809                continue;
810            }
811            let i = order[p - 1];
812            let (start, len) = extents[i];
813            if offset < start + len && !hit[i] {
814                hit[i] = true;
815                wanted -= 1;
816                if wanted == 0 {
817                    break;
818                }
819            }
820        }
821        Ok(hit)
822    }
823
824    /// **Drop every row `live` rejects.** Returns how many rows went.
825    ///
826    /// This is the one operation that is not append-only, and it exists for
827    /// exactly one caller: `GitOps::gc`, which computes reachability and then has
828    /// to remove what is unreachable *before* base znippy compacts the payload —
829    /// so that base's notion of "live" already means what git means (§13.20).
830    ///
831    /// The projection is rebuilt inside the same call. It has to be: it is a
832    /// snapshot of a tail that no longer says the same thing, and a stree that
833    /// still answers for a deleted oid would be **wrong** rather than merely
834    /// incomplete, which is the one failure this design does not tolerate.
835    pub fn retain(&self, live: &dyn Fn(&[u8]) -> bool) -> Result<u64> {
836        let w = self.tail.begin_write()?;
837        let mut dropped = 0u64;
838        {
839            let mut objects = w.open_table(OBJECTS)?;
840            let dead: Vec<Vec<u8>> = objects
841                .iter()?
842                .filter_map(|row| row.ok())
843                .filter(|(k, _)| !live(k.value()))
844                .map(|(k, _)| k.value().to_vec())
845                .collect();
846            for oid in &dead {
847                objects.remove(oid.as_slice())?;
848                dropped += 1;
849            }
850        }
851        w.commit()?;
852        self.rebuild()?;
853        Ok(dropped)
854    }
855
856    /// One tail read transaction serving `oids`, filling only the slots that are
857    /// still `None`. This is the fall-through, and it is a single transaction for
858    /// the whole batch on purpose — skade's `resolve_many` phase 2.
859    ///
860    /// **Not called at all when the projection is complete** — see
861    /// [`projection_is_complete`](Self::projection_is_complete). The unfilled
862    /// slots are then genuine absences and are counted as such by
863    /// [`absences_only`](Self::absences_only).
864    fn fill_from_tail(&self, oids: &[&[u8]], out: &mut [Option<IndexRow>]) -> Result<()> {
865        self.tail_txns.fetch_add(1, Ordering::AcqRel);
866        let read = self.tail.begin_read()?;
867        let objects = read.open_table(OBJECTS)?;
868        let mut hits = 0u64;
869        let mut absent = 0u64;
870        for (slot, oid) in out.iter_mut().zip(oids) {
871            if slot.is_some() {
872                continue;
873            }
874            match objects.get(*oid)? {
875                Some(v) => {
876                    *slot = Some(decode_row(v.value())?.as_index_row());
877                    hits += 1;
878                }
879                None => absent += 1,
880            }
881        }
882        self.tail_hits.fetch_add(hits, Ordering::AcqRel);
883        self.absent.fetch_add(absent, Ordering::AcqRel);
884        Ok(())
885    }
886
887    /// Count the unfilled slots as absences without asking the tail. Only ever
888    /// called when [`projection_is_complete`](Self::projection_is_complete),
889    /// where "the projection does not have it" and "the repository does not have
890    /// it" are the same statement.
891    fn absences_only(&self, out: &[Option<IndexRow>]) {
892        let n = out.iter().filter(|s| s.is_none()).count() as u64;
893        self.absent.fetch_add(n, Ordering::AcqRel);
894    }
895}
896
897/// Every row in the tail, in oid order, as index entries. redb's B-tree is
898/// already ordered by the raw oid key, so this hands `S::build` its rows in the
899/// order it wants them and the sort inside is a no-op scan.
900fn scan(db: &Database) -> Result<Vec<IndexEntry>> {
901    let read = db.begin_read()?;
902    let objects = read.open_table(OBJECTS)?;
903    let mut out = Vec::with_capacity(objects.len()? as usize);
904    for row in objects.iter()? {
905        let (k, v) = row?;
906        out.push(decode_row(v.value())?.as_entry(k.value()));
907    }
908    Ok(out)
909}
910
911impl<S: ObjectIndex> ObjectIndex for ObjectReadStack<S> {
912    /// Build a stack over `entries` with an in-memory tail. Every entry lands in
913    /// redb and the projection is built from it, so the stack starts fully
914    /// absorbed — which is what makes it directly comparable with the two Arrow
915    /// arms on the layout benchmark.
916    fn build(entries: &[IndexEntry]) -> Result<Self> {
917        let stack = Self::in_memory(RebuildTriggers::default())?;
918        stack.append(entries)?;
919        stack.rebuild()?;
920        Ok(stack)
921    }
922
923    /// Projection first; **a miss falls through to redb**, which always knows.
924    /// `None` here means the tail said no, never that the stree said no.
925    fn lookup(&self, oid: &[u8]) -> Option<IndexRow> {
926        if let Some(row) = self.projection.read().expect("projection lock").lookup(oid) {
927            return Some(row);
928        }
929        if self.projection_is_complete() {
930            self.absent.fetch_add(1, Ordering::AcqRel);
931            return None;
932        }
933        let mut out = [None];
934        // A tail read that errors is reported as absent to this signature, which
935        // cannot carry an error. Callers that need the distinction use
936        // `stats().absent` against their own miss count, or `fill_from_tail`
937        // through `lookup_batch`'s caller. A corrupt tail is a `rebuild()`
938        // failure long before it is a wrong lookup.
939        let _ = self.fill_from_tail(&[oid], &mut out);
940        out[0]
941    }
942
943    /// Two phases, and the second is not optional: one pipelined stree pass, then
944    /// **one** redb transaction for every slot the projection left empty.
945    fn lookup_batch(&self, oids: &[&[u8]]) -> Vec<Option<IndexRow>> {
946        let mut out = self
947            .projection
948            .read()
949            .expect("projection lock")
950            .lookup_batch(oids);
951        if out.iter().any(Option::is_none) {
952            if self.projection_is_complete() {
953                self.absences_only(&out);
954            } else {
955                let _ = self.fill_from_tail(oids, &mut out);
956            }
957        }
958        out
959    }
960
961    /// The floor path — oid → ordinal, no payload column touched. The tail is
962    /// still consulted for the misses, because a miss is still not an answer;
963    /// a tail-served ordinal carries [`TAIL_ORDINAL_BIT`].
964    fn ordinals_batch(&self, oids: &[&[u8]]) -> Vec<Option<u32>> {
965        let mut out = self
966            .projection
967            .read()
968            .expect("projection lock")
969            .ordinals_batch(oids);
970        if out.iter().any(Option::is_none) {
971            if self.projection_is_complete() {
972                let n = out.iter().filter(|s| s.is_none()).count() as u64;
973                self.absent.fetch_add(n, Ordering::AcqRel);
974                return out;
975            }
976            let mut rows: Vec<Option<IndexRow>> = out
977                .iter()
978                .map(|o| {
979                    o.map(|ordinal| IndexRow {
980                        ordinal,
981                        offset: 0,
982                        len: 0,
983                        obj_type: ObjType::Blob,
984                        uncompressed_size: 0,
985                        delta_base: 0,
986                    })
987                })
988                .collect();
989            let _ = self.fill_from_tail(oids, &mut rows);
990            for (slot, row) in out.iter_mut().zip(&rows) {
991                *slot = row.map(|r| r.ordinal);
992            }
993        }
994        out
995    }
996
997    /// The partial-row path — byte extent only.
998    fn extents_batch(&self, oids: &[&[u8]]) -> Vec<Option<(u64, u64)>> {
999        let mut out = self
1000            .projection
1001            .read()
1002            .expect("projection lock")
1003            .extents_batch(oids);
1004        if out.iter().any(Option::is_none) {
1005            if self.projection_is_complete() {
1006                let n = out.iter().filter(|s| s.is_none()).count() as u64;
1007                self.absent.fetch_add(n, Ordering::AcqRel);
1008                return out;
1009            }
1010            let mut rows: Vec<Option<IndexRow>> = out
1011                .iter()
1012                .map(|e| {
1013                    e.map(|(offset, len)| IndexRow {
1014                        ordinal: 0,
1015                        offset,
1016                        len,
1017                        obj_type: ObjType::Blob,
1018                        uncompressed_size: 0,
1019                        delta_base: 0,
1020                    })
1021                })
1022                .collect();
1023            let _ = self.fill_from_tail(oids, &mut rows);
1024            for (slot, row) in out.iter_mut().zip(&rows) {
1025                *slot = row.map(|r| (r.offset, r.len));
1026            }
1027        }
1028        out
1029    }
1030
1031    /// The quota gate, over the whole **repository** and without a scan of the
1032    /// tail: the projection's column scan plus the rows appended after it.
1033    /// Exact because the archive is append-only — a row the projection already
1034    /// holds can never gain or lose bytes, so the two terms cannot overlap.
1035    fn sum_uncompressed(&self) -> u64 {
1036        self.projection
1037            .read()
1038            .expect("projection lock")
1039            .sum_uncompressed()
1040            + self.unabsorbed_size.load(Ordering::Acquire)
1041    }
1042
1043    /// [`sum_uncompressed`](Self::sum_uncompressed)'s argument, per type.
1044    fn count_type(&self, t: ObjType) -> usize {
1045        self.projection
1046            .read()
1047            .expect("projection lock")
1048            .count_type(t)
1049            + self.unabsorbed_types[t.code() as usize].load(Ordering::Acquire) as usize
1050    }
1051
1052    fn name(&self) -> &'static str {
1053        "ObjectReadStack"
1054    }
1055
1056    /// Objects in the **repository**, not in the projection — the stack answers
1057    /// for the whole repository and this has to agree with what `lookup` will
1058    /// resolve. [`projection_len`](Self::projection_len) is the other number.
1059    fn len(&self) -> usize {
1060        self.total_rows.load(Ordering::Acquire) as usize
1061    }
1062
1063    fn ipc_bytes(&self) -> usize {
1064        self.projection.read().expect("projection lock").ipc_bytes()
1065    }
1066
1067    /// The projection's resident bytes. The tail is on disk (or in redb's own
1068    /// page cache) and is not counted here — counting it would make this
1069    /// incomparable with the two Arrow arms, which is the number's only use.
1070    fn resident_bytes(&self) -> usize {
1071        self.projection
1072            .read()
1073            .expect("projection lock")
1074            .resident_bytes()
1075    }
1076}
1077
1078#[cfg(test)]
1079mod tests {
1080    use super::*;
1081    use crate::arms::DEFAULT_REDB_CACHE_BYTES;
1082    use crate::index_layout::{FourTables, synthetic_entries};
1083
1084    type Stack = ObjectReadStack<OneTableFourColumns>;
1085
1086    /// A stack with the thresholds off, so a test decides when a rebuild happens.
1087    fn manual() -> Stack {
1088        Stack::in_memory(RebuildTriggers::manual()).expect("in-memory stack")
1089    }
1090
1091    /// Seal `sealed` into the projection, then append `tail` without absorbing
1092    /// it. The returned stack is deliberately in the state the whole design is
1093    /// about: a projection that is a strict subset of the truth.
1094    fn split_stack(sealed: &[IndexEntry], tail: &[IndexEntry]) -> Stack {
1095        let s = manual();
1096        s.append(sealed).expect("seal half");
1097        s.rebuild().expect("rebuild");
1098        s.append(tail).expect("append tail");
1099        s
1100    }
1101
1102    /// **The property the whole stack rests on: incomplete, never wrong.**
1103    ///
1104    /// The projection covers 300 of 500 objects. Asserted on applied output —
1105    /// every one of the five facts of all 500 rows, through both the serial and
1106    /// the batch path — plus the two things that would make the guard hollow:
1107    /// that the projection really is short (its own `lookup` misses all 200 tail
1108    /// objects, so the fall-through is genuinely being exercised) and that every
1109    /// projection hit is byte-identical to what a complete index built over all
1110    /// 500 returns.
1111    ///
1112    /// Seen RED by making `lookup_batch`'s phase 2 conditional on
1113    /// `out.iter().all(Option::is_none)` instead of `any` — i.e. falling through
1114    /// only when the projection answered nothing: "the stack lost tail object
1115    /// e50298d4cb83164fcbbd2a0d4fd99ebb43111e93, batch path". Restored.
1116    ///
1117    /// Seen RED a second time by returning `None` from `lookup` on a projection
1118    /// miss with no fall-through: "the stack lost tail object
1119    /// e50298d4cb83164fcbbd2a0d4fd99ebb43111e93, serial path". Restored.
1120    ///
1121    /// Seen RED a third time, for `delta_base`, by having [`encode_row`] write
1122    /// a literal `0` into bytes 33..41 — the shape of a column that exists in
1123    /// the struct and is never persisted: "the delta base of
1124    /// b2af964cd8a34795ce5ad114d4e2a2144cf2a6b1 did not survive the projection
1125    /// path / left: 0 / right: 6186". **Six** of this module's tests went red
1126    /// on that one edit, on both sides of the fall-through, which is what says
1127    /// the tail encoding is load-bearing rather than incidental. Restored.
1128    #[test]
1129    fn the_projection_is_incomplete_but_never_wrong() {
1130        let all = synthetic_entries(500, 20, 0xC0FFEE);
1131        let (sealed, tail) = all.split_at(300);
1132        let stack = split_stack(sealed, tail);
1133
1134        assert_eq!(stack.projection_len(), 300, "the projection must be short");
1135        assert_eq!(stack.len(), 500, "the stack answers for the repository");
1136        // The tail half has to carry delta bases, or the `delta_base` assertion
1137        // below is an assertion about zero on both sides of the fall-through.
1138        assert!(
1139            tail.iter().filter(|e| e.delta_base != 0).count() >= 10,
1140            "the un-absorbed half carries no delta bases — the tail encoding would be untested"
1141        );
1142
1143        // Two references. `sealed_only` is what the projection is *supposed* to
1144        // be — an index over exactly the 300 sealed objects, so its ordinals are
1145        // the ranks the projection must reproduce. `complete` is the index the
1146        // stack must be indistinguishable from on the five facts.
1147        let sealed_only = FourTables::build(sealed).expect("sealed-only index");
1148        let complete = FourTables::build(&all).expect("complete index");
1149
1150        // The projection alone must MISS every tail object — otherwise the
1151        // fall-through below proves nothing.
1152        {
1153            let proj = stack.projection.read().unwrap();
1154            for e in tail {
1155                assert!(
1156                    proj.lookup(&e.oid).is_none(),
1157                    "the projection already holds {} — this test would be vacuous",
1158                    hex::encode(&e.oid)
1159                );
1160            }
1161            for e in sealed {
1162                assert!(
1163                    proj.lookup(&e.oid).is_some(),
1164                    "the projection lost sealed object {}",
1165                    hex::encode(&e.oid)
1166                );
1167            }
1168        }
1169
1170        let refs: Vec<&[u8]> = all.iter().map(|e| e.oid.as_slice()).collect();
1171        let batched = stack.lookup_batch(&refs);
1172        for (i, e) in all.iter().enumerate() {
1173            let via_serial = stack.lookup(&e.oid).unwrap_or_else(|| {
1174                panic!(
1175                    "the stack lost {} object {}, serial path",
1176                    if i < 300 { "sealed" } else { "tail" },
1177                    hex::encode(&e.oid)
1178                )
1179            });
1180            let via_batch = batched[i].unwrap_or_else(|| {
1181                panic!(
1182                    "the stack lost {} object {}, batch path",
1183                    if i < 300 { "sealed" } else { "tail" },
1184                    hex::encode(&e.oid)
1185                )
1186            });
1187            assert_eq!(via_serial, via_batch, "serial and batch disagree");
1188
1189            // The five facts, against the entry that was appended.
1190            assert_eq!(via_serial.offset, e.offset);
1191            assert_eq!(via_serial.len, e.len);
1192            assert_eq!(via_serial.obj_type, e.obj_type);
1193            assert_eq!(via_serial.uncompressed_size, e.uncompressed_size);
1194            assert_eq!(
1195                via_serial.delta_base,
1196                e.delta_base,
1197                "the delta base of {} did not survive the {} path",
1198                hex::encode(&e.oid),
1199                if i < 300 { "projection" } else { "tail" }
1200            );
1201
1202            // A HIT in the projection is valid: the five facts are the complete
1203            // index's, and the ordinal is the rank within the generation the
1204            // projection covers — which is what an ordinal means.
1205            let truth = complete.lookup(&e.oid).expect("complete index has it");
1206            assert_eq!(via_serial.offset, truth.offset);
1207            assert_eq!(via_serial.len, truth.len);
1208            assert_eq!(via_serial.obj_type, truth.obj_type);
1209            assert_eq!(via_serial.uncompressed_size, truth.uncompressed_size);
1210            assert_eq!(via_serial.delta_base, truth.delta_base);
1211            if i < 300 {
1212                assert!(
1213                    !is_tail_row(&via_serial),
1214                    "sealed row wearing a tail ordinal"
1215                );
1216                assert_eq!(
1217                    via_serial.ordinal,
1218                    sealed_only.lookup(&e.oid).unwrap().ordinal,
1219                    "the projection's ordinal for {} is not its rank in the generation the \
1220                     projection covers",
1221                    hex::encode(&e.oid)
1222                );
1223            } else {
1224                assert!(
1225                    is_tail_row(&via_serial),
1226                    "tail row {} has no tail ordinal",
1227                    hex::encode(&e.oid)
1228                );
1229            }
1230        }
1231    }
1232
1233    /// A miss in the tree is not an answer, and an absence is.
1234    ///
1235    /// Seen RED by having `fill_from_tail` skip its `objects.get` and count every
1236    /// unfilled slot as absent: the tail-served block came back empty —
1237    /// "assertion `left == right` failed / left: 0 / right: 80". Restored.
1238    #[test]
1239    fn only_a_tail_miss_is_absent_and_only_a_tail_hit_counts() {
1240        let all = synthetic_entries(200, 20, 7);
1241        let (sealed, tail) = all.split_at(120);
1242        let stack = split_stack(sealed, tail);
1243        let nowhere = synthetic_entries(64, 20, 0x00AB_5E47);
1244
1245        let mut refs: Vec<&[u8]> = Vec::new();
1246        refs.extend(sealed.iter().map(|e| e.oid.as_slice()));
1247        refs.extend(tail.iter().map(|e| e.oid.as_slice()));
1248        refs.extend(nowhere.iter().map(|e| e.oid.as_slice()));
1249        let rows = stack.lookup_batch(&refs);
1250
1251        assert_eq!(rows[..120].iter().filter(|r| r.is_some()).count(), 120);
1252        assert_eq!(rows[120..200].iter().filter(|r| r.is_some()).count(), 80);
1253        assert!(
1254            rows[200..].iter().all(Option::is_none),
1255            "an object in no repository resolved to a row"
1256        );
1257
1258        let st = stack.stats();
1259        assert_eq!(st.tail_hits, 80, "tail-served lookups miscounted");
1260        assert_eq!(st.absent, 64, "genuine absences miscounted");
1261    }
1262
1263    /// **Append-only, enforced.** The refusal is what makes a stree hit
1264    /// trustworthy; if a fact could be rewritten, a stale projection would return
1265    /// the old one.
1266    ///
1267    /// **Identity is refused; placement is not.** REWRITTEN 2026-08-14 — the
1268    /// old form compared `offset`, `len` and `delta_base` too, and those say
1269    /// where a copy landed rather than what the object is. A re-pushed pack
1270    /// places the same oid at a new offset, which is the ordinary case, so the
1271    /// guard refused normal pushes: the ingest aborted and the connection
1272    /// dropped with `client_told=false`, leaving the client with
1273    /// "send-pack: unexpected disconnect" and no reason. It failed
1274    /// `bare_lifecycle` and `endurance` on all four znippy bench columns and
1275    /// bit any `--force` push that resent a known object.
1276    ///
1277    /// The old comparison's stated fear — a projection carrying "a base offset
1278    /// the archive never wrote" — is answered better by keeping the OLD row:
1279    /// the archive is append-only and `gc` truncates nothing, so the first
1280    /// writer's offset and base still address bytes that are still there.
1281    ///
1282    /// Seen RED by comparing `offset` again: "a re-pushed pack at a new offset
1283    /// was refused" — which is precisely the production failure, reproduced.
1284    ///
1285    /// Seen RED the other way by dropping `old.obj_type != e.obj_type`: "an oid
1286    /// that changed type was accepted" — the same oid describing a different
1287    /// object, waved through.
1288    #[test]
1289    fn an_append_only_violation_is_refused() {
1290        let entries = synthetic_entries(16, 20, 11);
1291        let stack = manual();
1292        stack.append(&entries).expect("first append");
1293
1294        // Re-appending the identical batch is a no-op, not an error: a re-pushed
1295        // pack repeats objects.
1296        stack
1297            .append(&entries)
1298            .expect("identical re-append is idempotent");
1299        assert_eq!(
1300            stack.len(),
1301            16,
1302            "an idempotent re-append changed the row count"
1303        );
1304
1305        // ★ THE PRODUCTION CASE: the same oid, re-pushed, lands elsewhere.
1306        // This must be a NO-OP, and the first writer's placement must stand.
1307        let mut moved = entries[3].clone();
1308        moved.offset += 1;
1309        stack
1310            .append(std::slice::from_ref(&moved))
1311            .expect("a re-pushed pack at a new offset was refused");
1312        let row = stack.lookup(&entries[3].oid).expect("still there");
1313        assert_eq!(
1314            row.offset, entries[3].offset,
1315            "the second placement overwrote the first — the projection now names \
1316             an offset that is not where the first writer put the bytes"
1317        );
1318        assert_eq!(stack.len(), 16, "a re-placement added a row");
1319
1320        // ★ IDENTITY still refuses: same oid, different object.
1321        let mut retyped = entries[4].clone();
1322        retyped.uncompressed_size += 1;
1323        let err = stack
1324            .append(std::slice::from_ref(&retyped))
1325            .expect_err("an oid that changed size was accepted");
1326        assert!(
1327            err.to_string().contains("identity violation"),
1328            "wrong error: {err}"
1329        );
1330
1331        // ★ A DIFFERENT DELTA BASE IS PLACEMENT TOO, and the first row stands.
1332        //
1333        // The old form refused this, on the reasoning that a row naming a base
1334        // the archive never wrote would be left behind. Keeping the OLD row
1335        // answers that completely: the base the FIRST writer recorded is still
1336        // in the archive, because the blob is append-only and `gc` truncates
1337        // nothing. The second pack's base is simply not adopted.
1338        //
1339        // Refusing it instead is what broke real pushes — a re-pushed pack
1340        // re-deltas against whatever is in ITS window, so a differing base is
1341        // the ordinary case, not evidence of corruption.
1342        let based = entries
1343            .iter()
1344            .find(|e| e.delta_base != 0)
1345            .expect("the fixture must carry a delta");
1346        let mut rebased = based.clone();
1347        rebased.delta_base += 8;
1348        stack
1349            .append(std::slice::from_ref(&rebased))
1350            .expect("a re-pushed pack that re-deltaed was refused");
1351        assert_eq!(
1352            stack.lookup(&based.oid).unwrap().delta_base,
1353            based.delta_base,
1354            "the second pack's base was adopted — the projection now names a base \
1355             the FIRST writer never recorded, which is the thing the old guard \
1356             was right to fear"
1357        );
1358    }
1359
1360    /// The volume trigger fires on appended bytes and the miss trigger on
1361    /// tail-served lookups, and neither fires on a genuine absence.
1362    ///
1363    /// Seen RED by counting `absent` into `tail_hits` in `fill_from_tail`:
1364    /// "assertion `left == right` failed: absences tripped the miss trigger /
1365    /// left: Some(TailHits(1000)) / right: None". Restored.
1366    #[test]
1367    fn each_trigger_fires_on_its_own_signal() {
1368        // ── volume ──
1369        let entries = synthetic_entries(64, 20, 21);
1370        let bytes: u64 = entries.iter().map(|e| e.len).sum();
1371        let stack = Stack::in_memory(RebuildTriggers {
1372            tail_bytes: bytes,
1373            ..RebuildTriggers::manual()
1374        })
1375        .unwrap();
1376        let g0 = stack.stats().generation;
1377        let reason = stack.append(&entries).unwrap();
1378        assert!(
1379            matches!(reason, Some(RebuildReason::TailBytes(_))),
1380            "the volume trigger did not fire at exactly its threshold: {reason:?}"
1381        );
1382        assert_eq!(stack.projection_len(), 64, "the rebuild absorbed nothing");
1383        assert_eq!(stack.stats().generation, g0 + 1);
1384        assert_eq!(
1385            stack.stats().tail_bytes,
1386            0,
1387            "the byte counter was not reset"
1388        );
1389
1390        // ── misses ──
1391        let all = synthetic_entries(200, 20, 22);
1392        let (sealed, tail) = all.split_at(100);
1393        // A flat floor of 100 misses: `tail_hits_per_row` 0 means the threshold
1394        // is exactly the floor, which makes the arithmetic in this test the
1395        // trigger's and not the ratio's.
1396        let stack = Stack::in_memory(RebuildTriggers {
1397            tail_hits_per_row: 0.0,
1398            min_tail_hits: 100,
1399            tail_bytes: 0,
1400        })
1401        .unwrap();
1402        stack.append(sealed).unwrap();
1403        stack.rebuild().unwrap();
1404        stack.append(tail).unwrap();
1405        let g1 = stack.stats().generation;
1406        assert_eq!(stack.projection_len(), 100);
1407
1408        // 1000 lookups of oids in NO repository: 1000 tail misses, zero
1409        // tail-served hits, and no rebuild may follow.
1410        let nowhere = synthetic_entries(1000, 20, 23);
1411        let refs: Vec<&[u8]> = nowhere.iter().map(|e| e.oid.as_slice()).collect();
1412        assert!(stack.lookup_batch(&refs).iter().all(Option::is_none));
1413        assert_eq!(stack.stats().absent, 1000);
1414        assert_eq!(
1415            stack.rebuild_due(),
1416            None,
1417            "absences tripped the miss trigger"
1418        );
1419        stack.maybe_rebuild().unwrap();
1420        assert_eq!(
1421            stack.stats().generation,
1422            g1,
1423            "1000 absences must not rebuild"
1424        );
1425        assert_eq!(stack.projection_len(), 100);
1426
1427        // 100 lookups the tail DOES answer: the trigger fires.
1428        let refs: Vec<&[u8]> = tail.iter().map(|e| e.oid.as_slice()).collect();
1429        assert_eq!(stack.lookup_batch(&refs).iter().flatten().count(), 100);
1430        assert_eq!(
1431            stack.rebuild_due(),
1432            Some(RebuildReason::TailHits(100)),
1433            "100 tail-served lookups did not trip a threshold of 100"
1434        );
1435        assert!(stack.maybe_rebuild().unwrap().is_some());
1436        assert_eq!(
1437            stack.projection_len(),
1438            200,
1439            "the rebuild did not absorb the tail"
1440        );
1441        assert_eq!(stack.stats().generation, g1 + 1);
1442    }
1443
1444    /// The two column scans answer for the **repository**, not for the
1445    /// projection — including the rows the projection has not absorbed, and
1446    /// without scanning the tail to find them.
1447    ///
1448    /// Seen RED by dropping the `unabsorbed_size` term from `sum_uncompressed`:
1449    /// "assertion `left == right` failed: sum_uncompressed answered for the
1450    /// projection, not the repository / left: 1179629 / right: 1915881".
1451    /// Restored.
1452    ///
1453    /// The rejected-append half of this test is what pins the *ordering* in
1454    /// `append`: the counters are accumulated locally and applied after the
1455    /// commit, because a `bail!` half-way through a batch rolls redb back and
1456    /// counters bumped inside the loop would not roll back with it. That one is
1457    /// fixed by construction rather than watched — there is one place the
1458    /// counters are written and it is after `commit()`.
1459    #[test]
1460    fn the_column_scans_answer_for_the_repository_not_the_projection() {
1461        let all = synthetic_entries(300, 20, 71);
1462        let (sealed, tail) = all.split_at(180);
1463        let stack = split_stack(sealed, tail);
1464        let complete = OneTableFourColumns::build(&all).unwrap();
1465
1466        assert!(
1467            stack.projection.read().unwrap().sum_uncompressed() < complete.sum_uncompressed(),
1468            "the projection already sums to the whole repository — this test would be vacuous"
1469        );
1470        assert_eq!(
1471            stack.sum_uncompressed(),
1472            complete.sum_uncompressed(),
1473            "sum_uncompressed answered for the projection, not the repository"
1474        );
1475        for t in ObjType::ALL {
1476            assert_eq!(
1477                stack.count_type(t),
1478                complete.count_type(t),
1479                "count_type({}) answered for the projection",
1480                t.as_str()
1481            );
1482        }
1483
1484        // A rejected append must not move either aggregate.
1485        let mut rewritten = all[0].clone();
1486        rewritten.uncompressed_size += 1_000_000;
1487        let batch = [all[7].clone(), rewritten];
1488        assert!(stack.append(&batch).is_err());
1489        assert_eq!(stack.sum_uncompressed(), complete.sum_uncompressed());
1490
1491        // And a rebuild leaves them where they were.
1492        stack.rebuild().unwrap();
1493        assert_eq!(stack.sum_uncompressed(), complete.sum_uncompressed());
1494        for t in ObjType::ALL {
1495            assert_eq!(stack.count_type(t), complete.count_type(t));
1496        }
1497    }
1498
1499    /// The two narrow batch paths fall through to the tail exactly as the
1500    /// full-row one does. A miss is not an answer on those either.
1501    ///
1502    /// Seen RED by returning the projection's `ordinals_batch` unchanged:
1503    /// "assertion `left == right` failed: the ordinal path lost 120 of 300
1504    /// objects / left: 180 / right: 300". Restored.
1505    #[test]
1506    fn the_narrow_batch_paths_also_fall_through() {
1507        let all = synthetic_entries(300, 20, 81);
1508        let (sealed, tail) = all.split_at(180);
1509        let stack = split_stack(sealed, tail);
1510        let refs: Vec<&[u8]> = all.iter().map(|e| e.oid.as_slice()).collect();
1511
1512        let ordinals = stack.ordinals_batch(&refs);
1513        assert_eq!(
1514            ordinals.iter().filter(|o| o.is_some()).count(),
1515            300,
1516            "the ordinal path lost {} of 300 objects",
1517            300 - ordinals.iter().filter(|o| o.is_some()).count()
1518        );
1519        for (o, e) in ordinals[180..].iter().zip(tail) {
1520            assert!(
1521                o.unwrap() & TAIL_ORDINAL_BIT != 0,
1522                "tail object {} got a projection ordinal",
1523                hex::encode(&e.oid)
1524            );
1525        }
1526
1527        let extents = stack.extents_batch(&refs);
1528        for (x, e) in extents.iter().zip(&all) {
1529            assert_eq!(
1530                *x,
1531                Some((e.offset, e.len)),
1532                "extent lost or wrong for {}",
1533                hex::encode(&e.oid)
1534            );
1535        }
1536    }
1537
1538    /// A rebuild re-derives every ordinal as the new lexicographic rank, and the
1539    /// tail's high-bit ordinals disappear as their rows are absorbed. This is the
1540    /// generation caveat, asserted rather than only documented.
1541    ///
1542    /// Seen RED by having `rebuild` compute the fresh projection and never swap
1543    /// it in: "an absorbed row kept its tail ordinal". Restored.
1544    #[test]
1545    fn a_rebuild_re_derives_ordinals_and_clears_the_tail_bit() {
1546        let all = synthetic_entries(120, 32, 31);
1547        let (sealed, tail) = all.split_at(60);
1548        let stack = split_stack(sealed, tail);
1549
1550        let before = stack.lookup(&tail[0].oid).unwrap();
1551        assert!(is_tail_row(&before));
1552
1553        stack.rebuild().unwrap();
1554        let after = stack.lookup(&tail[0].oid).unwrap();
1555        assert!(
1556            !is_tail_row(&after),
1557            "an absorbed row kept its tail ordinal"
1558        );
1559        assert_eq!(after.offset, before.offset, "absorption changed a fact");
1560        assert_eq!(after.len, before.len);
1561        assert_eq!(after.obj_type, before.obj_type);
1562        assert_eq!(after.uncompressed_size, before.uncompressed_size);
1563        assert_eq!(after.delta_base, before.delta_base);
1564
1565        // The delta base is an ARCHIVE OFFSET and absorption must not touch it.
1566        // An ordinal in this column would have had to be re-derived by the
1567        // rebuild — silently, and to a different number, because the rebuild
1568        // re-ranks every row. That is §13's argument, asserted on a row that
1569        // actually carries a base rather than on the 0 sentinel.
1570        let based = tail
1571            .iter()
1572            .find(|e| e.delta_base != 0)
1573            .expect("the tail half must carry at least one delta");
1574        let row = stack.lookup(&based.oid).unwrap();
1575        assert_eq!(
1576            row.delta_base,
1577            based.delta_base,
1578            "the rebuild moved {}'s delta base from {} to {}",
1579            hex::encode(&based.oid),
1580            based.delta_base,
1581            row.delta_base
1582        );
1583        assert_ne!(
1584            row.delta_base, row.ordinal as u64,
1585            "a delta base that equals a row ordinal is the mistake §13 forbids"
1586        );
1587
1588        let complete = OneTableFourColumns::build(&all).unwrap();
1589        assert_eq!(
1590            after.ordinal,
1591            complete.lookup(&tail[0].oid).unwrap().ordinal,
1592            "the rebuilt ordinal is not the lexicographic rank"
1593        );
1594    }
1595
1596    /// **The crash-recovery diff is exact at the pack boundary.**
1597    ///
1598    /// Three packs' worth of rows laid end to end, the middle one never appended:
1599    /// [`ObjectReadStack::extents_with_rows`] must answer `true, false, true`.
1600    /// The middle pack's extent is the interesting one — it is bounded on both
1601    /// sides by rows that *are* in the tail, so an off-by-one in either direction
1602    /// reports it absorbed, its objects are never re-queued after a restart, and a
1603    /// durable pack becomes unreadable for ever.
1604    ///
1605    /// Asserted per extent, not as a count.
1606    ///
1607    /// Seen RED by `let p = starts.partition_point(|&s| s <= offset)` →
1608    /// `partition_point(|&s| s < offset)`, i.e. placing a row against the
1609    /// *previous* extent when it lands on a pack's first byte: "left: [false] /
1610    /// right: [true]" on the single-row extent. Restored.
1611    ///
1612    /// Seen RED a second time by `offset < start + len` → `offset <= start + len`:
1613    /// "a row one byte past pack B's extent was counted as B's — left: [true,
1614    /// true], right: [true, false]". **That mutation stayed green against the
1615    /// four-pack question**, and the reason is worth writing down: when every
1616    /// extent is in the question and the packs are adjacent, the row at B's end
1617    /// offset is also C's first row, and the binary search assigns it to C before
1618    /// the bound is ever consulted. The bound only bites when a pack's successor
1619    /// is *not* in the question — a journal whose tail was torn, which is the
1620    /// state this whole diff exists to survive. The `[..2]` case below is
1621    /// therefore not an extra assertion, it is the one that tests the bound at
1622    /// all. Restored.
1623    #[test]
1624    fn the_crash_recovery_diff_is_exact_at_the_pack_boundary() {
1625        let all = synthetic_entries(400, 20, 0xB17_5E7);
1626        // Four packs, back to back, the way `SafeWriter` appends them.
1627        let bounds = [(0usize, 100usize), (100, 200), (200, 300), (300, 400)];
1628        let extents: Vec<(u64, u64)> = bounds
1629            .iter()
1630            .map(|&(a, b)| {
1631                let start = all[a].offset;
1632                let end = all[b - 1].offset + all[b - 1].len;
1633                (start, end - start)
1634            })
1635            .collect();
1636        assert_eq!(
1637            extents[1].0,
1638            extents[0].0 + extents[0].1,
1639            "the fixture's packs must be adjacent or the boundary is not under test"
1640        );
1641
1642        let stack = manual();
1643        for (i, &(a, b)) in bounds.iter().enumerate() {
1644            if i != 1 {
1645                stack.append(&all[a..b]).unwrap();
1646            }
1647        }
1648        assert_eq!(stack.len(), 300, "the middle pack must be the missing one");
1649
1650        let hit = stack.extents_with_rows(&extents).unwrap();
1651        assert_eq!(
1652            hit,
1653            vec![true, false, true, true],
1654            "pack B has no rows in the tail and was reported absorbed"
1655        );
1656
1657        // **The containment bound, made observable.** Asking only about A and B
1658        // is the torn-journal case: pack C's rows are in the tail but its extent
1659        // is not in the question, and C's first row sits at exactly B's end
1660        // offset. That row belongs to C and must not answer for B.
1661        assert_eq!(
1662            stack.extents_with_rows(&extents[..2]).unwrap(),
1663            vec![true, false],
1664            "a row one byte past pack B's extent was counted as B's"
1665        );
1666
1667        // A zero-length extent has no rows by construction, and an extent past
1668        // everything the tail knows about has none either.
1669        let past = all[399].offset + all[399].len;
1670        assert_eq!(
1671            stack.extents_with_rows(&[(0, 0), (past, 4096)]).unwrap(),
1672            vec![false, false]
1673        );
1674        // And an extent that covers exactly one row is that row's pack.
1675        assert_eq!(
1676            stack
1677                .extents_with_rows(&[(all[7].offset, all[7].len)])
1678                .unwrap(),
1679            vec![true]
1680        );
1681    }
1682
1683    /// A file-backed tail survives being closed and reopened, and the reopened
1684    /// stack warm-starts a projection over everything — including rows that were
1685    /// only in the tail when the process went away.
1686    ///
1687    /// Seen RED by having `from_db` build the projection from an empty slice
1688    /// instead of `scan(&db)`: "reopened projection covers 0 of 90". Restored.
1689    #[test]
1690    fn a_file_backed_tail_reopens_warm() {
1691        let dir = tempfile::tempdir().unwrap();
1692        let path = dir.path().join("objects.tail.redb");
1693        let all = synthetic_entries(90, 20, 41);
1694        {
1695            let stack =
1696                ObjectReadStack::<OneTableFourColumns>::open(
1697                    &path,
1698                    RebuildTriggers::manual(),
1699                    DEFAULT_REDB_CACHE_BYTES,
1700                )
1701                    .unwrap();
1702            stack.append(&all[..40]).unwrap();
1703            stack.rebuild().unwrap();
1704            stack.append(&all[40..]).unwrap();
1705            assert_eq!(stack.projection_len(), 40);
1706        }
1707        let stack =
1708            ObjectReadStack::<OneTableFourColumns>::open(
1709                    &path,
1710                    RebuildTriggers::manual(),
1711                    DEFAULT_REDB_CACHE_BYTES,
1712                ).unwrap();
1713        assert_eq!(
1714            stack.projection_len(),
1715            90,
1716            "reopened projection covers {} of 90",
1717            stack.projection_len()
1718        );
1719        for e in &all {
1720            let row = stack
1721                .lookup(&e.oid)
1722                .unwrap_or_else(|| panic!("reopen lost {}", hex::encode(&e.oid)));
1723            assert_eq!(row.offset, e.offset);
1724            assert_eq!(row.uncompressed_size, e.uncompressed_size);
1725            assert_eq!(row.delta_base, e.delta_base, "reopen lost a delta base");
1726        }
1727        assert!(
1728            all.iter().filter(|e| e.delta_base != 0).count() >= 5,
1729            "the fixture must carry delta bases across the reopen"
1730        );
1731    }
1732
1733    /// The packed tail row rejects the two things that would make it silently
1734    /// wrong: a short buffer and a type code git does not use.
1735    ///
1736    /// Seen RED by dropping the length check from `decode_row`: the short-buffer
1737    /// case panicked — "range end index 33 out of range for slice of length 32"
1738    /// — instead of returning an error. Restored.
1739    ///
1740    /// Seen RED for the `delta_base` field by decoding it from byte 25 (where
1741    /// `uncompressed_size` starts) instead of byte 33: "the delta base did not
1742    /// survive the tail row / left: 2448 / right: 4242" — it came back holding
1743    /// the *size*. Both are plausible u64s, so only a comparison against the
1744    /// entry that was encoded can tell them apart. Restored.
1745    ///
1746    /// A row written by the 33-byte encoding is a **length** error here rather
1747    /// than a misread: `decode_row` refuses anything that is not exactly
1748    /// `TAIL_ROW_BYTES`, and the assertion below is the same check the old width
1749    /// now trips.
1750    #[test]
1751    fn a_packed_tail_row_round_trips_and_refuses_nonsense() {
1752        let mut e = synthetic_entries(1, 20, 51)[0].clone();
1753        // A one-entry workload has nothing to delta against, so the base is set
1754        // here: a round trip that only ever saw 0 would not test the field.
1755        e.delta_base = 4242;
1756        let e = &e;
1757        let packed = encode_row(9, e);
1758        let back = decode_row(&packed).unwrap();
1759        assert_eq!(back.seq, 9);
1760        assert_eq!(back.offset, e.offset);
1761        assert_eq!(back.len, e.len);
1762        assert_eq!(back.obj_type, e.obj_type);
1763        assert_eq!(back.uncompressed_size, e.uncompressed_size);
1764        assert_eq!(
1765            back.delta_base, e.delta_base,
1766            "the delta base did not survive the tail row"
1767        );
1768        assert_ne!(
1769            back.delta_base, back.uncompressed_size,
1770            "the fixture must not let the two u64 fields alias"
1771        );
1772
1773        assert!(decode_row(&packed[..TAIL_ROW_BYTES - 1]).is_err());
1774        // 33 bytes is exactly the pre-`delta_base` row width, and it is refused
1775        // rather than read as a row with the field missing.
1776        assert!(
1777            decode_row(&packed[..33]).is_err(),
1778            "a row in the old 33-byte encoding must be refused, not reinterpreted"
1779        );
1780        let mut bad = packed;
1781        bad[24] = 5; // git uses 1-4, 6, 7; 5 is unassigned
1782        assert!(
1783            decode_row(&bad).is_err(),
1784            "an unassigned object type code was accepted"
1785        );
1786    }
1787
1788    /// The stack is the same index as the two Arrow arms once it is fully
1789    /// absorbed, which is what lets it be dropped into
1790    /// `examples/index_layout_bench.rs` unchanged.
1791    ///
1792    /// Seen RED by having `ObjectIndex::build` skip its final `rebuild()`, so
1793    /// every row was served from the tail instead of from the projection:
1794    /// "assertion `left == right` failed: build left rows in the tail / left: 0
1795    /// / right: 400". Restored.
1796    #[test]
1797    fn a_fully_absorbed_stack_agrees_with_both_arrow_arms() {
1798        let entries = synthetic_entries(400, 20, 61);
1799        let absent = synthetic_entries(100, 20, 62);
1800        let stack = Stack::build(&entries).unwrap();
1801        let a = FourTables::build(&entries).unwrap();
1802        let b = OneTableFourColumns::build(&entries).unwrap();
1803
1804        assert_eq!(stack.len(), 400);
1805        assert_eq!(stack.projection_len(), 400, "build left rows in the tail");
1806
1807        let mut refs: Vec<&[u8]> = entries.iter().map(|e| e.oid.as_slice()).collect();
1808        refs.extend(absent.iter().map(|e| e.oid.as_slice()));
1809        let rs = stack.lookup_batch(&refs);
1810        assert_eq!(
1811            rs,
1812            a.lookup_batch(&refs),
1813            "the stack disagrees with FourTables"
1814        );
1815        assert_eq!(
1816            rs,
1817            b.lookup_batch(&refs),
1818            "the stack disagrees with OneTableFourColumns"
1819        );
1820        assert_eq!(
1821            stack.stats().tail_hits,
1822            0,
1823            "a fully absorbed stack still went to the tail for a row"
1824        );
1825        assert_eq!(stack.stats().absent, 100);
1826    }
1827
1828    /// **A complete projection answers an absence without opening a redb
1829    /// transaction at all** — and the moment one row is appended, it stops doing
1830    /// that.
1831    ///
1832    /// This is the fast path the bench found was worth having: a `have`
1833    /// negotiation is mostly misses, and paying a redb round trip for each of
1834    /// them made the stack six times the cost of the bare Arrow arm on the most
1835    /// common git operation. `tail_txns` is the applied output — an assertion on
1836    /// timing would be a flake, and an assertion on the returned rows cannot
1837    /// distinguish the two paths, because both return the same rows. That is the
1838    /// point: the fast path is only allowed to exist because it is
1839    /// indistinguishable in its answers.
1840    ///
1841    /// Seen RED by deleting the `projection_is_complete()` branch from
1842    /// `lookup_batch`: "assertion `left == right` failed: a complete projection
1843    /// opened 1 redb transactions to say 'no' / left: 1 / right: 0". Restored.
1844    ///
1845    /// Seen RED the other way — the dangerous way — by making
1846    /// `projection_is_complete` return `true` unconditionally: the
1847    /// `the_projection_is_incomplete_but_never_wrong` test failed with "the
1848    /// stack lost tail object …, batch path", because an incomplete projection
1849    /// then declared its misses absent. Restored.
1850    #[test]
1851    fn a_complete_projection_answers_absent_without_asking_the_tail() {
1852        let entries = synthetic_entries(200, 20, 91);
1853        let nowhere = synthetic_entries(500, 20, 92);
1854        let stack = Stack::build(&entries).unwrap();
1855        assert!(stack.projection_is_complete());
1856
1857        let refs: Vec<&[u8]> = nowhere.iter().map(|e| e.oid.as_slice()).collect();
1858        assert!(stack.lookup_batch(&refs).iter().all(Option::is_none));
1859        assert!(stack.lookup(&nowhere[0].oid).is_none());
1860        assert!(stack.ordinals_batch(&refs).iter().all(Option::is_none));
1861        assert!(stack.extents_batch(&refs).iter().all(Option::is_none));
1862
1863        let st = stack.stats();
1864        assert_eq!(st.unabsorbed_rows, 0);
1865        assert_eq!(
1866            st.tail_txns, 0,
1867            "a complete projection opened {} redb transactions to say 'no'",
1868            st.tail_txns
1869        );
1870        assert_eq!(st.absent, 500 + 1 + 500 + 500, "absences went uncounted");
1871
1872        // One appended row and the fall-through is on again — the fast path is
1873        // conditional on completeness and not on having been complete once.
1874        let more = synthetic_entries(1, 20, 93);
1875        stack.append(&more).unwrap();
1876        assert!(!stack.projection_is_complete());
1877        assert!(stack.lookup_batch(&refs).iter().all(Option::is_none));
1878        assert!(
1879            stack.stats().tail_txns > 0,
1880            "an incomplete projection did not consult the tail"
1881        );
1882        assert!(
1883            stack.lookup(&more[0].oid).is_some(),
1884            "the appended row is not reachable"
1885        );
1886    }
1887}