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