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, 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 pub fn open(tail_path: &Path, triggers: RebuildTriggers, cache_bytes: usize) -> Result<Self> {
552 let db = Database::builder()
553 .set_cache_size(cache_bytes)
554 .create(tail_path)
555 .with_context(|| format!("opening object tail at {}", tail_path.display()))?;
556 // `objects.tail` → `objects.index`: the persisted projection sits beside
557 // the redb tail it is folded from. Only for an arm that can be written
558 // down; the others keep every row in redb, as before 2026-09-04.
559 let persisted = S::persists().then(|| tail_path.with_extension("index"));
560 Self::from_db(db, triggers, persisted, Some(tail_path.to_path_buf()))
561 }
562
563 /// Where this stack persists its projection, if it does.
564 pub fn persisted_path(&self) -> Option<&Path> {
565 self.persisted.as_deref()
566 }
567
568 /// **The name the built projection reports about itself** — the applied
569 /// output that says which [`crate::arms::IndexArm`] actually got built.
570 ///
571 /// Deliberately not [`ObjectIndex::name`] on the stack, which answers
572 /// `"ObjectReadStack"` for every arm because that is what the stack is. `S`
573 /// is the layout, and only `S` can say which one it is. It is the value
574 /// [`crate::arms::IndexArm::projection_name`] is written to be compared
575 /// against, so a caller that selected an arm can prove the selection took
576 /// rather than echo the selector back to itself.
577 pub fn projection_name(&self) -> &'static str {
578 self.projection.read().expect("projection lock").name()
579 }
580
581 /// A stack whose tail has no file behind it. Used by [`ObjectIndex::build`]
582 /// and by benchmarks; the redb code path is identical, only the backend
583 /// differs, so a test of this stack is a test of the durable one.
584 pub fn in_memory(triggers: RebuildTriggers) -> Result<Self> {
585 let db = Database::builder()
586 .create_with_backend(redb::backends::InMemoryBackend::new())
587 .context("creating an in-memory object tail")?;
588 Self::from_db(db, triggers, None, None)
589 }
590
591 fn from_db(
592 db: Database,
593 triggers: RebuildTriggers,
594 persisted: Option<PathBuf>,
595 tail_path: Option<PathBuf>,
596 ) -> Result<Self> {
597 // Materialise both tables so a read on a brand-new database does not
598 // trip `TableDoesNotExist`.
599 let w = db.begin_write()?;
600 let dropped = {
601 let _ = w.open_table(OBJECTS)?;
602 let mut meta = w.open_table(META)?;
603 let found = meta.get(META_TAIL_FORMAT)?.map(|v| v.value());
604 match found {
605 // Written by this layout, or by the layout in use when the key
606 // was introduced (see `META_TAIL_FORMAT`): nothing to do.
607 Some(TAIL_FORMAT) => 0,
608 None => {
609 meta.insert(META_TAIL_FORMAT, TAIL_FORMAT)?;
610 0
611 }
612 // A DIFFERENT layout. Every row in OBJECTS is unreadable by
613 // `decode_row`, so the tail is emptied here — in the same
614 // transaction that stamps the new version, so a crash between
615 // them cannot leave rows of one layout under the mark of
616 // another. `adopt_journal` then sees every pack as unabsorbed
617 // and requeues it.
618 Some(other) => {
619 let mut objects = w.open_table(OBJECTS)?;
620 let n = objects.len()?;
621 objects.retain(|_, _| false)?;
622 meta.insert(META_TAIL_FORMAT, TAIL_FORMAT)?;
623 // The oid set changed, so the content epoch moves with it.
624 let epoch = meta.get(META_TAIL_EPOCH)?.map(|v| v.value()).unwrap_or(0);
625 meta.insert(META_TAIL_EPOCH, epoch + 1)?;
626 tracing::warn!(
627 target: "znippy.git",
628 found = other,
629 expected = TAIL_FORMAT,
630 rows = n,
631 "the object tail was written in a different layout; dropping it and \
632 re-indexing from the journal. Reads fall back to the pack until the \
633 drain catches up — slower, never wrong"
634 );
635 n
636 }
637 }
638 };
639 w.commit()?;
640 let _ = dropped;
641 let mut db = db;
642
643 // ── the warm open ────────────────────────────────────────────────────
644 //
645 // If a persisted projection exists, it IS the projection: mapped, not
646 // rebuilt. redb then holds only the rows that arrived after the last
647 // fold, and those are served by the fall-through exactly as an
648 // un-absorbed row always was. The scan below is over that remainder —
649 // small by construction — not over the repository.
650 //
651 // The one thing to reconcile is the crash window a fold leaves: the
652 // file is renamed into place BEFORE redb forgets the rows it holds, so
653 // a crash between the two leaves the same row in both. Rows redb has
654 // that the file also has are removed here, in one transaction, so the
655 // two layers are disjoint again and `len` / `oids_in_order` count each
656 // object once. The other direction — file missing rows redb dropped —
657 // cannot happen, because the delete only ever follows a completed
658 // rename.
659 let folded: Option<(Arc<S>, u128, u128)> = match persisted.as_deref() {
660 Some(path) => {
661 // A `.tmp` left by a fold that died before its rename is not a
662 // projection and must not become one by accident.
663 let _ = fs::remove_file(tmp_path(path));
664 load_persisted::<S>(path)
665 }
666 None => None,
667 };
668 let t_scan = std::time::Instant::now();
669 let mut entries = scan(&db)?;
670 let mut migrated = 0u64;
671 let (projection, unfolded, loaded) = match folded {
672 Some((folded, map_ms, build_ms)) => {
673 let dup: Vec<Vec<u8>> = entries
674 .iter()
675 .filter(|e| folded.lookup(&e.oid).is_some())
676 .map(|e| e.oid.clone())
677 .collect();
678 if !dup.is_empty() {
679 let w = db.begin_write()?;
680 {
681 let mut objects = w.open_table(OBJECTS)?;
682 for oid in &dup {
683 objects.remove(oid.as_slice())?;
684 }
685 }
686 w.commit()?;
687 tracing::warn!(
688 target: "znippy.git",
689 rows = dup.len(),
690 "the object tail held rows the persisted index already has — a fold \
691 was interrupted between writing the index and emptying the tail; \
692 the duplicates are dropped from the tail, nothing is lost"
693 );
694 entries.retain(|e| folded.lookup(&e.oid).is_none());
695 }
696 // The three costs of a warm open, separately, so the next lever
697 // (persisting the stree section) is judged on the number it
698 // would actually remove: `build_ms` is the O(n) stree build over
699 // the mapped oid column; `map_ms` is the O(1) open+mmap;
700 // `tail_ms` is the redb scan of the rows since the last fold
701 // plus the reconcile. `stree_bytes` is what that build produces
702 // and what persisting it would have to write.
703 tracing::info!(
704 target: "znippy.git",
705 folded = folded.len(),
706 tail = entries.len(),
707 map_ms,
708 build_ms,
709 tail_ms = t_scan.elapsed().as_millis(),
710 stree_bytes = folded.resident_bytes().saturating_sub(folded.ipc_bytes()),
711 "mapped the persisted object index; the redb tail holds only what \
712 arrived after the last fold"
713 );
714 (folded, entries, 1u64)
715 }
716 None => {
717 let projection = Arc::new(S::build(&entries)?);
718 // ── the cold open of a store that predates persistence ──────
719 //
720 // No file, and redb holds rows: a store written before
721 // 2026-09-04, or one whose index was lost. The projection was
722 // just built from the full scan the old design paid on every
723 // open; fold it now, so this is the LAST time this store pays
724 // it. Nothing else holds `db` yet, so every row it has is a row
725 // the projection was just built from: drop the table rather
726 // than remove 14 M keys one by one. The kernel mirror pays this
727 // once — 14 M rows out of redb, ~850 MB written — and never
728 // again.
729 if let Some(path) = persisted.as_deref()
730 && !entries.is_empty()
731 {
732 write_atomic(path, projection.persisted_bytes().expect("PERSISTS arm"))?;
733 let w = db.begin_write()?;
734 w.delete_table(OBJECTS)?;
735 let _ = w.open_table(OBJECTS)?;
736 w.commit()?;
737 migrated = 1;
738 tracing::info!(
739 target: "znippy.git",
740 rows = entries.len(),
741 path = %path.display(),
742 "folded the object tail into a persisted index on first open; the \
743 redb tail now holds only rows that arrive after this"
744 );
745 }
746 (projection, Vec::new(), 0u64)
747 }
748 };
749
750 // ── give redb its file back ──────────────────────────────────────────
751 //
752 // redb reuses freed pages but never shrinks its file. A store that just
753 // migrated has a redb at its old high-water mark with nothing in it —
754 // 4 GiB allocated, 2.9 GB of blocks, for the kernel mirror — and a store
755 // that has been folding for a while sits at one fold window of dead
756 // pages (270 MB on the same repository, over 54 k live rows). This is the
757 // one moment the `Database` is owned and no transaction is open, so
758 // compaction is free of coordination here and nowhere else.
759 compact_if_fragmented(&mut db, tail_path.as_deref());
760
761 // What the projection does not cover, counted the way `append` would
762 // have counted it, so the column-scan aggregates and the completeness
763 // fast path are exact from the first read.
764 let total = projection.len() as u64 + unfolded.len() as u64;
765 let mut unabsorbed_size = 0u64;
766 let mut unabsorbed_types = [0u64; 8];
767 let mut tail_bytes = 0u64;
768 for e in &unfolded {
769 unabsorbed_size += e.uncompressed_size;
770 unabsorbed_types[e.obj_type.code() as usize] += 1;
771 tail_bytes += e.len;
772 }
773 let db = Arc::new(db);
774 let stack = Self {
775 tail: db,
776 projection: RwLock::new(projection),
777 triggers,
778 total_rows: AtomicU64::new(total),
779 tail_hits: AtomicU64::new(0),
780 absent: AtomicU64::new(0),
781 tail_bytes: AtomicU64::new(tail_bytes),
782 rebuilds: AtomicU64::new(1),
783 generation: AtomicU64::new(1),
784 unabsorbed_size: AtomicU64::new(unabsorbed_size),
785 unabsorbed_types: unabsorbed_types.map(AtomicU64::new),
786 unabsorbed_rows: AtomicU64::new(unfolded.len() as u64),
787 tail_txns: AtomicU64::new(0),
788 persisted,
789 fold: Mutex::new(()),
790 folds: AtomicU64::new(migrated),
791 persisted_loads: AtomicU64::new(loaded),
792 };
793
794 Ok(stack)
795 }
796
797 /// **The projection covers every row in the repository**, so a miss in the
798 /// stree *is* an absence and the tail need not be asked.
799 ///
800 /// Sound by construction, not by hope: the projection is built from a scan
801 /// of the tail, so its rows are always a subset of the tail's; the archive
802 /// is append-only, so no row ever leaves; therefore equal counts mean equal
803 /// sets. A concurrent append between this check and the lookup only means
804 /// the lookup answers as of an instant before that append, which is what a
805 /// lookup issued a microsecond earlier would have done anyway.
806 ///
807 /// This is worth the paragraph because of what it buys. MEASURED on oden
808 /// 2026-08-07 (`read_stack_bench`, 100 000 objects, batch 1000): a `have`
809 /// negotiation — 10% hit, so 90% of the oids are in no repository at all —
810 /// cost **544 ns** per oid when every one of those absences took a redb
811 /// round trip, against **77 ns** for the bare Arrow arm. With this fast path
812 /// the same workload costs **68 ns**, i.e. the Arrow arm's own cost inside
813 /// the noise band. A `have` negotiation is *mostly* misses, so without this
814 /// the stack would be the dominant cost of the most common operation a git
815 /// server performs.
816 #[inline]
817 pub fn projection_is_complete(&self) -> bool {
818 self.unabsorbed_rows.load(Ordering::Acquire) == 0
819 }
820
821 /// Append objects to the tail.
822 ///
823 /// **Append-only is enforced here, not assumed.** An oid already present with
824 /// identical facts is a no-op (a re-pushed pack repeats objects, and that is
825 /// normal); an oid already present with *different* facts is an error and
826 /// nothing in the batch is written. Without that refusal a stree hit could
827 /// return a superseded row and the projection would be not merely incomplete
828 /// but wrong, which is the one thing this design must not allow.
829 ///
830 /// Runs a rebuild inline if the batch trips a threshold; the reason is
831 /// returned so the caller can see that it happened.
832 pub fn append(&self, entries: &[IndexEntry]) -> Result<Option<RebuildReason>> {
833 if entries.is_empty() {
834 return Ok(None);
835 }
836 let width = entries[0].oid.len();
837 if width != 20 && width != 32 {
838 bail!("oid width {width} is neither sha1 (20) nor sha256 (32)");
839 }
840
841 // Under the fold lock for the transaction AND the counter updates, so a
842 // fold cannot reset `unabsorbed_rows` between this batch landing in
843 // redb and its count landing in the atomics. Released before the
844 // trigger check below, because `rebuild` takes the same lock.
845 let guard = self.fold.lock().map_err(|_| anyhow!("the fold lock is poisoned"))?;
846 let w = self.tail.begin_write()?;
847 let mut added = 0u64;
848 let mut bytes = 0u64;
849 // Accumulated locally and applied only after the commit: a `bail!`
850 // half-way through the batch rolls redb back, and counters bumped inside
851 // the loop would survive a rollback and put the column-scan aggregates
852 // permanently out of step with the tail.
853 let mut added_size = 0u64;
854 let mut added_types = [0u64; 8];
855 {
856 let mut objects = w.open_table(OBJECTS)?;
857 let mut meta = w.open_table(META)?;
858 let mut seq = meta.get(META_ARRIVAL_SEQ)?.map(|v| v.value()).unwrap_or(0);
859 // The rows already known: the projection first, then redb. On a
860 // persisting stack the projection holds every folded row and redb
861 // does NOT, so a re-pushed or re-indexed object that was folded
862 // would otherwise be inserted into redb as new and exist in both
863 // layers — `len()` doubles, `oids_in_order` repeats, and the next
864 // fold's `validate` refuses the duplicate oid. MEASURED 2026-09-04
865 // on `dropping_the_exploded_table_and_reopening_still_answers`:
866 // 2704 rows for a 1352-object repository, exactly ×2. The stree
867 // lookup is also the cheaper check by an order of magnitude, so the
868 // common case of a re-push now never opens redb for it.
869 let projection = self.projection.read().expect("projection lock").clone();
870 for e in entries {
871 if e.oid.len() != width {
872 bail!(
873 "mixed oid widths in one append: {width} and {}",
874 e.oid.len()
875 );
876 }
877 let known: Option<TailRow> = match projection.lookup(&e.oid) {
878 Some(row) => Some(TailRow {
879 seq: 0,
880 offset: row.offset,
881 len: row.len,
882 obj_type: row.obj_type,
883 uncompressed_size: row.uncompressed_size,
884 delta_base: row.delta_base,
885 }),
886 None => match objects.get(e.oid.as_slice())? {
887 Some(existing) => Some(decode_row(existing.value())?),
888 None => None,
889 },
890 };
891 if let Some(old) = known {
892 // ── IDENTITY, not PLACEMENT ─────────────────────────
893 //
894 // `offset`, `len` and `delta_base` say WHERE this copy of
895 // the object landed. They are not what it IS. A re-pushed
896 // pack legitimately places the same oid somewhere else —
897 // that is the ordinary case, not a violation — so comparing
898 // them refused a normal push.
899 //
900 // MEASURED 2026-08-14: pushing an object the repository
901 // already held aborted the ingest and dropped the
902 // connection with `client_told=false`, so the client saw
903 // "send-pack: unexpected disconnect" and no reason at all.
904 // It bit `bare_lifecycle` and `endurance` on all four
905 // znippy columns of the bench, and it bites any real
906 // `--force` push that resends a known object.
907 //
908 // The guard's own doc three lines up already said what it
909 // meant to do — "An oid already present with identical
910 // facts is a no-op (a re-pushed pack repeats objects, and
911 // that is normal)" — so the code contradicted its contract.
912 //
913 // What the original comparison was protecting is still
914 // protected, and by a stronger argument: we KEEP THE OLD
915 // ROW. The archive is append-only and `gc` truncates
916 // nothing, so the offset and delta base already recorded
917 // still address bytes that are still there. The projection
918 // therefore never carries a base the archive did not write
919 // — the first writer's placement stands, forever.
920 //
921 // What CANNOT be waved through is a disagreement about the
922 // object itself. Same oid, different type or different
923 // inflated size, means either a hash collision or a
924 // corrupted row, and no push may quietly overwrite that.
925 // …and `obj_type` is a REPRESENTATION, not the object's
926 // kind. `ofs-delta` and `ref-delta` say how these bytes are
927 // ENCODED; what the object IS lives at the end of the delta
928 // chain. The same oid legitimately arrives whole in one
929 // pack and delta-encoded in another, so comparing those two
930 // is comparing encodings and calling them different
931 // objects. MEASURED here, `gunnar.import_export`, znippy:
932 //
933 // 6da81e5e… is already stored as (…, ofs-delta, size 2084)
934 // and cannot be redeclared as (…, tree, size 2084)
935 //
936 // — the inflated size agrees to the byte, which is the
937 // identity; only the encoding moved. The refusal poisoned
938 // the repository (`it stays un-indexed; reads will keep
939 // falling back`) and every later read answered `the
940 // repository is unavailable; try again`.
941 //
942 // So a type disagreement is a violation only when BOTH
943 // rows name a real object kind. The inflated size is
944 // compared unconditionally, and it is the half that
945 // actually catches a collision or a corrupt row.
946 let encoded = |t: ObjType| matches!(t, ObjType::OfsDelta | ObjType::RefDelta);
947 let kind_disagrees = !encoded(old.obj_type)
948 && !encoded(e.obj_type)
949 && old.obj_type != e.obj_type;
950 if kind_disagrees || old.uncompressed_size != e.uncompressed_size {
951 bail!(
952 "identity violation: {} is already stored as \
953 (offset {}, len {}, {}, size {}, delta_base {}) and cannot be \
954 redeclared as (offset {}, len {}, {}, size {}, delta_base {}) — \
955 the TYPE or the inflated SIZE differs, so the same oid is \
956 describing a different object. Placement (offset, len, \
957 delta_base) may differ freely: a re-pushed pack lands its \
958 copies elsewhere and the first writer's row stands.",
959 hex::encode(&e.oid),
960 old.offset,
961 old.len,
962 old.obj_type.as_str(),
963 old.uncompressed_size,
964 old.delta_base,
965 e.offset,
966 e.len,
967 e.obj_type.as_str(),
968 e.uncompressed_size,
969 e.delta_base,
970 );
971 }
972 continue;
973 }
974 objects.insert(e.oid.as_slice(), encode_row(seq, e).as_slice())?;
975 seq += 1;
976 added += 1;
977 bytes += e.len;
978 added_size += e.uncompressed_size;
979 added_types[e.obj_type.code() as usize] += 1;
980 }
981 meta.insert(META_ARRIVAL_SEQ, seq)?;
982 // Only a genuine insertion changes the oid set, so the content epoch
983 // holds still across a re-pushed pack that adds nothing (added == 0).
984 // The bump rides this same transaction, so the epoch and the rows it
985 // describes commit or roll back together.
986 if added > 0 {
987 let epoch = meta.get(META_TAIL_EPOCH)?.map(|v| v.value()).unwrap_or(0);
988 meta.insert(META_TAIL_EPOCH, epoch + 1)?;
989 }
990 }
991 w.commit()?;
992
993 self.total_rows.fetch_add(added, Ordering::AcqRel);
994 self.unabsorbed_rows.fetch_add(added, Ordering::AcqRel);
995 self.unabsorbed_size.fetch_add(added_size, Ordering::AcqRel);
996 for (slot, n) in self.unabsorbed_types.iter().zip(added_types) {
997 slot.fetch_add(n, Ordering::AcqRel);
998 }
999 let total_bytes = self.tail_bytes.fetch_add(bytes, Ordering::AcqRel) + bytes;
1000 drop(guard);
1001 if let Some(threshold) = self.volume_threshold()
1002 && total_bytes >= threshold
1003 {
1004 self.rebuild()?;
1005 return Ok(Some(RebuildReason::TailBytes(total_bytes)));
1006 }
1007 self.maybe_rebuild()
1008 }
1009
1010 /// Which threshold, if any, is currently tripped. Pure read; a caller on a
1011 /// read-only repository can poll this from its own maintenance tick.
1012 pub fn rebuild_due(&self) -> Option<RebuildReason> {
1013 let hits = self.tail_hits.load(Ordering::Acquire);
1014 if let Some(threshold) = self.triggers.miss_threshold(self.projection_len() as u64)
1015 && hits >= threshold
1016 {
1017 return Some(RebuildReason::TailHits(hits));
1018 }
1019 let bytes = self.tail_bytes.load(Ordering::Acquire);
1020 if let Some(threshold) = self.volume_threshold()
1021 && bytes >= threshold
1022 {
1023 return Some(RebuildReason::TailBytes(bytes));
1024 }
1025 None
1026 }
1027
1028 /// The volume trigger's threshold right now, or `None` when it is off.
1029 ///
1030 /// The fixed floor everywhere; on a persisting stack, the larger of the
1031 /// floor and `fold_ratio ×` the persisted index's current size — so a fold
1032 /// on a big repository is paid for by a proportionate amount of new data,
1033 /// and the sum of index writes over an import stays ≈ the bytes imported.
1034 /// See [`RebuildTriggers::DEFAULT_FOLD_RATIO`].
1035 fn volume_threshold(&self) -> Option<u64> {
1036 let floor = self.triggers.tail_bytes;
1037 if floor == 0 {
1038 return None;
1039 }
1040 if !self.persists() || self.triggers.fold_ratio <= 0.0 {
1041 return Some(floor);
1042 }
1043 let index_bytes = self.projection.read().expect("projection lock").ipc_bytes() as f64;
1044 Some(floor.max((self.triggers.fold_ratio * index_bytes) as u64))
1045 }
1046
1047 /// Rebuild if [`rebuild_due`](Self::rebuild_due) says so.
1048 pub fn maybe_rebuild(&self) -> Result<Option<RebuildReason>> {
1049 match self.rebuild_due() {
1050 Some(reason) => {
1051 self.rebuild()?;
1052 Ok(Some(reason))
1053 }
1054 None => Ok(None),
1055 }
1056 }
1057
1058 /// Rebuild the Arrow/stree projection from a full ordered scan of the tail
1059 /// and swap it in, resetting the counters and bumping the generation.
1060 ///
1061 /// The old projection stays live for every reader until the swap; the write
1062 /// lock is held only for the pointer store, not for the build.
1063 pub fn rebuild(&self) -> Result<()> {
1064 let _g = self.fold.lock().map_err(|_| anyhow!("the fold lock is poisoned"))?;
1065 self.fold_locked(None).map(|_| ())
1066 }
1067
1068 /// **The fold** — a rebuild that, on a persisting stack, also writes the
1069 /// projection down and empties redb of what it wrote. Caller holds
1070 /// [`fold`](Self::fold).
1071 ///
1072 /// The merge is of two disjoint sorted runs: the current projection (which,
1073 /// on a persisting stack, IS the file's content — the invariant every fold
1074 /// re-establishes) and the rows redb holds right now. On a non-persisting
1075 /// stack redb holds every row and the projection is not consulted, which is
1076 /// the pre-2026-09-04 rebuild unchanged.
1077 ///
1078 /// `live`, when given, is applied to the **projection's** rows only: those
1079 /// are all older than the reachability set `retain`'s caller computed, so
1080 /// the predicate has an answer for each of them. The redb rows are never
1081 /// filtered here — `retain` has already removed the dead ones under this
1082 /// same lock, and any row that landed after the caller's scan is one the
1083 /// predicate does not know and must not be asked about (`gc`'s closure
1084 /// answers `false` for an oid it never saw).
1085 ///
1086 /// Durability order, and why it is that order: (1) the new index goes to
1087 /// `objects.index.tmp`, is fsynced, and is renamed over `objects.index`;
1088 /// (2) the rows it holds are removed from redb. A crash after (1) and
1089 /// before (2) leaves them in both — reconciled at the next open. A crash
1090 /// during (1) leaves a `.tmp` nobody reads and a redb that still has every
1091 /// row. Nothing is ever lost, because a row leaves redb only after the
1092 /// rename that made it durable elsewhere has returned.
1093 ///
1094 /// Returns how many projection rows `live` rejected.
1095 fn fold_locked(&self, live: Option<&dyn Fn(&[u8]) -> bool>) -> Result<u64> {
1096 let unfolded = scan(&self.tail)?;
1097 let mut dropped = 0u64;
1098 let mut merged: Vec<IndexEntry>;
1099 if self.persists() {
1100 let cur = self.projection.read().expect("projection lock").clone();
1101 merged = Vec::with_capacity(cur.len() + unfolded.len());
1102 for o in 0..cur.len() as u32 {
1103 let e = cur.entry_at(o);
1104 if live.is_none_or(|f| f(&e.oid)) {
1105 merged.push(e);
1106 } else {
1107 dropped += 1;
1108 }
1109 }
1110 } else {
1111 merged = Vec::with_capacity(unfolded.len());
1112 }
1113 let forget: Vec<Vec<u8>> = if self.persists() {
1114 unfolded.iter().map(|e| e.oid.clone()).collect()
1115 } else {
1116 Vec::new()
1117 };
1118 merged.extend(unfolded);
1119 let total = merged.len() as u64;
1120 let fresh = Arc::new(S::build(&merged)?);
1121 drop(merged);
1122
1123 if let Some(path) = self.persisted.as_deref() {
1124 write_atomic(path, fresh.persisted_bytes().expect("PERSISTS arm"))?;
1125 if !forget.is_empty() {
1126 let w = self.tail.begin_write()?;
1127 {
1128 let mut objects = w.open_table(OBJECTS)?;
1129 for oid in &forget {
1130 objects.remove(oid.as_slice())?;
1131 }
1132 }
1133 w.commit()?;
1134 }
1135 self.folds.fetch_add(1, Ordering::AcqRel);
1136 }
1137
1138 *self
1139 .projection
1140 .write()
1141 .map_err(|_| anyhow!("the projection lock is poisoned"))? = fresh;
1142 self.total_rows.store(total, Ordering::Release);
1143 self.tail_hits.store(0, Ordering::Release);
1144 self.tail_bytes.store(0, Ordering::Release);
1145 self.unabsorbed_size.store(0, Ordering::Release);
1146 self.unabsorbed_rows.store(0, Ordering::Release);
1147 self.tail_txns.store(0, Ordering::Release);
1148 for slot in &self.unabsorbed_types {
1149 slot.store(0, Ordering::Release);
1150 }
1151 self.rebuilds.fetch_add(1, Ordering::AcqRel);
1152 self.generation.fetch_add(1, Ordering::AcqRel);
1153 Ok(dropped)
1154 }
1155
1156 pub fn stats(&self) -> StackStats {
1157 StackStats {
1158 sealed_rows: self.projection_len() as u64,
1159 total_rows: self.total_rows.load(Ordering::Acquire),
1160 tail_hits: self.tail_hits.load(Ordering::Acquire),
1161 absent: self.absent.load(Ordering::Acquire),
1162 tail_bytes: self.tail_bytes.load(Ordering::Acquire),
1163 rebuilds: self.rebuilds.load(Ordering::Acquire),
1164 folds: self.folds.load(Ordering::Acquire),
1165 persisted_loads: self.persisted_loads.load(Ordering::Acquire),
1166 generation: self.generation.load(Ordering::Acquire),
1167 unabsorbed_rows: self.unabsorbed_rows.load(Ordering::Acquire),
1168 tail_txns: self.tail_txns.load(Ordering::Acquire),
1169 }
1170 }
1171
1172 /// Rows in the projection right now. `stats().total_rows - this` is the
1173 /// un-absorbed tail.
1174 pub fn projection_len(&self) -> usize {
1175 self.projection.read().expect("projection lock").len()
1176 }
1177
1178 /// Every oid in the repository, in oid order — which is ordinal order, since
1179 /// redb keys the tail by the raw oid.
1180 ///
1181 /// A full scan, and it is here for the one caller that genuinely needs the
1182 /// whole set at once: a GC, which has to name what is *not* live.
1183 pub fn oids_in_order(&self) -> Result<Vec<Vec<u8>>> {
1184 let read = self.tail.begin_read()?;
1185 let objects = read.open_table(OBJECTS)?;
1186 let mut tail = Vec::with_capacity(objects.len()? as usize);
1187 for row in objects.iter()? {
1188 let (k, _) = row?;
1189 tail.push(k.value().to_vec());
1190 }
1191 if !self.persists() {
1192 // redb holds every row, so its key order is the whole answer.
1193 return Ok(tail);
1194 }
1195 // Two sorted runs — the persisted projection and the redb tail — merged
1196 // once. Disjoint by the fold's invariant; the equal-key arm below exists
1197 // for the window inside a fold where the file is renamed and redb has
1198 // not yet forgotten, so a caller in that window counts each oid once.
1199 let cur = self.projection.read().expect("projection lock").clone();
1200 let n = cur.len();
1201 let mut out = Vec::with_capacity(n + tail.len());
1202 let (mut i, mut j) = (0usize, 0usize);
1203 while i < n || j < tail.len() {
1204 let a = (i < n).then(|| cur.oid_at(i as u32));
1205 let b = tail.get(j).map(Vec::as_slice);
1206 match (a, b) {
1207 (Some(a), Some(b)) => match a.as_slice().cmp(b) {
1208 std::cmp::Ordering::Less => {
1209 out.push(a);
1210 i += 1;
1211 }
1212 std::cmp::Ordering::Greater => {
1213 out.push(b.to_vec());
1214 j += 1;
1215 }
1216 std::cmp::Ordering::Equal => {
1217 out.push(a);
1218 i += 1;
1219 j += 1;
1220 }
1221 },
1222 (Some(a), None) => {
1223 out.push(a);
1224 i += 1;
1225 }
1226 (None, Some(b)) => {
1227 out.push(b.to_vec());
1228 j += 1;
1229 }
1230 (None, None) => break,
1231 }
1232 }
1233 Ok(out)
1234 }
1235
1236 /// **The ordinal space** — every oid in the repository, back to back, in oid
1237 /// (= ordinal) order — and where each part came from.
1238 ///
1239 /// This replaces the `objects.ordinal` memo (S-082). That file existed to
1240 /// spare a cold open the full redb scan it took to rebuild the sorted oid
1241 /// list; the scan no longer exists to be spared. On a persisting stack the
1242 /// folded oids ARE the projection's oid column — one contiguous copy out of
1243 /// the mapped buffer — and only the redb tail (the rows since the last fold)
1244 /// is scanned and merged in. `folded_rows` / `tail_rows` say which is which,
1245 /// so a test can assert the space came off the index and not off a scan
1246 /// without comparing two byte strings that are equal either way.
1247 ///
1248 /// Same overlap note as [`oids_in_order`](Self::oids_in_order): inside a
1249 /// fold's rename-then-forget window an oid may sit in both layers for a
1250 /// moment; the merge emits it once.
1251 pub fn oids_raw(&self) -> Result<OrdinalSpace> {
1252 let read = self.tail.begin_read()?;
1253 let objects = read.open_table(OBJECTS)?;
1254 let mut tail: Vec<Vec<u8>> = Vec::with_capacity(objects.len()? as usize);
1255 for row in objects.iter()? {
1256 let (k, _) = row?;
1257 tail.push(k.value().to_vec());
1258 }
1259 let tail_rows = tail.len() as u64;
1260 if !self.persists() {
1261 let mut raw = Vec::with_capacity(tail.iter().map(Vec::len).sum());
1262 for oid in &tail {
1263 raw.extend_from_slice(oid);
1264 }
1265 return Ok(OrdinalSpace { raw, folded_rows: 0, tail_rows });
1266 }
1267 let cur = self.projection.read().expect("projection lock").clone();
1268 let folded = cur.oids_raw();
1269 let folded_rows = cur.len() as u64;
1270 if tail.is_empty() {
1271 return Ok(OrdinalSpace { raw: folded, folded_rows, tail_rows: 0 });
1272 }
1273 let w = match tail.first() {
1274 Some(o) => o.len(),
1275 None => unreachable!("tail is non-empty"),
1276 };
1277 let mut raw = Vec::with_capacity(folded.len() + tail.len() * w);
1278 let (mut i, mut j) = (0usize, 0usize);
1279 let n = folded.len() / w;
1280 while i < n || j < tail.len() {
1281 let a = (i < n).then(|| &folded[i * w..(i + 1) * w]);
1282 let b = tail.get(j).map(Vec::as_slice);
1283 match (a, b) {
1284 (Some(a), Some(b)) => match a.cmp(b) {
1285 std::cmp::Ordering::Less => {
1286 raw.extend_from_slice(a);
1287 i += 1;
1288 }
1289 std::cmp::Ordering::Greater => {
1290 raw.extend_from_slice(b);
1291 j += 1;
1292 }
1293 std::cmp::Ordering::Equal => {
1294 raw.extend_from_slice(a);
1295 i += 1;
1296 j += 1;
1297 }
1298 },
1299 (Some(a), None) => {
1300 raw.extend_from_slice(a);
1301 i += 1;
1302 }
1303 (None, Some(b)) => {
1304 raw.extend_from_slice(b);
1305 j += 1;
1306 }
1307 (None, None) => break,
1308 }
1309 }
1310 Ok(OrdinalSpace { raw, folded_rows, tail_rows })
1311 }
1312
1313 /// `(tail_epoch, object_count)` in one read transaction — the two cheap facts
1314 /// that say whether the oid set has moved, **without** the ordered scan
1315 /// [`Self::oids_in_order`] pays. (They were the `objects.ordinal` memo's
1316 /// validity key until that file went, 2026-09-04.) `epoch` is [`META_TAIL_EPOCH`] (0 when a
1317 /// store predates it, e.g. was populated by an older binary); the count is
1318 /// redb's own O(1) table length. Both are metadata reads — a point lookup and
1319 /// a stored length — so this is microseconds against the scan's hundreds of
1320 /// milliseconds, which is the whole reason the memo can exist.
1321 pub fn tail_epoch_and_len(&self) -> Result<(u64, u64)> {
1322 let read = self.tail.begin_read()?;
1323 let meta = read.open_table(META)?;
1324 let epoch = meta.get(META_TAIL_EPOCH)?.map(|v| v.value()).unwrap_or(0);
1325 let objects = read.open_table(OBJECTS)?;
1326 // On a persisting stack the repository is the persisted projection plus
1327 // the redb tail, disjoint. Inside a fold's rename-then-forget window the
1328 // two overlap for a moment and this over-counts; the memo it validates
1329 // then reads as stale and re-scans, and the next call is exact again —
1330 // a transient re-scan, never a wrong answer, and not worth taking the
1331 // fold lock on the read path for.
1332 let folded = if self.persists() { self.projection_len() as u64 } else { 0 };
1333 Ok((epoch, objects.len()? + folded))
1334 }
1335
1336 /// **Which of these archive extents already have rows here** — the
1337 /// crash-recovery diff, and the reason §13.12's `indexed` bit is derived
1338 /// rather than stored.
1339 ///
1340 /// A pack is unabsorbed **iff its extent is in the journal and its rows are
1341 /// not in the index**. Both of those are already durable — the journal is
1342 /// fsynced on the ack path, the tail is a redb commit — so the bit is a diff
1343 /// of two durable facts and there is nothing for it to drift from. A *stored*
1344 /// bit would be a third fact that can disagree with the two it describes,
1345 /// which is precisely how a pack ends up marked absorbed with no rows behind
1346 /// it: fast and wrong, in the one direction (`absent`) that loses a client's
1347 /// objects during negotiation.
1348 ///
1349 /// `out[i]` answers `extents[i]`. **The tail and not the projection**, because
1350 /// the projection is a snapshot of a prefix of the tail and the question is
1351 /// about what survived the crash.
1352 ///
1353 /// # Cost
1354 ///
1355 /// One ordered scan of the tail that **stops the moment every extent has been
1356 /// hit**. Rows arrive in oid order, which is uncorrelated with the pack an
1357 /// object came from, so a store whose packs are all absorbed answers after a
1358 /// few rows per pack rather than after a full scan — the coupon-collector
1359 /// case, and the one every clean reopen takes. The scan only runs to the end
1360 /// when some pack genuinely has **no** rows, which is exactly the case where
1361 /// the caller is about to re-resolve that whole pack anyway.
1362 ///
1363 /// MEASURED on oden 2026-08-08, release, file-backed redb tail, 1-minute
1364 /// loadavg 7.1–7.5 (another tenant's work — read these as ratios):
1365 ///
1366 /// | packs | rows | every pack absorbed | one pack un-absorbed |
1367 /// |---:|---:|---:|---:|
1368 /// | 1 000 | 1 000 000 | **0.77 ms** | 120.6 ms |
1369 /// | 10 000 | 1 000 000 | **11.7 ms** | 141.8 ms |
1370 /// | 1 000 | 3 686 | **0.40 ms** | 0.39 ms |
1371 ///
1372 /// The first column is what a clean reopen pays and it is bounded by the
1373 /// **pack** count, not the object count — 1000 packs over a million objects
1374 /// costs the same order as 1000 packs over four thousand. The second column
1375 /// is the crash path, where the scan runs to the end: 120 ms over a million
1376 /// rows, against the ~160 ms a *single* 2687-object pack takes to re-absorb
1377 /// (`a_push_ends_in_object_rows_and_no_read_asked_for_them`). The diff is
1378 /// therefore never the expensive half of a recovery.
1379 /// The third row is the shape
1380 /// [`crate::git_ops::tests::the_derive_on_open_diff_costs_milliseconds_at_a_realistic_pack_count`]
1381 /// asserts on every run, over real pushed packs rather than synthetic rows.
1382 ///
1383 /// A zero-length extent has no rows by construction and is answered `false`
1384 /// without looking.
1385 pub fn extents_with_rows(&self, extents: &[(u64, u64)]) -> Result<Vec<bool>> {
1386 let mut hit = vec![false; extents.len()];
1387 // Sorted by start, so one row is placed with a binary search instead of a
1388 // pass over every extent: the scan below is the hot loop.
1389 let mut order: Vec<usize> = (0..extents.len()).filter(|&i| extents[i].1 > 0).collect();
1390 order.sort_unstable_by_key(|&i| extents[i].0);
1391 let starts: Vec<u64> = order.iter().map(|&i| extents[i].0).collect();
1392 let mut wanted = order.len();
1393 if wanted == 0 {
1394 return Ok(hit);
1395 }
1396
1397 // One row's offset, placed against the sorted extents. `true` when every
1398 // extent has now been hit and the scan can stop.
1399 let mut place = |offset: u64| -> bool {
1400 // The last extent that starts at or before this row.
1401 let p = starts.partition_point(|&s| s <= offset);
1402 if p == 0 {
1403 return false;
1404 }
1405 let i = order[p - 1];
1406 let (start, len) = extents[i];
1407 if offset < start + len && !hit[i] {
1408 hit[i] = true;
1409 wanted -= 1;
1410 }
1411 wanted == 0
1412 };
1413
1414 // The persisted projection first: on a persisting stack it holds every
1415 // row older than the last fold, which is nearly all of them, and its
1416 // offset column is one contiguous `u64` run — the cheapest scan there
1417 // is. Then the redb tail for the rows that arrived since.
1418 if self.persists() {
1419 let cur = self.projection.read().expect("projection lock").clone();
1420 for o in 0..cur.len() as u32 {
1421 if place(cur.extent_at(o).0) {
1422 return Ok(hit);
1423 }
1424 }
1425 }
1426 let read = self.tail.begin_read()?;
1427 let objects = read.open_table(OBJECTS)?;
1428 for row in objects.iter()? {
1429 let (_, v) = row?;
1430 if place(decode_row(v.value())?.offset) {
1431 break;
1432 }
1433 }
1434 Ok(hit)
1435 }
1436
1437 /// **Drop every row `live` rejects.** Returns how many rows went.
1438 ///
1439 /// This is the one operation that is not append-only, and it exists for
1440 /// exactly one caller: `GitOps::gc`, which computes reachability and then has
1441 /// to remove what is unreachable *before* base znippy compacts the payload —
1442 /// so that base's notion of "live" already means what git means (§13.20).
1443 ///
1444 /// The projection is rebuilt inside the same call. It has to be: it is a
1445 /// snapshot of a tail that no longer says the same thing, and a stree that
1446 /// still answers for a deleted oid would be **wrong** rather than merely
1447 /// incomplete, which is the one failure this design does not tolerate.
1448 pub fn retain(&self, live: &dyn Fn(&[u8]) -> bool) -> Result<u64> {
1449 // The whole of `retain` — the redb pass, the fold that rewrites the
1450 // persisted index without the dead rows, and the epoch bump — under one
1451 // hold of the fold lock, so no fold can interleave and re-persist a row
1452 // this call is removing.
1453 let _g = self.fold.lock().map_err(|_| anyhow!("the fold lock is poisoned"))?;
1454 let w = self.tail.begin_write()?;
1455 let mut dropped = 0u64;
1456 {
1457 let mut objects = w.open_table(OBJECTS)?;
1458 let dead: Vec<Vec<u8>> = objects
1459 .iter()?
1460 .filter_map(|row| row.ok())
1461 .filter(|(k, _)| !live(k.value()))
1462 .map(|(k, _)| k.value().to_vec())
1463 .collect();
1464 for oid in &dead {
1465 objects.remove(oid.as_slice())?;
1466 dropped += 1;
1467 }
1468 // A removal changes the oid set exactly as an insert does, so the
1469 // content epoch must record it: bump it in this same transaction
1470 // when anything actually went. `arrival_seq` is deliberately not
1471 // touched — it is an *arrival* ordinal, not a live count.
1472 if dropped > 0 {
1473 let mut meta = w.open_table(META)?;
1474 let epoch = meta.get(META_TAIL_EPOCH)?.map(|v| v.value()).unwrap_or(0);
1475 meta.insert(META_TAIL_EPOCH, epoch + 1)?;
1476 }
1477 }
1478 w.commit()?;
1479 // The persisted projection's dead rows go in the fold: it rewrites the
1480 // index from `live` rows only and swaps it in, so a stree that answered
1481 // for a deleted oid a moment ago answers `None` now. On a persisting
1482 // stack this is where most of what dies actually goes — those rows were
1483 // never in redb — so the epoch is bumped again for them. (`len` in the
1484 // memo's `(epoch, len)` key would catch it too; the bump keeps the
1485 // contract that a changed oid set is a changed epoch.)
1486 let folded_dead = self.fold_locked(Some(live))?;
1487 if folded_dead > 0 {
1488 let w = self.tail.begin_write()?;
1489 {
1490 let mut meta = w.open_table(META)?;
1491 let epoch = meta.get(META_TAIL_EPOCH)?.map(|v| v.value()).unwrap_or(0);
1492 meta.insert(META_TAIL_EPOCH, epoch + 1)?;
1493 }
1494 w.commit()?;
1495 }
1496 Ok(dropped + folded_dead)
1497 }
1498
1499 /// One tail read transaction serving `oids`, filling only the slots that are
1500 /// still `None`. This is the fall-through, and it is a single transaction for
1501 /// the whole batch on purpose — skade's `resolve_many` phase 2.
1502 ///
1503 /// **Not called at all when the projection is complete** — see
1504 /// [`projection_is_complete`](Self::projection_is_complete). The unfilled
1505 /// slots are then genuine absences and are counted as such by
1506 /// [`absences_only`](Self::absences_only).
1507 fn fill_from_tail(&self, oids: &[&[u8]], out: &mut [Option<IndexRow>]) -> Result<()> {
1508 self.tail_txns.fetch_add(1, Ordering::AcqRel);
1509 let read = self.tail.begin_read()?;
1510 let objects = read.open_table(OBJECTS)?;
1511 let mut hits = 0u64;
1512 let mut absent = 0u64;
1513 for (slot, oid) in out.iter_mut().zip(oids) {
1514 if slot.is_some() {
1515 continue;
1516 }
1517 match objects.get(*oid)? {
1518 Some(v) => {
1519 *slot = Some(decode_row(v.value())?.as_index_row());
1520 hits += 1;
1521 }
1522 None => absent += 1,
1523 }
1524 }
1525 self.tail_hits.fetch_add(hits, Ordering::AcqRel);
1526 self.absent.fetch_add(absent, Ordering::AcqRel);
1527 Ok(())
1528 }
1529
1530 /// Count the unfilled slots as absences without asking the tail. Only ever
1531 /// called when [`projection_is_complete`](Self::projection_is_complete),
1532 /// where "the projection does not have it" and "the repository does not have
1533 /// it" are the same statement.
1534 fn absences_only(&self, out: &[Option<IndexRow>]) {
1535 let n = out.iter().filter(|s| s.is_none()).count() as u64;
1536 self.absent.fetch_add(n, Ordering::AcqRel);
1537 }
1538}
1539
1540/// Every row in the tail, in oid order, as index entries. redb's B-tree is
1541/// already ordered by the raw oid key, so this hands `S::build` its rows in the
1542/// order it wants them and the sort inside is a no-op scan.
1543fn scan(db: &Database) -> Result<Vec<IndexEntry>> {
1544 let read = db.begin_read()?;
1545 let objects = read.open_table(OBJECTS)?;
1546 let mut out = Vec::with_capacity(objects.len()? as usize);
1547 for row in objects.iter()? {
1548 let (k, v) = row?;
1549 out.push(decode_row(v.value())?.as_entry(k.value()));
1550 }
1551 Ok(out)
1552}
1553
1554// ── the persisted projection on disk ─────────────────────────────────────────
1555
1556/// `objects.index` → `objects.index.tmp`: where a fold writes before its rename.
1557fn tmp_path(path: &Path) -> PathBuf {
1558 let mut name = path.file_name().map(|n| n.to_os_string()).unwrap_or_default();
1559 name.push(".tmp");
1560 path.with_file_name(name)
1561}
1562
1563/// Write `bytes` so that `path` either holds all of them or is untouched:
1564/// sibling temp file, fsync, rename, fsync the directory. The same idiom
1565/// every durable writer in this crate uses, spelled once here for the index.
1566fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
1567 let tmp = tmp_path(path);
1568 {
1569 let mut f = File::create(&tmp)
1570 .with_context(|| format!("creating {}", tmp.display()))?;
1571 f.write_all(bytes)
1572 .with_context(|| format!("writing {} bytes to {}", bytes.len(), tmp.display()))?;
1573 f.sync_all()
1574 .with_context(|| format!("syncing {}", tmp.display()))?;
1575 }
1576 fs::rename(&tmp, path)
1577 .with_context(|| format!("renaming {} over {}", tmp.display(), path.display()))?;
1578 if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
1579 File::open(dir)
1580 .and_then(|d| d.sync_all())
1581 .with_context(|| format!("syncing the directory of {}", path.display()))?;
1582 }
1583 Ok(())
1584}
1585
1586/// Dead redb pages worth compacting for: below this an open leaves the file
1587/// alone, because a small repository's redb is a few pages either way and a
1588/// rewrite of it on every open would be churn for nothing. Compaction costs
1589/// what is LIVE, not what is dead — after a fold that is little — so the floor
1590/// is set by the file lying about the store, not by the work: 16 MiB is a redb
1591/// that has doubled itself four times past a few thousand live rows.
1592const COMPACT_FRAGMENTED_FLOOR: u64 = 16 << 20;
1593
1594/// Shrink redb's file when it holds at least [`COMPACT_FRAGMENTED_FLOOR`] of dead
1595/// pages. Needs the `Database` owned and no transaction open, which is true at
1596/// exactly one place — the open — and nowhere else; that is why it lives here
1597/// rather than behind a maintain verb. Any failure is logged and swallowed: the
1598/// file being large is not a fault, and a store must open over it regardless.
1599fn compact_if_fragmented(db: &mut Database, tail_path: Option<&Path>) {
1600 let Some(tail_path) = tail_path else { return }; // in-memory: nothing on disk to shrink
1601 let file_len = match fs::metadata(tail_path) {
1602 Ok(m) => m.len(),
1603 Err(_) => return,
1604 };
1605 // `stats()` lives on the write transaction in redb 2; this one is opened to
1606 // be read and aborted, never committed. The dead-page figure is taken from
1607 // the FILE, not from `fragmented_bytes()`: right after a commit that freed
1608 // pages (the migration fold's `delete_table`) redb still has them PENDING
1609 // — two-phase free — and reports them as neither stored nor fragmented.
1610 // MEASURED: the same 34 MB file read `fragmented = 12.8 MiB` inside the
1611 // open and `33 MB` from a fresh handle a moment later. The file length
1612 // minus what is stored is what the store is actually lying by, and
1613 // `compact()` begins with the two commits that turn pending into free.
1614 let (stored, metadata) = {
1615 let w = match db.begin_write() {
1616 Ok(w) => w,
1617 Err(e) => {
1618 tracing::debug!(target: "znippy.git", error = %e, "redb stats unavailable; not compacting");
1619 return;
1620 }
1621 };
1622 let s = w.stats().map(|s| (s.stored_bytes(), s.metadata_bytes()));
1623 let _ = w.abort();
1624 match s {
1625 Ok(s) => s,
1626 Err(e) => {
1627 tracing::debug!(target: "znippy.git", error = %e, "redb stats unavailable; not compacting");
1628 return;
1629 }
1630 }
1631 };
1632 let dead = file_len.saturating_sub(stored + metadata);
1633 if dead < COMPACT_FRAGMENTED_FLOOR {
1634 return;
1635 }
1636 let t = std::time::Instant::now();
1637 match db.compact() {
1638 Ok(did) => tracing::info!(
1639 target: "znippy.git",
1640 file_before = file_len,
1641 file_after = fs::metadata(tail_path).map(|m| m.len()).unwrap_or(0),
1642 stored_bytes = stored,
1643 compacted = did,
1644 ms = t.elapsed().as_millis(),
1645 tail = %tail_path.display(),
1646 "compacted the redb object tail: it was carrying dead pages from before a fold"
1647 ),
1648 Err(e) => tracing::warn!(
1649 target: "znippy.git",
1650 file_len,
1651 stored_bytes = stored,
1652 error = %e,
1653 "could not compact the redb object tail; it keeps its size, nothing else changes"
1654 ),
1655 }
1656}
1657
1658/// The persisted index as an Arrow [`Buffer`] — `None` when there is none.
1659///
1660/// Memory-mapped where the platform allows it, so the columns
1661/// [`ObjectIndex::from_persisted`] decodes are views into the page cache: the
1662/// index costs resident memory only for the pages a lookup touches, and none
1663/// of it is a copy. Elsewhere the file is read into an aligned buffer, which
1664/// keeps every other property (no rebuild, no redb scan) and gives up only
1665/// the sharing.
1666fn map_persisted(path: &Path) -> Result<Option<Buffer>> {
1667 let f = match File::open(path) {
1668 Ok(f) => f,
1669 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1670 Err(e) => return Err(e).with_context(|| format!("opening {}", path.display())),
1671 };
1672 let len = f
1673 .metadata()
1674 .with_context(|| format!("stat {}", path.display()))?
1675 .len();
1676 if len == 0 {
1677 // A persisted projection is never zero bytes — even an empty index
1678 // carries its schema — so this is a truncated write, not an index.
1679 return Ok(None);
1680 }
1681 #[cfg(target_os = "linux")]
1682 {
1683 // SAFETY: the file is replaced only by `write_atomic`'s rename, which
1684 // swaps the directory entry and never writes into the inode this
1685 // mapping is over; the old inode lives until the last mapping drops.
1686 let map = unsafe { memmap2::Mmap::map(&f) }
1687 .with_context(|| format!("mapping {}", path.display()))?;
1688 let map = Arc::new(map);
1689 let ptr = std::ptr::NonNull::new(map.as_ptr() as *mut u8)
1690 .ok_or_else(|| anyhow!("mmap of {} returned a null pointer", path.display()))?;
1691 // SAFETY: `ptr` and `len` describe exactly the mapping `map` owns, and
1692 // `map` is kept alive inside the buffer as its allocation owner.
1693 let buf = unsafe { Buffer::from_custom_allocation(ptr, map.len(), map) };
1694 Ok(Some(buf))
1695 }
1696 #[cfg(not(target_os = "linux"))]
1697 {
1698 use std::io::Read;
1699 let mut f = f;
1700 let mut mb = znippy_common::arrow::buffer::MutableBuffer::with_capacity(len as usize);
1701 let mut v = Vec::with_capacity(len as usize);
1702 f.read_to_end(&mut v)
1703 .with_context(|| format!("reading {}", path.display()))?;
1704 mb.extend_from_slice(&v);
1705 Ok(Some(mb.into()))
1706 }
1707}
1708
1709/// The projection at `path`, or `None` if there is none — or if what is there
1710/// cannot be read as one. The second case is a warning and not an error on
1711/// purpose: the index is a cache the journal can rebuild, and a store that
1712/// refuses to open over a damaged cache would be a store that has stopped
1713/// answering over something it knows how to recover from. The rows redb still
1714/// holds are served; the rest are re-indexed from the journal by
1715/// `adopt_journal`, which sees them as "extent journaled, rows absent".
1716/// Returns the projection and two wall-clock figures in milliseconds: the map
1717/// (open + mmap, which is O(1)) and the decode-plus-stree build (which is O(n)
1718/// in the oid count and is the whole of a warm open's real cost). They are
1719/// logged by the open, so the number the next lever has to beat is in the
1720/// server log rather than in a bench nobody reruns.
1721fn load_persisted<S: ObjectIndex>(path: &Path) -> Option<(Arc<S>, u128, u128)> {
1722 let t0 = std::time::Instant::now();
1723 match map_persisted(path) {
1724 Ok(None) => None,
1725 Ok(Some(buf)) => {
1726 let map_ms = t0.elapsed().as_millis();
1727 let t1 = std::time::Instant::now();
1728 match S::from_persisted(buf) {
1729 Ok(s) => Some((Arc::new(s), map_ms, t1.elapsed().as_millis())),
1730 Err(e) => {
1731 tracing::warn!(
1732 target: "znippy.git",
1733 path = %path.display(),
1734 error = %format!("{e:#}"),
1735 "the persisted object index could not be read; opening without it and \
1736 re-indexing from the journal — slower until the drain catches up, \
1737 never wrong"
1738 );
1739 None
1740 }
1741 }
1742 }
1743 Err(e) => {
1744 tracing::warn!(
1745 target: "znippy.git",
1746 path = %path.display(),
1747 error = %format!("{e:#}"),
1748 "the persisted object index could not be opened; opening without it"
1749 );
1750 None
1751 }
1752 }
1753}
1754
1755impl<S: ObjectIndex> ObjectIndex for ObjectReadStack<S> {
1756 /// Build a stack over `entries` with an in-memory tail. Every entry lands in
1757 /// redb and the projection is built from it, so the stack starts fully
1758 /// absorbed — which is what makes it directly comparable with the two Arrow
1759 /// arms on the layout benchmark.
1760 fn build(entries: &[IndexEntry]) -> Result<Self> {
1761 let stack = Self::in_memory(RebuildTriggers::default())?;
1762 stack.append(entries)?;
1763 stack.rebuild()?;
1764 Ok(stack)
1765 }
1766
1767 /// Projection first; **a miss falls through to redb**, which always knows.
1768 /// `None` here means the tail said no, never that the stree said no.
1769 fn lookup(&self, oid: &[u8]) -> Option<IndexRow> {
1770 if let Some(row) = self.projection.read().expect("projection lock").lookup(oid) {
1771 return Some(row);
1772 }
1773 if self.projection_is_complete() {
1774 self.absent.fetch_add(1, Ordering::AcqRel);
1775 return None;
1776 }
1777 let mut out = [None];
1778 // A tail read that errors is reported as absent to this signature, which
1779 // cannot carry an error. Callers that need the distinction use
1780 // `stats().absent` against their own miss count, or `fill_from_tail`
1781 // through `lookup_batch`'s caller. A corrupt tail is a `rebuild()`
1782 // failure long before it is a wrong lookup.
1783 let _ = self.fill_from_tail(&[oid], &mut out);
1784 out[0]
1785 }
1786
1787 /// Two phases, and the second is not optional: one pipelined stree pass, then
1788 /// **one** redb transaction for every slot the projection left empty.
1789 fn lookup_batch(&self, oids: &[&[u8]]) -> Vec<Option<IndexRow>> {
1790 let mut out = self
1791 .projection
1792 .read()
1793 .expect("projection lock")
1794 .lookup_batch(oids);
1795 if out.iter().any(Option::is_none) {
1796 if self.projection_is_complete() {
1797 self.absences_only(&out);
1798 } else {
1799 let _ = self.fill_from_tail(oids, &mut out);
1800 }
1801 }
1802 out
1803 }
1804
1805 /// The floor path — oid → ordinal, no payload column touched. The tail is
1806 /// still consulted for the misses, because a miss is still not an answer;
1807 /// a tail-served ordinal carries [`TAIL_ORDINAL_BIT`].
1808 fn ordinals_batch(&self, oids: &[&[u8]]) -> Vec<Option<u32>> {
1809 let mut out = self
1810 .projection
1811 .read()
1812 .expect("projection lock")
1813 .ordinals_batch(oids);
1814 if out.iter().any(Option::is_none) {
1815 if self.projection_is_complete() {
1816 let n = out.iter().filter(|s| s.is_none()).count() as u64;
1817 self.absent.fetch_add(n, Ordering::AcqRel);
1818 return out;
1819 }
1820 let mut rows: Vec<Option<IndexRow>> = out
1821 .iter()
1822 .map(|o| {
1823 o.map(|ordinal| IndexRow {
1824 ordinal,
1825 offset: 0,
1826 len: 0,
1827 obj_type: ObjType::Blob,
1828 uncompressed_size: 0,
1829 delta_base: 0,
1830 })
1831 })
1832 .collect();
1833 let _ = self.fill_from_tail(oids, &mut rows);
1834 for (slot, row) in out.iter_mut().zip(&rows) {
1835 *slot = row.map(|r| r.ordinal);
1836 }
1837 }
1838 out
1839 }
1840
1841 /// The partial-row path — byte extent only.
1842 fn extents_batch(&self, oids: &[&[u8]]) -> Vec<Option<(u64, u64)>> {
1843 let mut out = self
1844 .projection
1845 .read()
1846 .expect("projection lock")
1847 .extents_batch(oids);
1848 if out.iter().any(Option::is_none) {
1849 if self.projection_is_complete() {
1850 let n = out.iter().filter(|s| s.is_none()).count() as u64;
1851 self.absent.fetch_add(n, Ordering::AcqRel);
1852 return out;
1853 }
1854 let mut rows: Vec<Option<IndexRow>> = out
1855 .iter()
1856 .map(|e| {
1857 e.map(|(offset, len)| IndexRow {
1858 ordinal: 0,
1859 offset,
1860 len,
1861 obj_type: ObjType::Blob,
1862 uncompressed_size: 0,
1863 delta_base: 0,
1864 })
1865 })
1866 .collect();
1867 let _ = self.fill_from_tail(oids, &mut rows);
1868 for (slot, row) in out.iter_mut().zip(&rows) {
1869 *slot = row.map(|r| (r.offset, r.len));
1870 }
1871 }
1872 out
1873 }
1874
1875 /// The quota gate, over the whole **repository** and without a scan of the
1876 /// tail: the projection's column scan plus the rows appended after it.
1877 /// Exact because the archive is append-only — a row the projection already
1878 /// holds can never gain or lose bytes, so the two terms cannot overlap.
1879 fn sum_uncompressed(&self) -> u64 {
1880 self.projection
1881 .read()
1882 .expect("projection lock")
1883 .sum_uncompressed()
1884 + self.unabsorbed_size.load(Ordering::Acquire)
1885 }
1886
1887 /// [`sum_uncompressed`](Self::sum_uncompressed)'s argument, per type.
1888 fn count_type(&self, t: ObjType) -> usize {
1889 self.projection
1890 .read()
1891 .expect("projection lock")
1892 .count_type(t)
1893 + self.unabsorbed_types[t.code() as usize].load(Ordering::Acquire) as usize
1894 }
1895
1896 fn name(&self) -> &'static str {
1897 "ObjectReadStack"
1898 }
1899
1900 /// Positional rows are the **projection's** rows. A tail ordinal carries
1901 /// [`TAIL_ORDINAL_BIT`] and is a row address in no table — asking for it
1902 /// positionally is the same programming error as indexing past `len()`,
1903 /// and fails the same way, out loud.
1904 fn oid_at(&self, ordinal: u32) -> Vec<u8> {
1905 assert!(!is_tail_ordinal(ordinal), "ordinal {ordinal:#x} is tail-served, not positional");
1906 self.projection.read().expect("projection lock").oid_at(ordinal)
1907 }
1908
1909 fn entry_at(&self, ordinal: u32) -> IndexEntry {
1910 assert!(!is_tail_ordinal(ordinal), "ordinal {ordinal:#x} is tail-served, not positional");
1911 self.projection.read().expect("projection lock").entry_at(ordinal)
1912 }
1913
1914 fn extent_at(&self, ordinal: u32) -> (u64, u64) {
1915 assert!(!is_tail_ordinal(ordinal), "ordinal {ordinal:#x} is tail-served, not positional");
1916 self.projection.read().expect("projection lock").extent_at(ordinal)
1917 }
1918
1919 /// Objects in the **repository**, not in the projection — the stack answers
1920 /// for the whole repository and this has to agree with what `lookup` will
1921 /// resolve. [`projection_len`](Self::projection_len) is the other number.
1922 fn len(&self) -> usize {
1923 self.total_rows.load(Ordering::Acquire) as usize
1924 }
1925
1926 fn ipc_bytes(&self) -> usize {
1927 self.projection.read().expect("projection lock").ipc_bytes()
1928 }
1929
1930 /// The projection's resident bytes. The tail is on disk (or in redb's own
1931 /// page cache) and is not counted here — counting it would make this
1932 /// incomparable with the two Arrow arms, which is the number's only use.
1933 fn resident_bytes(&self) -> usize {
1934 self.projection
1935 .read()
1936 .expect("projection lock")
1937 .resident_bytes()
1938 }
1939}
1940
1941#[cfg(test)]
1942mod tests {
1943 use super::*;
1944 use crate::arms::DEFAULT_REDB_CACHE_BYTES;
1945 use crate::index_layout::{FourTables, synthetic_entries};
1946
1947 type Stack = ObjectReadStack<OneTableFourColumns>;
1948
1949 /// A stack with the thresholds off, so a test decides when a rebuild happens.
1950 fn manual() -> Stack {
1951 Stack::in_memory(RebuildTriggers::manual()).expect("in-memory stack")
1952 }
1953
1954 /// Seal `sealed` into the projection, then append `tail` without absorbing
1955 /// it. The returned stack is deliberately in the state the whole design is
1956 /// about: a projection that is a strict subset of the truth.
1957 fn split_stack(sealed: &[IndexEntry], tail: &[IndexEntry]) -> Stack {
1958 let s = manual();
1959 s.append(sealed).expect("seal half");
1960 s.rebuild().expect("rebuild");
1961 s.append(tail).expect("append tail");
1962 s
1963 }
1964
1965 /// **The property the whole stack rests on: incomplete, never wrong.**
1966 ///
1967 /// The projection covers 300 of 500 objects. Asserted on applied output —
1968 /// every one of the five facts of all 500 rows, through both the serial and
1969 /// the batch path — plus the two things that would make the guard hollow:
1970 /// that the projection really is short (its own `lookup` misses all 200 tail
1971 /// objects, so the fall-through is genuinely being exercised) and that every
1972 /// projection hit is byte-identical to what a complete index built over all
1973 /// 500 returns.
1974 ///
1975 /// Seen RED by making `lookup_batch`'s phase 2 conditional on
1976 /// `out.iter().all(Option::is_none)` instead of `any` — i.e. falling through
1977 /// only when the projection answered nothing: "the stack lost tail object
1978 /// e50298d4cb83164fcbbd2a0d4fd99ebb43111e93, batch path". Restored.
1979 ///
1980 /// Seen RED a second time by returning `None` from `lookup` on a projection
1981 /// miss with no fall-through: "the stack lost tail object
1982 /// e50298d4cb83164fcbbd2a0d4fd99ebb43111e93, serial path". Restored.
1983 ///
1984 /// Seen RED a third time, for `delta_base`, by having [`encode_row`] write
1985 /// a literal `0` into bytes 33..41 — the shape of a column that exists in
1986 /// the struct and is never persisted: "the delta base of
1987 /// b2af964cd8a34795ce5ad114d4e2a2144cf2a6b1 did not survive the projection
1988 /// path / left: 0 / right: 6186". **Six** of this module's tests went red
1989 /// on that one edit, on both sides of the fall-through, which is what says
1990 /// the tail encoding is load-bearing rather than incidental. Restored.
1991 #[test]
1992 fn the_projection_is_incomplete_but_never_wrong() {
1993 let all = synthetic_entries(500, 20, 0xC0FFEE);
1994 let (sealed, tail) = all.split_at(300);
1995 let stack = split_stack(sealed, tail);
1996
1997 assert_eq!(stack.projection_len(), 300, "the projection must be short");
1998 assert_eq!(stack.len(), 500, "the stack answers for the repository");
1999 // The tail half has to carry delta bases, or the `delta_base` assertion
2000 // below is an assertion about zero on both sides of the fall-through.
2001 assert!(
2002 tail.iter().filter(|e| e.delta_base != 0).count() >= 10,
2003 "the un-absorbed half carries no delta bases — the tail encoding would be untested"
2004 );
2005
2006 // Two references. `sealed_only` is what the projection is *supposed* to
2007 // be — an index over exactly the 300 sealed objects, so its ordinals are
2008 // the ranks the projection must reproduce. `complete` is the index the
2009 // stack must be indistinguishable from on the five facts.
2010 let sealed_only = FourTables::build(sealed).expect("sealed-only index");
2011 let complete = FourTables::build(&all).expect("complete index");
2012
2013 // The projection alone must MISS every tail object — otherwise the
2014 // fall-through below proves nothing.
2015 {
2016 let proj = stack.projection.read().unwrap();
2017 for e in tail {
2018 assert!(
2019 proj.lookup(&e.oid).is_none(),
2020 "the projection already holds {} — this test would be vacuous",
2021 hex::encode(&e.oid)
2022 );
2023 }
2024 for e in sealed {
2025 assert!(
2026 proj.lookup(&e.oid).is_some(),
2027 "the projection lost sealed object {}",
2028 hex::encode(&e.oid)
2029 );
2030 }
2031 }
2032
2033 let refs: Vec<&[u8]> = all.iter().map(|e| e.oid.as_slice()).collect();
2034 let batched = stack.lookup_batch(&refs);
2035 for (i, e) in all.iter().enumerate() {
2036 let via_serial = stack.lookup(&e.oid).unwrap_or_else(|| {
2037 panic!(
2038 "the stack lost {} object {}, serial path",
2039 if i < 300 { "sealed" } else { "tail" },
2040 hex::encode(&e.oid)
2041 )
2042 });
2043 let via_batch = batched[i].unwrap_or_else(|| {
2044 panic!(
2045 "the stack lost {} object {}, batch path",
2046 if i < 300 { "sealed" } else { "tail" },
2047 hex::encode(&e.oid)
2048 )
2049 });
2050 assert_eq!(via_serial, via_batch, "serial and batch disagree");
2051
2052 // The five facts, against the entry that was appended.
2053 assert_eq!(via_serial.offset, e.offset);
2054 assert_eq!(via_serial.len, e.len);
2055 assert_eq!(via_serial.obj_type, e.obj_type);
2056 assert_eq!(via_serial.uncompressed_size, e.uncompressed_size);
2057 assert_eq!(
2058 via_serial.delta_base,
2059 e.delta_base,
2060 "the delta base of {} did not survive the {} path",
2061 hex::encode(&e.oid),
2062 if i < 300 { "projection" } else { "tail" }
2063 );
2064
2065 // A HIT in the projection is valid: the five facts are the complete
2066 // index's, and the ordinal is the rank within the generation the
2067 // projection covers — which is what an ordinal means.
2068 let truth = complete.lookup(&e.oid).expect("complete index has it");
2069 assert_eq!(via_serial.offset, truth.offset);
2070 assert_eq!(via_serial.len, truth.len);
2071 assert_eq!(via_serial.obj_type, truth.obj_type);
2072 assert_eq!(via_serial.uncompressed_size, truth.uncompressed_size);
2073 assert_eq!(via_serial.delta_base, truth.delta_base);
2074 if i < 300 {
2075 assert!(
2076 !is_tail_row(&via_serial),
2077 "sealed row wearing a tail ordinal"
2078 );
2079 assert_eq!(
2080 via_serial.ordinal,
2081 sealed_only.lookup(&e.oid).unwrap().ordinal,
2082 "the projection's ordinal for {} is not its rank in the generation the \
2083 projection covers",
2084 hex::encode(&e.oid)
2085 );
2086 } else {
2087 assert!(
2088 is_tail_row(&via_serial),
2089 "tail row {} has no tail ordinal",
2090 hex::encode(&e.oid)
2091 );
2092 }
2093 }
2094 }
2095
2096 /// A miss in the tree is not an answer, and an absence is.
2097 ///
2098 /// Seen RED by having `fill_from_tail` skip its `objects.get` and count every
2099 /// unfilled slot as absent: the tail-served block came back empty —
2100 /// "assertion `left == right` failed / left: 0 / right: 80". Restored.
2101 #[test]
2102 fn only_a_tail_miss_is_absent_and_only_a_tail_hit_counts() {
2103 let all = synthetic_entries(200, 20, 7);
2104 let (sealed, tail) = all.split_at(120);
2105 let stack = split_stack(sealed, tail);
2106 let nowhere = synthetic_entries(64, 20, 0x00AB_5E47);
2107
2108 let mut refs: Vec<&[u8]> = Vec::new();
2109 refs.extend(sealed.iter().map(|e| e.oid.as_slice()));
2110 refs.extend(tail.iter().map(|e| e.oid.as_slice()));
2111 refs.extend(nowhere.iter().map(|e| e.oid.as_slice()));
2112 let rows = stack.lookup_batch(&refs);
2113
2114 assert_eq!(rows[..120].iter().filter(|r| r.is_some()).count(), 120);
2115 assert_eq!(rows[120..200].iter().filter(|r| r.is_some()).count(), 80);
2116 assert!(
2117 rows[200..].iter().all(Option::is_none),
2118 "an object in no repository resolved to a row"
2119 );
2120
2121 let st = stack.stats();
2122 assert_eq!(st.tail_hits, 80, "tail-served lookups miscounted");
2123 assert_eq!(st.absent, 64, "genuine absences miscounted");
2124 }
2125
2126 /// **Append-only, enforced.** The refusal is what makes a stree hit
2127 /// trustworthy; if a fact could be rewritten, a stale projection would return
2128 /// the old one.
2129 ///
2130 /// **Identity is refused; placement is not.** REWRITTEN 2026-08-14 — the
2131 /// old form compared `offset`, `len` and `delta_base` too, and those say
2132 /// where a copy landed rather than what the object is. A re-pushed pack
2133 /// places the same oid at a new offset, which is the ordinary case, so the
2134 /// guard refused normal pushes: the ingest aborted and the connection
2135 /// dropped with `client_told=false`, leaving the client with
2136 /// "send-pack: unexpected disconnect" and no reason. It failed
2137 /// `bare_lifecycle` and `endurance` on all four znippy bench columns and
2138 /// bit any `--force` push that resent a known object.
2139 ///
2140 /// The old comparison's stated fear — a projection carrying "a base offset
2141 /// the archive never wrote" — is answered better by keeping the OLD row:
2142 /// the archive is append-only and `gc` truncates nothing, so the first
2143 /// writer's offset and base still address bytes that are still there.
2144 ///
2145 /// Seen RED by comparing `offset` again: "a re-pushed pack at a new offset
2146 /// was refused" — which is precisely the production failure, reproduced.
2147 ///
2148 /// Seen RED the other way by dropping `old.obj_type != e.obj_type`: "an oid
2149 /// that changed type was accepted" — the same oid describing a different
2150 /// object, waved through.
2151 #[test]
2152 fn an_append_only_violation_is_refused() {
2153 let entries = synthetic_entries(16, 20, 11);
2154 let stack = manual();
2155 stack.append(&entries).expect("first append");
2156
2157 // Re-appending the identical batch is a no-op, not an error: a re-pushed
2158 // pack repeats objects.
2159 stack
2160 .append(&entries)
2161 .expect("identical re-append is idempotent");
2162 assert_eq!(
2163 stack.len(),
2164 16,
2165 "an idempotent re-append changed the row count"
2166 );
2167
2168 // ★ THE PRODUCTION CASE: the same oid, re-pushed, lands elsewhere.
2169 // This must be a NO-OP, and the first writer's placement must stand.
2170 let mut moved = entries[3].clone();
2171 moved.offset += 1;
2172 stack
2173 .append(std::slice::from_ref(&moved))
2174 .expect("a re-pushed pack at a new offset was refused");
2175 let row = stack.lookup(&entries[3].oid).expect("still there");
2176 assert_eq!(
2177 row.offset, entries[3].offset,
2178 "the second placement overwrote the first — the projection now names \
2179 an offset that is not where the first writer put the bytes"
2180 );
2181 assert_eq!(stack.len(), 16, "a re-placement added a row");
2182
2183 // ★ IDENTITY still refuses: same oid, different object.
2184 let mut retyped = entries[4].clone();
2185 retyped.uncompressed_size += 1;
2186 let err = stack
2187 .append(std::slice::from_ref(&retyped))
2188 .expect_err("an oid that changed size was accepted");
2189 assert!(
2190 err.to_string().contains("identity violation"),
2191 "wrong error: {err}"
2192 );
2193
2194 // ★ A DIFFERENT DELTA BASE IS PLACEMENT TOO, and the first row stands.
2195 //
2196 // The old form refused this, on the reasoning that a row naming a base
2197 // the archive never wrote would be left behind. Keeping the OLD row
2198 // answers that completely: the base the FIRST writer recorded is still
2199 // in the archive, because the blob is append-only and `gc` truncates
2200 // nothing. The second pack's base is simply not adopted.
2201 //
2202 // Refusing it instead is what broke real pushes — a re-pushed pack
2203 // re-deltas against whatever is in ITS window, so a differing base is
2204 // the ordinary case, not evidence of corruption.
2205 let based = entries
2206 .iter()
2207 .find(|e| e.delta_base != 0)
2208 .expect("the fixture must carry a delta");
2209 let mut rebased = based.clone();
2210 rebased.delta_base += 8;
2211 stack
2212 .append(std::slice::from_ref(&rebased))
2213 .expect("a re-pushed pack that re-deltaed was refused");
2214 assert_eq!(
2215 stack.lookup(&based.oid).unwrap().delta_base,
2216 based.delta_base,
2217 "the second pack's base was adopted — the projection now names a base \
2218 the FIRST writer never recorded, which is the thing the old guard \
2219 was right to fear"
2220 );
2221 }
2222
2223 /// The volume trigger fires on appended bytes and the miss trigger on
2224 /// tail-served lookups, and neither fires on a genuine absence.
2225 ///
2226 /// Seen RED by counting `absent` into `tail_hits` in `fill_from_tail`:
2227 /// "assertion `left == right` failed: absences tripped the miss trigger /
2228 /// left: Some(TailHits(1000)) / right: None". Restored.
2229 #[test]
2230 fn each_trigger_fires_on_its_own_signal() {
2231 // ── volume ──
2232 let entries = synthetic_entries(64, 20, 21);
2233 let bytes: u64 = entries.iter().map(|e| e.len).sum();
2234 let stack = Stack::in_memory(RebuildTriggers {
2235 tail_bytes: bytes,
2236 ..RebuildTriggers::manual()
2237 })
2238 .unwrap();
2239 let g0 = stack.stats().generation;
2240 let reason = stack.append(&entries).unwrap();
2241 assert!(
2242 matches!(reason, Some(RebuildReason::TailBytes(_))),
2243 "the volume trigger did not fire at exactly its threshold: {reason:?}"
2244 );
2245 assert_eq!(stack.projection_len(), 64, "the rebuild absorbed nothing");
2246 assert_eq!(stack.stats().generation, g0 + 1);
2247 assert_eq!(
2248 stack.stats().tail_bytes,
2249 0,
2250 "the byte counter was not reset"
2251 );
2252
2253 // ── misses ──
2254 let all = synthetic_entries(200, 20, 22);
2255 let (sealed, tail) = all.split_at(100);
2256 // A flat floor of 100 misses: `tail_hits_per_row` 0 means the threshold
2257 // is exactly the floor, which makes the arithmetic in this test the
2258 // trigger's and not the ratio's.
2259 let stack = Stack::in_memory(RebuildTriggers {
2260 tail_hits_per_row: 0.0,
2261 min_tail_hits: 100,
2262 tail_bytes: 0,
2263 fold_ratio: 0.0,
2264 })
2265 .unwrap();
2266 stack.append(sealed).unwrap();
2267 stack.rebuild().unwrap();
2268 stack.append(tail).unwrap();
2269 let g1 = stack.stats().generation;
2270 assert_eq!(stack.projection_len(), 100);
2271
2272 // 1000 lookups of oids in NO repository: 1000 tail misses, zero
2273 // tail-served hits, and no rebuild may follow.
2274 let nowhere = synthetic_entries(1000, 20, 23);
2275 let refs: Vec<&[u8]> = nowhere.iter().map(|e| e.oid.as_slice()).collect();
2276 assert!(stack.lookup_batch(&refs).iter().all(Option::is_none));
2277 assert_eq!(stack.stats().absent, 1000);
2278 assert_eq!(
2279 stack.rebuild_due(),
2280 None,
2281 "absences tripped the miss trigger"
2282 );
2283 stack.maybe_rebuild().unwrap();
2284 assert_eq!(
2285 stack.stats().generation,
2286 g1,
2287 "1000 absences must not rebuild"
2288 );
2289 assert_eq!(stack.projection_len(), 100);
2290
2291 // 100 lookups the tail DOES answer: the trigger fires.
2292 let refs: Vec<&[u8]> = tail.iter().map(|e| e.oid.as_slice()).collect();
2293 assert_eq!(stack.lookup_batch(&refs).iter().flatten().count(), 100);
2294 assert_eq!(
2295 stack.rebuild_due(),
2296 Some(RebuildReason::TailHits(100)),
2297 "100 tail-served lookups did not trip a threshold of 100"
2298 );
2299 assert!(stack.maybe_rebuild().unwrap().is_some());
2300 assert_eq!(
2301 stack.projection_len(),
2302 200,
2303 "the rebuild did not absorb the tail"
2304 );
2305 assert_eq!(stack.stats().generation, g1 + 1);
2306 }
2307
2308 /// The two column scans answer for the **repository**, not for the
2309 /// projection — including the rows the projection has not absorbed, and
2310 /// without scanning the tail to find them.
2311 ///
2312 /// Seen RED by dropping the `unabsorbed_size` term from `sum_uncompressed`:
2313 /// "assertion `left == right` failed: sum_uncompressed answered for the
2314 /// projection, not the repository / left: 1179629 / right: 1915881".
2315 /// Restored.
2316 ///
2317 /// The rejected-append half of this test is what pins the *ordering* in
2318 /// `append`: the counters are accumulated locally and applied after the
2319 /// commit, because a `bail!` half-way through a batch rolls redb back and
2320 /// counters bumped inside the loop would not roll back with it. That one is
2321 /// fixed by construction rather than watched — there is one place the
2322 /// counters are written and it is after `commit()`.
2323 #[test]
2324 fn the_column_scans_answer_for_the_repository_not_the_projection() {
2325 let all = synthetic_entries(300, 20, 71);
2326 let (sealed, tail) = all.split_at(180);
2327 let stack = split_stack(sealed, tail);
2328 let complete = OneTableFourColumns::build(&all).unwrap();
2329
2330 assert!(
2331 stack.projection.read().unwrap().sum_uncompressed() < complete.sum_uncompressed(),
2332 "the projection already sums to the whole repository — this test would be vacuous"
2333 );
2334 assert_eq!(
2335 stack.sum_uncompressed(),
2336 complete.sum_uncompressed(),
2337 "sum_uncompressed answered for the projection, not the repository"
2338 );
2339 for t in ObjType::ALL {
2340 assert_eq!(
2341 stack.count_type(t),
2342 complete.count_type(t),
2343 "count_type({}) answered for the projection",
2344 t.as_str()
2345 );
2346 }
2347
2348 // A rejected append must not move either aggregate.
2349 let mut rewritten = all[0].clone();
2350 rewritten.uncompressed_size += 1_000_000;
2351 let batch = [all[7].clone(), rewritten];
2352 assert!(stack.append(&batch).is_err());
2353 assert_eq!(stack.sum_uncompressed(), complete.sum_uncompressed());
2354
2355 // And a rebuild leaves them where they were.
2356 stack.rebuild().unwrap();
2357 assert_eq!(stack.sum_uncompressed(), complete.sum_uncompressed());
2358 for t in ObjType::ALL {
2359 assert_eq!(stack.count_type(t), complete.count_type(t));
2360 }
2361 }
2362
2363 /// The two narrow batch paths fall through to the tail exactly as the
2364 /// full-row one does. A miss is not an answer on those either.
2365 ///
2366 /// Seen RED by returning the projection's `ordinals_batch` unchanged:
2367 /// "assertion `left == right` failed: the ordinal path lost 120 of 300
2368 /// objects / left: 180 / right: 300". Restored.
2369 #[test]
2370 fn the_narrow_batch_paths_also_fall_through() {
2371 let all = synthetic_entries(300, 20, 81);
2372 let (sealed, tail) = all.split_at(180);
2373 let stack = split_stack(sealed, tail);
2374 let refs: Vec<&[u8]> = all.iter().map(|e| e.oid.as_slice()).collect();
2375
2376 let ordinals = stack.ordinals_batch(&refs);
2377 assert_eq!(
2378 ordinals.iter().filter(|o| o.is_some()).count(),
2379 300,
2380 "the ordinal path lost {} of 300 objects",
2381 300 - ordinals.iter().filter(|o| o.is_some()).count()
2382 );
2383 for (o, e) in ordinals[180..].iter().zip(tail) {
2384 assert!(
2385 o.unwrap() & TAIL_ORDINAL_BIT != 0,
2386 "tail object {} got a projection ordinal",
2387 hex::encode(&e.oid)
2388 );
2389 }
2390
2391 let extents = stack.extents_batch(&refs);
2392 for (x, e) in extents.iter().zip(&all) {
2393 assert_eq!(
2394 *x,
2395 Some((e.offset, e.len)),
2396 "extent lost or wrong for {}",
2397 hex::encode(&e.oid)
2398 );
2399 }
2400 }
2401
2402 /// A rebuild re-derives every ordinal as the new lexicographic rank, and the
2403 /// tail's high-bit ordinals disappear as their rows are absorbed. This is the
2404 /// generation caveat, asserted rather than only documented.
2405 ///
2406 /// Seen RED by having `rebuild` compute the fresh projection and never swap
2407 /// it in: "an absorbed row kept its tail ordinal". Restored.
2408 #[test]
2409 fn a_rebuild_re_derives_ordinals_and_clears_the_tail_bit() {
2410 let all = synthetic_entries(120, 32, 31);
2411 let (sealed, tail) = all.split_at(60);
2412 let stack = split_stack(sealed, tail);
2413
2414 let before = stack.lookup(&tail[0].oid).unwrap();
2415 assert!(is_tail_row(&before));
2416
2417 stack.rebuild().unwrap();
2418 let after = stack.lookup(&tail[0].oid).unwrap();
2419 assert!(
2420 !is_tail_row(&after),
2421 "an absorbed row kept its tail ordinal"
2422 );
2423 assert_eq!(after.offset, before.offset, "absorption changed a fact");
2424 assert_eq!(after.len, before.len);
2425 assert_eq!(after.obj_type, before.obj_type);
2426 assert_eq!(after.uncompressed_size, before.uncompressed_size);
2427 assert_eq!(after.delta_base, before.delta_base);
2428
2429 // The delta base is an ARCHIVE OFFSET and absorption must not touch it.
2430 // An ordinal in this column would have had to be re-derived by the
2431 // rebuild — silently, and to a different number, because the rebuild
2432 // re-ranks every row. That is §13's argument, asserted on a row that
2433 // actually carries a base rather than on the 0 sentinel.
2434 let based = tail
2435 .iter()
2436 .find(|e| e.delta_base != 0)
2437 .expect("the tail half must carry at least one delta");
2438 let row = stack.lookup(&based.oid).unwrap();
2439 assert_eq!(
2440 row.delta_base,
2441 based.delta_base,
2442 "the rebuild moved {}'s delta base from {} to {}",
2443 hex::encode(&based.oid),
2444 based.delta_base,
2445 row.delta_base
2446 );
2447 assert_ne!(
2448 row.delta_base, row.ordinal as u64,
2449 "a delta base that equals a row ordinal is the mistake §13 forbids"
2450 );
2451
2452 let complete = OneTableFourColumns::build(&all).unwrap();
2453 assert_eq!(
2454 after.ordinal,
2455 complete.lookup(&tail[0].oid).unwrap().ordinal,
2456 "the rebuilt ordinal is not the lexicographic rank"
2457 );
2458 }
2459
2460 /// **The crash-recovery diff is exact at the pack boundary.**
2461 ///
2462 /// Three packs' worth of rows laid end to end, the middle one never appended:
2463 /// [`ObjectReadStack::extents_with_rows`] must answer `true, false, true`.
2464 /// The middle pack's extent is the interesting one — it is bounded on both
2465 /// sides by rows that *are* in the tail, so an off-by-one in either direction
2466 /// reports it absorbed, its objects are never re-queued after a restart, and a
2467 /// durable pack becomes unreadable for ever.
2468 ///
2469 /// Asserted per extent, not as a count.
2470 ///
2471 /// Seen RED by `let p = starts.partition_point(|&s| s <= offset)` →
2472 /// `partition_point(|&s| s < offset)`, i.e. placing a row against the
2473 /// *previous* extent when it lands on a pack's first byte: "left: [false] /
2474 /// right: [true]" on the single-row extent. Restored.
2475 ///
2476 /// Seen RED a second time by `offset < start + len` → `offset <= start + len`:
2477 /// "a row one byte past pack B's extent was counted as B's — left: [true,
2478 /// true], right: [true, false]". **That mutation stayed green against the
2479 /// four-pack question**, and the reason is worth writing down: when every
2480 /// extent is in the question and the packs are adjacent, the row at B's end
2481 /// offset is also C's first row, and the binary search assigns it to C before
2482 /// the bound is ever consulted. The bound only bites when a pack's successor
2483 /// is *not* in the question — a journal whose tail was torn, which is the
2484 /// state this whole diff exists to survive. The `[..2]` case below is
2485 /// therefore not an extra assertion, it is the one that tests the bound at
2486 /// all. Restored.
2487 #[test]
2488 fn the_crash_recovery_diff_is_exact_at_the_pack_boundary() {
2489 let all = synthetic_entries(400, 20, 0xB17_5E7);
2490 // Four packs, back to back, the way `SafeWriter` appends them.
2491 let bounds = [(0usize, 100usize), (100, 200), (200, 300), (300, 400)];
2492 let extents: Vec<(u64, u64)> = bounds
2493 .iter()
2494 .map(|&(a, b)| {
2495 let start = all[a].offset;
2496 let end = all[b - 1].offset + all[b - 1].len;
2497 (start, end - start)
2498 })
2499 .collect();
2500 assert_eq!(
2501 extents[1].0,
2502 extents[0].0 + extents[0].1,
2503 "the fixture's packs must be adjacent or the boundary is not under test"
2504 );
2505
2506 let stack = manual();
2507 for (i, &(a, b)) in bounds.iter().enumerate() {
2508 if i != 1 {
2509 stack.append(&all[a..b]).unwrap();
2510 }
2511 }
2512 assert_eq!(stack.len(), 300, "the middle pack must be the missing one");
2513
2514 let hit = stack.extents_with_rows(&extents).unwrap();
2515 assert_eq!(
2516 hit,
2517 vec![true, false, true, true],
2518 "pack B has no rows in the tail and was reported absorbed"
2519 );
2520
2521 // **The containment bound, made observable.** Asking only about A and B
2522 // is the torn-journal case: pack C's rows are in the tail but its extent
2523 // is not in the question, and C's first row sits at exactly B's end
2524 // offset. That row belongs to C and must not answer for B.
2525 assert_eq!(
2526 stack.extents_with_rows(&extents[..2]).unwrap(),
2527 vec![true, false],
2528 "a row one byte past pack B's extent was counted as B's"
2529 );
2530
2531 // A zero-length extent has no rows by construction, and an extent past
2532 // everything the tail knows about has none either.
2533 let past = all[399].offset + all[399].len;
2534 assert_eq!(
2535 stack.extents_with_rows(&[(0, 0), (past, 4096)]).unwrap(),
2536 vec![false, false]
2537 );
2538 // And an extent that covers exactly one row is that row's pack.
2539 assert_eq!(
2540 stack
2541 .extents_with_rows(&[(all[7].offset, all[7].len)])
2542 .unwrap(),
2543 vec![true]
2544 );
2545 }
2546
2547 /// A file-backed tail survives being closed and reopened, and the reopened
2548 /// stack warm-starts a projection over everything — including rows that were
2549 /// only in the tail when the process went away.
2550 ///
2551 /// Seen RED by having `from_db` build the projection from an empty slice
2552 /// instead of `scan(&db)`: "reopened projection covers 0 of 90". Restored.
2553 #[test]
2554 fn a_file_backed_tail_reopens_warm() {
2555 let dir = tempfile::tempdir().unwrap();
2556 let path = dir.path().join("objects.tail.redb");
2557 let all = synthetic_entries(90, 20, 41);
2558 {
2559 let stack =
2560 ObjectReadStack::<OneTableFourColumns>::open(
2561 &path,
2562 RebuildTriggers::manual(),
2563 DEFAULT_REDB_CACHE_BYTES,
2564 )
2565 .unwrap();
2566 stack.append(&all[..40]).unwrap();
2567 stack.rebuild().unwrap();
2568 stack.append(&all[40..]).unwrap();
2569 assert_eq!(stack.projection_len(), 40);
2570 }
2571 let stack =
2572 ObjectReadStack::<OneTableFourColumns>::open(
2573 &path,
2574 RebuildTriggers::manual(),
2575 DEFAULT_REDB_CACHE_BYTES,
2576 ).unwrap();
2577 // **What "warm" means since 2026-09-04.** This used to assert
2578 // `projection_len() == 90`: the old open rebuilt the projection from a
2579 // full redb scan, so everything was in it. Now the open MAPS the folded
2580 // index (the 40 rows the rebuild persisted) and leaves the 50 that
2581 // arrived after it in redb, where the fall-through serves them — the
2582 // same routing an un-absorbed row always had. The repository is still
2583 // 90 rows and every one of them answers; what changed is that nothing
2584 // was rebuilt to make that so, and `persisted_loads` is the applied
2585 // output that says so.
2586 assert_eq!(stack.len(), 90, "the repository is {} of 90 rows", stack.len());
2587 assert_eq!(stack.projection_len(), 40, "the mapped index holds what the fold wrote");
2588 assert_eq!(stack.stats().persisted_loads, 1, "the open must have mapped, not rebuilt");
2589 assert_eq!(stack.stats().unabsorbed_rows, 50, "the 50 later rows are redb's to serve");
2590 for e in &all {
2591 let row = stack
2592 .lookup(&e.oid)
2593 .unwrap_or_else(|| panic!("reopen lost {}", hex::encode(&e.oid)));
2594 assert_eq!(row.offset, e.offset);
2595 assert_eq!(row.uncompressed_size, e.uncompressed_size);
2596 assert_eq!(row.delta_base, e.delta_base, "reopen lost a delta base");
2597 }
2598 assert!(
2599 all.iter().filter(|e| e.delta_base != 0).count() >= 5,
2600 "the fixture must carry delta bases across the reopen"
2601 );
2602 }
2603
2604 // ── the fold ─────────────────────────────────────────────────────────────
2605
2606 /// A file-backed stack over `all[..40]` folded, `all[40..]` appended after.
2607 /// Returns the tail path and the index path.
2608 fn folded_then_appended(
2609 dir: &Path,
2610 all: &[IndexEntry],
2611 split: usize,
2612 ) -> (PathBuf, PathBuf, ObjectReadStack<OneTableFourColumns>) {
2613 let tail = dir.join("objects.tail");
2614 let stack = ObjectReadStack::<OneTableFourColumns>::open(
2615 &tail,
2616 RebuildTriggers::manual(),
2617 DEFAULT_REDB_CACHE_BYTES,
2618 )
2619 .unwrap();
2620 stack.append(&all[..split]).unwrap();
2621 stack.rebuild().unwrap();
2622 stack.append(&all[split..]).unwrap();
2623 let index = stack.persisted_path().expect("the default arm persists").to_path_buf();
2624 (tail, index, stack)
2625 }
2626
2627 /// How many rows redb holds right now — the thing the fold exists to keep
2628 /// small, read straight off the table so the test cannot be fooled by the
2629 /// stack's own bookkeeping.
2630 fn redb_rows(stack: &ObjectReadStack<OneTableFourColumns>) -> u64 {
2631 let r = stack.tail.begin_read().unwrap();
2632 r.open_table(OBJECTS).unwrap().len().unwrap()
2633 }
2634
2635 /// **A rebuild on a file-backed stack is a fold**: the projection lands in
2636 /// `objects.index`, and redb forgets the rows it holds. Asserted on the
2637 /// file and on redb's own row count, not on the stack's counters.
2638 ///
2639 /// Seen RED by skipping the `objects.remove` loop in `fold_locked`: redb
2640 /// kept 40 rows after the fold ("redb must hold only what arrived after the
2641 /// fold / left: 40 / right: 0"). Seen RED by skipping `write_atomic`: the
2642 /// index file was absent. Restored.
2643 #[test]
2644 fn a_rebuild_on_a_file_backed_stack_is_a_fold() {
2645 let dir = tempfile::tempdir().unwrap();
2646 let all = synthetic_entries(90, 20, 43);
2647 let (_tail, index, stack) = folded_then_appended(dir.path(), &all, 40);
2648
2649 assert!(index.is_file(), "the fold must persist the projection at {}", index.display());
2650 assert!(
2651 fs::metadata(&index).unwrap().len() > 40 * 61,
2652 "the persisted index must carry at least the rows' own bytes"
2653 );
2654 assert_eq!(stack.stats().folds, 1);
2655 assert_eq!(redb_rows(&stack), 50, "redb must hold only what arrived after the fold");
2656 assert_eq!(stack.projection_len(), 40);
2657 assert_eq!(stack.len(), 90);
2658 assert!(!stack.projection_is_complete());
2659 for e in &all {
2660 let row = stack.lookup(&e.oid).unwrap_or_else(|| panic!("lost {}", hex::encode(&e.oid)));
2661 assert_eq!((row.offset, row.len, row.delta_base), (e.offset, e.len, e.delta_base));
2662 }
2663 // The tail rows are the tail's: served with the tail bit, as before.
2664 assert!(is_tail_row(&stack.lookup(&all[80].oid).unwrap()));
2665 assert!(!is_tail_row(&stack.lookup(&all[10].oid).unwrap()));
2666
2667 // A second fold takes the 50 with it and redb is empty.
2668 stack.rebuild().unwrap();
2669 assert_eq!(redb_rows(&stack), 0);
2670 assert_eq!(stack.projection_len(), 90);
2671 assert!(stack.projection_is_complete());
2672 assert_eq!(stack.stats().folds, 2);
2673 }
2674
2675 /// **Re-appending a folded row creates nothing.** `append` dedupes against
2676 /// the projection AND redb, because on a persisting stack the folded rows
2677 /// are in the projection only — a check against redb alone would insert
2678 /// every re-pushed or re-indexed object a second time, into redb, and the
2679 /// two layers would each hold it.
2680 ///
2681 /// Seen RED before the projection check existed, on the real store:
2682 /// `git_ops::tests::dropping_the_exploded_table_and_reopening_still_answers`
2683 /// re-indexed a pack from the journal after a reopen and the row count
2684 /// read **2704 for 1352 objects** — exactly twice. Seen RED here by
2685 /// consulting redb only: `redb_rows` read 40 where 0 was expected and
2686 /// `len()` read 80. Restored.
2687 #[test]
2688 fn re_appending_a_folded_row_creates_no_second_row() {
2689 let dir = tempfile::tempdir().unwrap();
2690 let all = synthetic_entries(40, 20, 50);
2691 let tail = dir.path().join("objects.tail");
2692 let stack = ObjectReadStack::<OneTableFourColumns>::open(
2693 &tail,
2694 RebuildTriggers::manual(),
2695 DEFAULT_REDB_CACHE_BYTES,
2696 )
2697 .unwrap();
2698 stack.append(&all).unwrap();
2699 stack.rebuild().unwrap();
2700 assert_eq!(redb_rows(&stack), 0, "precondition: everything folded");
2701
2702 // The same objects again — a re-push, or the journal re-indexing a
2703 // pack after a reopen. Placement may differ; identity must agree.
2704 let mut again = all.clone();
2705 for e in &mut again {
2706 e.offset += 1_000_000;
2707 }
2708 stack.append(&again).unwrap();
2709 assert_eq!(redb_rows(&stack), 0, "a folded row re-appended must not land in redb");
2710 assert_eq!(stack.len(), 40);
2711 assert_eq!(stack.oids_in_order().unwrap().len(), 40);
2712 // And the first writer's placement stands, as `append`'s contract says.
2713 assert_eq!(stack.lookup(&all[3].oid).unwrap().offset, all[3].offset);
2714
2715 // The identity check still bites through the projection path: a
2716 // different inflated size for a folded oid is refused, not inserted.
2717 let mut lie = all[7].clone();
2718 lie.uncompressed_size += 1;
2719 let err = stack.append(&[lie]).unwrap_err().to_string();
2720 assert!(err.contains("identity violation"), "{err}");
2721 assert_eq!(redb_rows(&stack), 0);
2722 }
2723
2724 /// **A warm open maps the index and rebuilds nothing.** The proof is
2725 /// `persisted_loads == 1` plus a projection whose size is exactly what the
2726 /// fold wrote — a rebuilt projection would hold all 90.
2727 ///
2728 /// Seen RED by making `load_persisted` return `None` unconditionally: the
2729 /// open fell back to the scan-and-build, `persisted_loads` read 0, and the
2730 /// projection held 90. Restored.
2731 #[test]
2732 fn a_warm_open_maps_the_folded_index_and_does_not_rebuild() {
2733 let dir = tempfile::tempdir().unwrap();
2734 let all = synthetic_entries(90, 20, 44);
2735 let (tail, _index, stack) = folded_then_appended(dir.path(), &all, 60);
2736 drop(stack);
2737
2738 let again = ObjectReadStack::<OneTableFourColumns>::open(
2739 &tail,
2740 RebuildTriggers::manual(),
2741 DEFAULT_REDB_CACHE_BYTES,
2742 )
2743 .unwrap();
2744 let s = again.stats();
2745 assert_eq!(s.persisted_loads, 1, "the open must have taken the index off disk");
2746 assert_eq!(s.folds, 0, "a warm open folds nothing — there is nothing to migrate");
2747 assert_eq!(again.projection_len(), 60, "the mapped projection is what the fold wrote");
2748 assert_eq!(again.len(), 90);
2749 assert_eq!(s.unabsorbed_rows, 30);
2750 assert_eq!(redb_rows(&again), 30);
2751 // The column-scan aggregates are exact from the first read — computed
2752 // over the redb remainder at open, not left at zero.
2753 let expect_size: u64 = all.iter().map(|e| e.uncompressed_size).sum();
2754 assert_eq!(again.sum_uncompressed(), expect_size);
2755 for t in [ObjType::Commit, ObjType::Tree, ObjType::Blob, ObjType::OfsDelta] {
2756 assert_eq!(
2757 again.count_type(t),
2758 all.iter().filter(|e| e.obj_type == t).count(),
2759 "count_type({t:?}) over both layers"
2760 );
2761 }
2762 for e in &all {
2763 let row = again.lookup(&e.oid).unwrap_or_else(|| panic!("lost {}", hex::encode(&e.oid)));
2764 assert_eq!(row.uncompressed_size, e.uncompressed_size);
2765 }
2766 }
2767
2768 /// **The crash window a fold leaves is reconciled at open.** The fold
2769 /// renames the index into place and THEN forgets the rows in redb; a crash
2770 /// between the two leaves every folded row in both. Staged here by putting
2771 /// the folded rows back into redb by hand after a fold, exactly as that
2772 /// crash would leave them.
2773 ///
2774 /// Seen RED by removing the `dup` pass in `from_db`: `oids_in_order` came
2775 /// back with 90 entries for a 60-object repository and `len()` read 90.
2776 /// Restored.
2777 #[test]
2778 fn an_interrupted_fold_is_reconciled_at_the_next_open() {
2779 let dir = tempfile::tempdir().unwrap();
2780 let all = synthetic_entries(60, 20, 45);
2781 let (tail, _index, stack) = folded_then_appended(dir.path(), &all, 30);
2782 // Put the 30 folded rows back — the state after "renamed, then died".
2783 {
2784 let w = stack.tail.begin_write().unwrap();
2785 {
2786 let mut objects = w.open_table(OBJECTS).unwrap();
2787 for (i, e) in all[..30].iter().enumerate() {
2788 objects.insert(e.oid.as_slice(), encode_row(1000 + i as u64, e).as_slice()).unwrap();
2789 }
2790 }
2791 w.commit().unwrap();
2792 }
2793 assert_eq!(redb_rows(&stack), 60, "precondition: every row is now in both layers");
2794 drop(stack);
2795
2796 let again = ObjectReadStack::<OneTableFourColumns>::open(
2797 &tail,
2798 RebuildTriggers::manual(),
2799 DEFAULT_REDB_CACHE_BYTES,
2800 )
2801 .unwrap();
2802 assert_eq!(redb_rows(&again), 30, "the duplicates must leave redb at open");
2803 assert_eq!(again.len(), 60);
2804 assert_eq!(again.tail_epoch_and_len().unwrap().1, 60);
2805 let oids = again.oids_in_order().unwrap();
2806 assert_eq!(oids.len(), 60, "each object once");
2807 assert!(oids.windows(2).all(|w| w[0] < w[1]), "oid order, no repeats");
2808 }
2809
2810 /// **The tail-wide answers cover both layers.** `oids_in_order` is the
2811 /// merge of the persisted projection and redb; `tail_epoch_and_len` counts
2812 /// both; `extents_with_rows` finds a row whichever layer holds it.
2813 ///
2814 /// Seen RED by returning only redb's keys from `oids_in_order` on a
2815 /// persisting stack: 50 of 90. Seen RED by scanning only redb in
2816 /// `extents_with_rows`: the folded extent read `false`. Restored.
2817 #[test]
2818 fn the_tail_wide_answers_cover_both_layers() {
2819 let dir = tempfile::tempdir().unwrap();
2820 let all = synthetic_entries(90, 20, 46);
2821 let (_tail, _index, stack) = folded_then_appended(dir.path(), &all, 40);
2822
2823 let mut expect: Vec<Vec<u8>> = all.iter().map(|e| e.oid.clone()).collect();
2824 expect.sort();
2825 assert_eq!(stack.oids_in_order().unwrap(), expect, "the merge of both layers, in oid order");
2826 assert_eq!(stack.tail_epoch_and_len().unwrap().1, 90);
2827
2828 // One extent that only the persisted half can answer, one only redb can,
2829 // and one nobody can.
2830 let folded = &all[3];
2831 let tailed = &all[77];
2832 let nowhere = (u64::MAX / 2, 7u64);
2833 let hit = stack
2834 .extents_with_rows(&[(folded.offset, folded.len), (tailed.offset, tailed.len), nowhere])
2835 .unwrap();
2836 assert_eq!(hit, vec![true, true, false], "folded / tail / absent");
2837 }
2838
2839 /// **The ordinal space is the merge of both layers, and says which is which.**
2840 /// After a fold with nothing appended, every oid comes off the index and
2841 /// none off redb; after more appends the tail rows are counted as such; in
2842 /// every state the bytes equal `oids_in_order` concatenated.
2843 ///
2844 /// Seen RED by returning the redb tail alone on a persisting stack:
2845 /// `folded_rows` read 0 where 40 was expected and the space held 50 of 90
2846 /// oids. Restored.
2847 #[test]
2848 fn the_ordinal_space_is_both_layers_with_provenance() {
2849 let dir = tempfile::tempdir().unwrap();
2850 let all = synthetic_entries(90, 20, 51);
2851 let (_tail, _index, stack) = folded_then_appended(dir.path(), &all, 40);
2852
2853 let expect: Vec<u8> = stack.oids_in_order().unwrap().concat();
2854 let space = stack.oids_raw().unwrap();
2855 assert_eq!(space.raw, expect, "the space must be oids_in_order, concatenated");
2856 assert_eq!((space.folded_rows, space.tail_rows), (40, 50));
2857 assert_eq!(space.raw.len(), 90 * 20);
2858
2859 stack.rebuild().unwrap();
2860 let space = stack.oids_raw().unwrap();
2861 assert_eq!(space.raw, expect, "a fold does not change the space");
2862 assert_eq!((space.folded_rows, space.tail_rows), (90, 0), "everything off the index now");
2863 }
2864
2865 /// **On a persisting stack the volume trigger scales with the index.** With
2866 /// a 1-byte floor and `fold_ratio = 1`, appending fewer object bytes than the
2867 /// persisted index is long does not fold; appending past it does. On an
2868 /// in-memory stack the same triggers fold at the floor, because there a
2869 /// rebuild writes nothing and the fixed cadence is right.
2870 ///
2871 /// Seen RED by making `volume_threshold` return the floor unconditionally:
2872 /// the small append folded ("a small append must not fold a big index /
2873 /// left: 2 / right: 1"). Restored.
2874 #[test]
2875 fn the_volume_trigger_scales_with_the_persisted_index() {
2876 let dir = tempfile::tempdir().unwrap();
2877 let mut all = synthetic_entries(400, 20, 52);
2878 for e in &mut all {
2879 e.len = 100;
2880 }
2881 let triggers = RebuildTriggers {
2882 tail_bytes: 1,
2883 fold_ratio: 1.0,
2884 ..RebuildTriggers::manual()
2885 };
2886 let stack = ObjectReadStack::<OneTableFourColumns>::open(
2887 &dir.path().join("objects.tail"),
2888 triggers,
2889 DEFAULT_REDB_CACHE_BYTES,
2890 )
2891 .unwrap();
2892 // The first append trips the 1-byte floor at once: there is no index yet
2893 // to scale against. After it the index is 300 rows ≈ 16 kB of IPC.
2894 stack.append(&all[..300]).unwrap();
2895 let folds = stack.stats().folds;
2896 assert_eq!(folds, 1, "the first batch folds at the floor");
2897 let index_bytes = stack.ipc_bytes() as u64;
2898 assert!(index_bytes > 10_000, "precondition: a real index, got {index_bytes} B");
2899
2900 // 50 rows × 100 B = 5 000 B of object bytes: well under the index size.
2901 stack.append(&all[300..350]).unwrap();
2902 assert_eq!(stack.stats().folds, folds, "a small append must not fold a big index");
2903 assert_eq!(stack.stats().unabsorbed_rows, 50, "the 50 rows wait in redb");
2904
2905 // Enough more to pass the index size: 50 rows × 100 B on top → 10 000 B
2906 // is still under 16 kB, so lift the lens for the last batch instead.
2907 let mut big: Vec<IndexEntry> = all[350..400].to_vec();
2908 for e in &mut big {
2909 e.len = index_bytes; // one row is enough to cross it
2910 }
2911 stack.append(&big[..1]).unwrap();
2912 assert_eq!(stack.stats().folds, folds + 1, "crossing the index size folds");
2913 assert!(stack.projection_is_complete());
2914
2915 // The same triggers on an in-memory stack: the floor alone decides.
2916 let mem = ObjectReadStack::<OneTableFourColumns>::in_memory(triggers).unwrap();
2917 mem.append(&all[..300]).unwrap();
2918 let g = mem.stats().generation;
2919 mem.append(&all[300..350]).unwrap();
2920 assert!(
2921 mem.stats().generation > g,
2922 "without persistence the fixed floor still rebuilds on every batch"
2923 );
2924 assert_eq!(mem.stats().folds, 0, "and nothing is written");
2925 }
2926
2927 /// **`retain` rewrites the persisted index without the dead rows**, whichever
2928 /// layer they were in, and the count it returns is both halves.
2929 ///
2930 /// Seen RED by passing `None` instead of `Some(live)` to `fold_locked` from
2931 /// `retain`: the dead folded oid was still answered after the call, and the
2932 /// count was 1 where 2 rows had died. Restored.
2933 #[test]
2934 fn retain_rewrites_the_persisted_index_without_the_dead_rows() {
2935 let dir = tempfile::tempdir().unwrap();
2936 let all = synthetic_entries(90, 20, 47);
2937 let (tail, index, stack) = folded_then_appended(dir.path(), &all, 40);
2938 let dead_folded = all[5].oid.clone();
2939 let dead_tailed = all[70].oid.clone();
2940 let before = fs::metadata(&index).unwrap().len();
2941 let epoch_before = stack.tail_epoch_and_len().unwrap().0;
2942
2943 let dropped = stack
2944 .retain(&|oid: &[u8]| oid != dead_folded.as_slice() && oid != dead_tailed.as_slice())
2945 .unwrap();
2946 assert_eq!(dropped, 2, "one from each layer");
2947 assert!(stack.lookup(&dead_folded).is_none(), "the folded dead row must be gone");
2948 assert!(stack.lookup(&dead_tailed).is_none(), "the tail dead row must be gone");
2949 assert_eq!(stack.len(), 88);
2950 assert_eq!(stack.projection_len(), 88, "retain folds: everything live is in the index");
2951 assert_eq!(redb_rows(&stack), 0);
2952 assert!(
2953 fs::metadata(&index).unwrap().len() > before,
2954 "the index was rewritten (88 rows now, was 40)"
2955 );
2956 assert!(
2957 stack.tail_epoch_and_len().unwrap().0 > epoch_before,
2958 "a changed oid set is a changed epoch"
2959 );
2960 for e in all.iter().filter(|e| e.oid != dead_folded && e.oid != dead_tailed) {
2961 assert!(stack.lookup(&e.oid).is_some(), "live row {} lost", hex::encode(&e.oid));
2962 }
2963
2964 // And it survives a reopen off the rewritten file.
2965 drop(stack);
2966 let again = ObjectReadStack::<OneTableFourColumns>::open(
2967 &tail,
2968 RebuildTriggers::manual(),
2969 DEFAULT_REDB_CACHE_BYTES,
2970 )
2971 .unwrap();
2972 assert_eq!(again.stats().persisted_loads, 1);
2973 assert_eq!(again.len(), 88);
2974 assert!(again.lookup(&dead_folded).is_none());
2975 }
2976
2977 /// **A damaged index is ignored, loudly, and the store still opens.** The
2978 /// rows redb has are served; the rest are the journal's to re-index. What
2979 /// this asserts is the shape of the failure: open succeeds, the projection
2980 /// is empty, redb's rows answer, and nothing panicked on a half-written file.
2981 ///
2982 /// Seen RED by making `load_persisted` propagate the decode error: the open
2983 /// failed. Restored.
2984 #[test]
2985 fn a_damaged_persisted_index_is_ignored_and_redb_still_answers() {
2986 let dir = tempfile::tempdir().unwrap();
2987 let all = synthetic_entries(60, 20, 48);
2988 let (tail, index, stack) = folded_then_appended(dir.path(), &all, 30);
2989 drop(stack);
2990 // Truncate the index mid-stream: an IPC prefix, not an index.
2991 let bytes = fs::read(&index).unwrap();
2992 fs::write(&index, &bytes[..bytes.len() / 2]).unwrap();
2993
2994 let again = ObjectReadStack::<OneTableFourColumns>::open(
2995 &tail,
2996 RebuildTriggers::manual(),
2997 DEFAULT_REDB_CACHE_BYTES,
2998 )
2999 .unwrap();
3000 assert_eq!(again.stats().persisted_loads, 0, "a damaged index must not count as loaded");
3001 assert_eq!(again.len(), 30, "the 30 redb rows are what the store knows");
3002 for e in &all[30..] {
3003 assert!(again.lookup(&e.oid).is_some(), "redb row {} lost", hex::encode(&e.oid));
3004 }
3005 for e in &all[..30] {
3006 assert!(
3007 again.lookup(&e.oid).is_none(),
3008 "a row only the damaged index held must read absent, so the journal re-indexes it"
3009 );
3010 }
3011 // And the store keeps working: the next fold overwrites the damage.
3012 again.append(&all[..30]).unwrap();
3013 again.rebuild().unwrap();
3014 assert_eq!(again.len(), 60);
3015 drop(again);
3016 let third = ObjectReadStack::<OneTableFourColumns>::open(
3017 &tail,
3018 RebuildTriggers::manual(),
3019 DEFAULT_REDB_CACHE_BYTES,
3020 )
3021 .unwrap();
3022 assert_eq!(third.stats().persisted_loads, 1);
3023 assert_eq!(third.len(), 60);
3024 }
3025
3026 /// **A store from before persistence folds once, at its first open, and
3027 /// never pays the scan again.** Staged by building a redb-only tail (the
3028 /// old shape: every row in redb, no index file) and opening it.
3029 ///
3030 /// Seen RED by removing the cold-open fold in `from_db`: `folds` read 0,
3031 /// the index file was absent, and a second open rebuilt from redb again
3032 /// (`persisted_loads == 0`). Restored.
3033 #[test]
3034 fn a_cold_open_of_a_pre_fold_store_folds_it_once() {
3035 let dir = tempfile::tempdir().unwrap();
3036 let tail = dir.path().join("objects.tail");
3037 let all = synthetic_entries(70, 20, 49);
3038 // The pre-2026-09-04 shape, written by hand: rows in redb, stamped with
3039 // this layout, no index anywhere.
3040 {
3041 let db = Database::create(&tail).unwrap();
3042 let w = db.begin_write().unwrap();
3043 {
3044 let mut objects = w.open_table(OBJECTS).unwrap();
3045 for (i, e) in all.iter().enumerate() {
3046 objects.insert(e.oid.as_slice(), encode_row(i as u64, e).as_slice()).unwrap();
3047 }
3048 let mut meta = w.open_table(META).unwrap();
3049 meta.insert(META_TAIL_FORMAT, TAIL_FORMAT).unwrap();
3050 meta.insert(META_ARRIVAL_SEQ, all.len() as u64).unwrap();
3051 }
3052 w.commit().unwrap();
3053 }
3054 let index = tail.with_extension("index");
3055 assert!(!index.exists(), "precondition: no persisted index");
3056
3057 let stack = ObjectReadStack::<OneTableFourColumns>::open(
3058 &tail,
3059 RebuildTriggers::manual(),
3060 DEFAULT_REDB_CACHE_BYTES,
3061 )
3062 .unwrap();
3063 assert_eq!(stack.stats().persisted_loads, 0, "a cold open has nothing to map");
3064 assert_eq!(stack.stats().folds, 1, "and folds exactly once");
3065 assert!(index.is_file(), "the fold must have written {}", index.display());
3066 assert_eq!(redb_rows(&stack), 0, "redb is emptied by the migration fold");
3067 assert_eq!(stack.len(), 70);
3068 assert!(stack.projection_is_complete());
3069 for e in &all {
3070 assert!(stack.lookup(&e.oid).is_some(), "migrated row {} lost", hex::encode(&e.oid));
3071 }
3072 drop(stack);
3073
3074 let again = ObjectReadStack::<OneTableFourColumns>::open(
3075 &tail,
3076 RebuildTriggers::manual(),
3077 DEFAULT_REDB_CACHE_BYTES,
3078 )
3079 .unwrap();
3080 assert_eq!(again.stats().persisted_loads, 1, "the second open is warm");
3081 assert_eq!(again.stats().folds, 0);
3082 assert_eq!(again.len(), 70);
3083 }
3084
3085 /// **A migrated store gets its redb file back.** A pre-fold store big enough
3086 /// to matter (here 200 k rows, ~34 MB of redb) is opened once: the migration
3087 /// fold empties redb, and the open compacts the file because it now carries
3088 /// more than [`COMPACT_FRAGMENTED_FLOOR`] of dead pages. Asserted on the
3089 /// file's size on disk, not on a flag. A small store (the 70-row fixture next
3090 /// door) is deliberately left alone — that case is covered by the floor, and
3091 /// this test's precondition asserts the fixture is above it.
3092 ///
3093 /// Seen RED by making `compact_if_fragmented` return before `db.compact()`:
3094 /// "the migrated redb must shrink" with equal before/after sizes. Restored.
3095 #[test]
3096 fn a_migrated_store_gets_its_redb_file_back() {
3097 let dir = tempfile::tempdir().unwrap();
3098 let tail = dir.path().join("objects.tail");
3099 let n = 200_000usize;
3100 let all = synthetic_entries(n, 20, 53);
3101 {
3102 let db = Database::create(&tail).unwrap();
3103 let w = db.begin_write().unwrap();
3104 {
3105 let mut objects = w.open_table(OBJECTS).unwrap();
3106 for (i, e) in all.iter().enumerate() {
3107 objects.insert(e.oid.as_slice(), encode_row(i as u64, e).as_slice()).unwrap();
3108 }
3109 let mut meta = w.open_table(META).unwrap();
3110 meta.insert(META_TAIL_FORMAT, TAIL_FORMAT).unwrap();
3111 meta.insert(META_ARRIVAL_SEQ, n as u64).unwrap();
3112 }
3113 w.commit().unwrap();
3114 }
3115 let before = fs::metadata(&tail).unwrap().len();
3116 assert!(
3117 before >= COMPACT_FRAGMENTED_FLOOR,
3118 "precondition: the fixture must be above the compaction floor, is {before} B"
3119 );
3120
3121 let stack = ObjectReadStack::<OneTableFourColumns>::open(
3122 &tail,
3123 RebuildTriggers::manual(),
3124 DEFAULT_REDB_CACHE_BYTES,
3125 )
3126 .unwrap();
3127 assert_eq!(stack.stats().folds, 1, "the migration fold ran");
3128 assert_eq!(redb_rows(&stack), 0);
3129 let after = fs::metadata(&tail).unwrap().len();
3130 assert!(
3131 after < before / 4,
3132 "the migrated redb must shrink: {after} >= {before} / 4"
3133 );
3134 // And it still works, in both directions.
3135 assert!(stack.lookup(&all[123].oid).is_some());
3136 stack.append(&synthetic_entries(10, 20, 54)).unwrap();
3137 assert_eq!(redb_rows(&stack), 10);
3138 }
3139
3140 /// The packed tail row rejects the two things that would make it silently
3141 /// wrong: a short buffer and a type code git does not use.
3142 ///
3143 /// Seen RED by dropping the length check from `decode_row`: the short-buffer
3144 /// case panicked — "range end index 33 out of range for slice of length 32"
3145 /// — instead of returning an error. Restored.
3146 ///
3147 /// Seen RED for the `delta_base` field by decoding it from byte 25 (where
3148 /// `uncompressed_size` starts) instead of byte 33: "the delta base did not
3149 /// survive the tail row / left: 2448 / right: 4242" — it came back holding
3150 /// the *size*. Both are plausible u64s, so only a comparison against the
3151 /// entry that was encoded can tell them apart. Restored.
3152 ///
3153 /// A row written by the 33-byte encoding is a **length** error here rather
3154 /// than a misread: `decode_row` refuses anything that is not exactly
3155 /// `TAIL_ROW_BYTES`, and the assertion below is the same check the old width
3156 /// now trips.
3157 #[test]
3158 fn a_packed_tail_row_round_trips_and_refuses_nonsense() {
3159 let mut e = synthetic_entries(1, 20, 51)[0].clone();
3160 // A one-entry workload has nothing to delta against, so the base is set
3161 // here: a round trip that only ever saw 0 would not test the field.
3162 e.delta_base = 4242;
3163 let e = &e;
3164 let packed = encode_row(9, e);
3165 let back = decode_row(&packed).unwrap();
3166 assert_eq!(back.seq, 9);
3167 assert_eq!(back.offset, e.offset);
3168 assert_eq!(back.len, e.len);
3169 assert_eq!(back.obj_type, e.obj_type);
3170 assert_eq!(back.uncompressed_size, e.uncompressed_size);
3171 assert_eq!(
3172 back.delta_base, e.delta_base,
3173 "the delta base did not survive the tail row"
3174 );
3175 assert_ne!(
3176 back.delta_base, back.uncompressed_size,
3177 "the fixture must not let the two u64 fields alias"
3178 );
3179
3180 assert!(decode_row(&packed[..TAIL_ROW_BYTES - 1]).is_err());
3181 // 33 bytes is exactly the pre-`delta_base` row width, and it is refused
3182 // rather than read as a row with the field missing.
3183 assert!(
3184 decode_row(&packed[..33]).is_err(),
3185 "a row in the old 33-byte encoding must be refused, not reinterpreted"
3186 );
3187 let mut bad = packed;
3188 bad[24] = 5; // git uses 1-4, 6, 7; 5 is unassigned
3189 assert!(
3190 decode_row(&bad).is_err(),
3191 "an unassigned object type code was accepted"
3192 );
3193 }
3194
3195 /// The stack is the same index as the two Arrow arms once it is fully
3196 /// absorbed, which is what lets it be dropped into
3197 /// `examples/index_layout_bench.rs` unchanged.
3198 ///
3199 /// Seen RED by having `ObjectIndex::build` skip its final `rebuild()`, so
3200 /// every row was served from the tail instead of from the projection:
3201 /// "assertion `left == right` failed: build left rows in the tail / left: 0
3202 /// / right: 400". Restored.
3203 #[test]
3204 fn a_fully_absorbed_stack_agrees_with_both_arrow_arms() {
3205 let entries = synthetic_entries(400, 20, 61);
3206 let absent = synthetic_entries(100, 20, 62);
3207 let stack = Stack::build(&entries).unwrap();
3208 let a = FourTables::build(&entries).unwrap();
3209 let b = OneTableFourColumns::build(&entries).unwrap();
3210
3211 assert_eq!(stack.len(), 400);
3212 assert_eq!(stack.projection_len(), 400, "build left rows in the tail");
3213
3214 let mut refs: Vec<&[u8]> = entries.iter().map(|e| e.oid.as_slice()).collect();
3215 refs.extend(absent.iter().map(|e| e.oid.as_slice()));
3216 let rs = stack.lookup_batch(&refs);
3217 assert_eq!(
3218 rs,
3219 a.lookup_batch(&refs),
3220 "the stack disagrees with FourTables"
3221 );
3222 assert_eq!(
3223 rs,
3224 b.lookup_batch(&refs),
3225 "the stack disagrees with OneTableFourColumns"
3226 );
3227 assert_eq!(
3228 stack.stats().tail_hits,
3229 0,
3230 "a fully absorbed stack still went to the tail for a row"
3231 );
3232 assert_eq!(stack.stats().absent, 100);
3233 }
3234
3235 /// **A complete projection answers an absence without opening a redb
3236 /// transaction at all** — and the moment one row is appended, it stops doing
3237 /// that.
3238 ///
3239 /// This is the fast path the bench found was worth having: a `have`
3240 /// negotiation is mostly misses, and paying a redb round trip for each of
3241 /// them made the stack six times the cost of the bare Arrow arm on the most
3242 /// common git operation. `tail_txns` is the applied output — an assertion on
3243 /// timing would be a flake, and an assertion on the returned rows cannot
3244 /// distinguish the two paths, because both return the same rows. That is the
3245 /// point: the fast path is only allowed to exist because it is
3246 /// indistinguishable in its answers.
3247 ///
3248 /// Seen RED by deleting the `projection_is_complete()` branch from
3249 /// `lookup_batch`: "assertion `left == right` failed: a complete projection
3250 /// opened 1 redb transactions to say 'no' / left: 1 / right: 0". Restored.
3251 ///
3252 /// Seen RED the other way — the dangerous way — by making
3253 /// `projection_is_complete` return `true` unconditionally: the
3254 /// `the_projection_is_incomplete_but_never_wrong` test failed with "the
3255 /// stack lost tail object …, batch path", because an incomplete projection
3256 /// then declared its misses absent. Restored.
3257 #[test]
3258 fn a_complete_projection_answers_absent_without_asking_the_tail() {
3259 let entries = synthetic_entries(200, 20, 91);
3260 let nowhere = synthetic_entries(500, 20, 92);
3261 let stack = Stack::build(&entries).unwrap();
3262 assert!(stack.projection_is_complete());
3263
3264 let refs: Vec<&[u8]> = nowhere.iter().map(|e| e.oid.as_slice()).collect();
3265 assert!(stack.lookup_batch(&refs).iter().all(Option::is_none));
3266 assert!(stack.lookup(&nowhere[0].oid).is_none());
3267 assert!(stack.ordinals_batch(&refs).iter().all(Option::is_none));
3268 assert!(stack.extents_batch(&refs).iter().all(Option::is_none));
3269
3270 let st = stack.stats();
3271 assert_eq!(st.unabsorbed_rows, 0);
3272 assert_eq!(
3273 st.tail_txns, 0,
3274 "a complete projection opened {} redb transactions to say 'no'",
3275 st.tail_txns
3276 );
3277 assert_eq!(st.absent, 500 + 1 + 500 + 500, "absences went uncounted");
3278
3279 // One appended row and the fall-through is on again — the fast path is
3280 // conditional on completeness and not on having been complete once.
3281 let more = synthetic_entries(1, 20, 93);
3282 stack.append(&more).unwrap();
3283 assert!(!stack.projection_is_complete());
3284 assert!(stack.lookup_batch(&refs).iter().all(Option::is_none));
3285 assert!(
3286 stack.stats().tail_txns > 0,
3287 "an incomplete projection did not consult the tail"
3288 );
3289 assert!(
3290 stack.lookup(&more[0].oid).is_some(),
3291 "the appended row is not reachable"
3292 );
3293 }
3294
3295 /// **A tail written in a different layout is DROPPED and rebuilt, not
3296 /// refused for ever.**
3297 ///
3298 /// `decode_row` refuses an unknown row width on purpose — reinterpreting one
3299 /// puts a wrong offset into an index that is then queried and believed. But
3300 /// until `META_TAIL_FORMAT` existed the refusal had nothing behind it: every
3301 /// read `bail`ed and the only way back was deleting the repository.
3302 ///
3303 /// The tail is a cache, so the way back is to empty it and let
3304 /// `Absorber::adopt_journal` requeue every pack — the same path a crash
3305 /// mid-absorb already takes.
3306 ///
3307 /// RED-FIRST: with the version check removed, the reopened stack keeps the
3308 /// 90 foreign rows and the first `get` through them fails on the row width
3309 /// instead. The assertion is on the APPLIED state — the row count and a
3310 /// working `append`/`get` afterwards — not on a returned flag.
3311 #[test]
3312 fn a_tail_in_a_foreign_layout_is_dropped_and_the_store_keeps_working() {
3313 let dir = tempfile::tempdir().unwrap();
3314 let path = dir.path().join("objects.tail.redb");
3315 let all = synthetic_entries(90, 20, 41);
3316 {
3317 let stack = ObjectReadStack::<OneTableFourColumns>::open(
3318 &path,
3319 RebuildTriggers::manual(),
3320 DEFAULT_REDB_CACHE_BYTES,
3321 )
3322 .unwrap();
3323 stack.append(&all).unwrap();
3324 assert_eq!(stack.tail_epoch_and_len().unwrap().1, 90);
3325 }
3326
3327 // Stamp a version this build does not know, exactly as a future layout
3328 // change would leave behind.
3329 {
3330 let db = Database::create(&path).unwrap();
3331 let w = db.begin_write().unwrap();
3332 {
3333 let mut meta = w.open_table(META).unwrap();
3334 meta.insert(META_TAIL_FORMAT, TAIL_FORMAT + 7).unwrap();
3335 }
3336 w.commit().unwrap();
3337 }
3338
3339 let stack = ObjectReadStack::<OneTableFourColumns>::open(
3340 &path,
3341 RebuildTriggers::manual(),
3342 DEFAULT_REDB_CACHE_BYTES,
3343 )
3344 .unwrap();
3345 assert_eq!(
3346 stack.tail_epoch_and_len().unwrap().1,
3347 0,
3348 "a foreign-layout tail was kept; the next read would bail on the row width"
3349 );
3350 assert_eq!(stack.projection_len(), 0);
3351
3352 // And the store is USABLE, which is the half that says "dropped" and not
3353 // "broken": the same oids re-appended come back.
3354 stack.append(&all).unwrap();
3355 assert_eq!(stack.tail_epoch_and_len().unwrap().1, 90);
3356 let got = stack.lookup(&all[7].oid).expect("re-appended row");
3357 assert_eq!(got.offset, all[7].offset);
3358
3359 // The stamp is now this build's, so a second reopen drops nothing.
3360 drop(stack);
3361 let again = ObjectReadStack::<OneTableFourColumns>::open(
3362 &path,
3363 RebuildTriggers::manual(),
3364 DEFAULT_REDB_CACHE_BYTES,
3365 )
3366 .unwrap();
3367 assert_eq!(
3368 again.tail_epoch_and_len().unwrap().1,
3369 90,
3370 "the rewritten version did not stick, so every open would drop the tail"
3371 );
3372 }
3373
3374 /// A tail written BEFORE the version key existed carries no stamp, and must
3375 /// be adopted rather than dropped: it holds rows of exactly this layout, and
3376 /// rebuilding every deployed store for a key that was merely absent would be
3377 /// a self-inflicted re-index of the whole fleet.
3378 #[test]
3379 fn a_tail_with_no_version_stamp_is_adopted_and_stamped_not_dropped() {
3380 let dir = tempfile::tempdir().unwrap();
3381 let path = dir.path().join("objects.tail.redb");
3382 let all = synthetic_entries(50, 20, 41);
3383 {
3384 let stack = ObjectReadStack::<OneTableFourColumns>::open(
3385 &path,
3386 RebuildTriggers::manual(),
3387 DEFAULT_REDB_CACHE_BYTES,
3388 )
3389 .unwrap();
3390 stack.append(&all).unwrap();
3391 }
3392 // Remove the stamp: this is what every tail on disk looked like before
3393 // the key existed.
3394 {
3395 let db = Database::create(&path).unwrap();
3396 let w = db.begin_write().unwrap();
3397 {
3398 let mut meta = w.open_table(META).unwrap();
3399 meta.remove(META_TAIL_FORMAT).unwrap();
3400 }
3401 w.commit().unwrap();
3402 }
3403 let stack = ObjectReadStack::<OneTableFourColumns>::open(
3404 &path,
3405 RebuildTriggers::manual(),
3406 DEFAULT_REDB_CACHE_BYTES,
3407 )
3408 .unwrap();
3409 assert_eq!(stack.tail_epoch_and_len().unwrap().1, 50, "an unstamped tail was dropped");
3410 let r = stack.tail.begin_read().unwrap();
3411 let meta = r.open_table(META).unwrap();
3412 assert_eq!(
3413 meta.get(META_TAIL_FORMAT).unwrap().map(|v| v.value()),
3414 Some(TAIL_FORMAT),
3415 "the tail was adopted but not stamped, so it is re-examined for ever"
3416 );
3417 }
3418}