Skip to main content

znippy_plugin_git/
index_layout.rs

1//! One git object index, **three Arrow IPC layouts**, measured against each other.
2//!
3//! ## The question
4//!
5//! A git `.pack` carries no index at all. Git derives several *separate* files
6//! from it — `.idx` (oid → offset), `.rev` (offset → length), `.bitmap`,
7//! `.midx` — and two of the four facts a server actually needs on the hot path
8//! are in **none** of them:
9//!
10//! | fact | where git keeps it |
11//! |---|---|
12//! | oid | `.idx` |
13//! | byte extent (offset, len) | offset in `.idx`, length only via `.rev` or the next offset |
14//! | **type** | nowhere — an entry-header parse per object |
15//! | **uncompressed size** | nowhere — the same entry-header parse |
16//! | **delta base** | nowhere — the same entry-header parse, plus a varint |
17//!
18//! ## `delta_base` is an OFFSET, and the arms are named for what they were
19//!
20//! PLAN §13 settles it: `delta_base` is a `u64` **archive offset**, not a row
21//! ordinal into this table. An ordinal would be half the width and would index
22//! the very table it sits in — and it would break **silently** the first time
23//! the table is rebuilt in a different order, which §14 guarantees will happen
24//! because the whole index is a droppable cache. An offset is order-independent
25//! because the pack it addresses never moves; `PackWalk::rebased` shifts it with
26//! one addition and no table to keep in step. `0` is the sentinel for *no base*:
27//! archive offset 0 is a pack's `PACK` magic, so no entry can start there.
28//!
29//! It joined the schema after the three arms were named and measured, so the
30//! names now count **sections and tables, which is the variable under test** —
31//! not facts. [`FourTables`] is four IPC sections, [`OneTableFourColumns`] is
32//! one; both carry **five** payload columns today, and [`PackedPayload`] still
33//! carries one. Every ns figure quoted below was taken on the four-fact,
34//! 25-byte schema and is marked where that matters.
35//!
36//! So `cat-file --batch-check` over a pack is a varint decode per object, and a
37//! `have` negotiation of 1000 oids is 1000 of them. Consolidating all four facts
38//! into one index is the point of this module. The open question is what
39//! *shape* that index should have, and this module answers it by building three
40//! shapes behind [`ObjectIndex`] and benchmarking them
41//! (`examples/index_layout_bench.rs`).
42//!
43//! | arm | payload columns | buffers a full-row fetch touches |
44//! |---|---:|---:|
45//! | [`FourTables`] | 5, in four IPC sections | 5 |
46//! | [`OneTableFourColumns`] | 5, in one IPC section | 5 |
47//! | [`PackedPayload`] | **1**, in one IPC section | **1** |
48//!
49//! The first two were the original experiment, and they **tied at every size,
50//! batch size and hit rate**. The reason is visible in the table: Arrow is
51//! columnar, so "one table with five columns" is *not* row-contiguous. Both
52//! arms keep `offset`, `len`, `object_type`, `uncompressed_size` and
53//! `delta_base` in five separate buffers whose bases are megabytes apart
54//! (measured: 1 737 664 bytes between the `offset` and `size` buffers at
55//! 100 000 rows), so a full-row fetch costs five unrelated strides either way.
56//! **The number of tables was never the variable. The number of columns is** —
57//! which is what the third arm changes, and it is the only one that moves the
58//! number.
59//!
60//! ## What is deliberately shared, so the measurement is of the layout
61//!
62//! All three arms resolve `oid → ordinal` through the **same** [`crate::oid_index`]
63//! `stree` (LAW 5: reuse, do not twin — and a third oid index written by
64//! accident would have made the arms incomparable anyway). The two columnar arms
65//! share one `ColumnarPayload` holding every read path, so they cannot drift
66//! apart in the hot loop and the only thing between them is IPC framing.
67//!
68//! ## Zero-copy is asserted, not assumed
69//!
70//! All three arms decode with `StreamDecoder::with_require_alignment(true)`,
71//! which makes arrow **error** rather than silently allocate-and-copy when a
72//! buffer is misaligned. A layout that quietly copied its columns out of the IPC
73//! bytes on open would be a different (and much slower to build) thing than the
74//! one being claimed, so the reader refuses to be that thing.
75//! [`ObjectIndex::ipc_bytes`] plus each arm's `column_is_inside_ipc` let a test
76//! prove each array's data pointer actually lies inside the IPC buffer.
77//!
78//! # Measured
79//!
80//! oden, 32 cores, 2026-08-07, `--release --no-default-features`, sha1 oids,
81//! 100 000 lookups per cell, 5 runs per cell, 4 sizes × 3 hit rates × 7 access
82//! paths + 2 column scans. `/proc/loadavg` 0.96–2.98 (1-min) across the three
83//! sweeps — the first rotation started at 2.98, the other two at 1.53 and 1.35,
84//! and the reported figures are the geometric mean of all three, so no arm sat
85//! disproportionately in the busier one.
86//!
87//! Position is cancelled by **rotation**: the sweep is run three times with the
88//! arm order rotated, so each arm occupies each position exactly once. This
89//! matters — in the earlier two-arm work the arm timed second was measurably
90//! 1.1% slower at N ≥ 1e6 whichever layout it was, which is enough to invent a
91//! result out of nothing.
92//!
93//! **Noise band: median 8.9% run-to-run spread, p90 32.3%** over 270 cells
94//! (excluding the 1e3 scans, whose ns-per-row is below timer resolution). The
95//! p90 is carried almost entirely by the 100 000-object rows, where the index
96//! fits in L3; at 1e3 and 4e6 the bands are 2–20%. **Nothing below is claimed
97//! unless it clears its own cell's band.**
98//!
99//! ## First: the harness really does read all four facts
100//!
101//! This has to be established before any of the rest means anything. A harness
102//! that resolved the oid and threw the row away would measure the `stree` and
103//! nothing else, and would produce a tie no matter what the layouts did.
104//!
105//! [`ObjectIndex::ordinals_batch`] exists for exactly this: it resolves
106//! `oid → ordinal` and stops, touching no payload byte, and it is the same code
107//! in all three arms. Subtracting it from the full-row path isolates the payload
108//! gather — the only part any of these layouts can change:
109//!
110//! | objects | mix | stree floor | A payload | B payload | **C payload** | C/B |
111//! |---:|---|---:|---:|---:|---:|---:|
112//! | 1e3 | 100% hit | 50.3 | 6.3 | 6.1 | **1.5** | 0.24 |
113//! | 1e3 | 10% hit | 39.1 | 5.3 | 5.1 | **1.8** | 0.34 |
114//! | 1e5 | 50% hit | 58.6 | 15.0 | 14.0 | **10.6** | 0.76 |
115//! | 1e6 | 100% hit | 195.8 | 45.0 | 46.2 | **27.7** | 0.60 |
116//! | 1e6 | 10% hit | 113.4 | 11.4 | 11.1 | **7.2** | 0.65 |
117//! | 4e6 | 100% hit | 247.2 | 39.2 | 43.1 | **29.8** | 0.69 |
118//! | 4e6 | 50% hit | 199.6 | 30.9 | 27.9 | **20.9** | 0.75 |
119//! | 4e6 | 10% hit | 141.3 | 25.1 | 21.7 | **17.7** | 0.81 |
120//!
121//! ns per lookup, batch 1000, position-cancelled. The payload column is nowhere
122//! near zero and it differs between arms, so the facts are being read. It is
123//! **9–28% of a full-row lookup**; the shared `stree` is the other 72–91%.
124//!
125//! ## The owner was right: one column IS faster to fetch than four
126//!
127//! On the part of the work the layout controls, `PackedPayload` cuts the payload
128//! gather by **19–76%**, and by **25–40% at every size from 1e6 up**. One
129//! 25-byte stride against four strides megabytes apart, exactly as predicted.
130//! The single cell that does not show it (1e5, 100% hit, C/B 1.06) sits in the
131//! noisiest regime in the sweep, band 44%.
132//!
133//! Those cells are the **four-fact** schema. `delta_base` makes the packed
134//! record 33 bytes and gives the columnar arms a fifth stride, so the mechanism
135//! points the same way and the magnitudes are stale until re-measured. See
136//! "What the fifth column costs" below for the measurement that was actually
137//! taken after the column landed.
138//!
139//! **But the win is capped by Amdahl**, and this is the honest headline: the
140//! payload gather is only 9–28% of a lookup, so a 31% cut in it is a **4–8% cut
141//! end to end**. At 4e6 objects, 100% hit, position-cancelled `C/B`:
142//!
143//! | path | A ns | B ns | C ns | C/B | band | |
144//! |---|---:|---:|---:|---:|---:|---|
145//! | ordinals only (the floor) | 247.2 | 245.1 | 244.6 | 0.998 | 4.9% | tie, as it must be |
146//! | full row, serial `lookup` | 464.8 | 471.8 | 435.2 | **0.922** | 7.7% | **C faster** |
147//! | full row, batch 1 | 824.2 | 811.9 | 757.4 | 0.933 | 11.4% | inside band |
148//! | full row, batch 100 | 298.6 | 299.0 | 283.4 | **0.948** | 5.2% | **C faster** |
149//! | full row, batch 1000 | 286.4 | 288.3 | 274.4 | **0.952** | 2.8% | **C faster** |
150//! | full row, batch 10000 | 284.4 | 283.3 | 271.5 | 0.959 | 4.8% | inside band |
151//! | extents only, batch 1000 | 261.5 | 263.0 | 264.2 | 1.005 | 4.0% | tie |
152//!
153//! Three of the four full-row cells clear their band; the direction is the same
154//! in all seven and at every size. At 1e3 the win is larger and cleaner (C/B
155//! 0.898–0.931 at batch ≥ 100, bands 1.8–2.8%) because there the `stree` floor
156//! is small enough not to swamp it.
157//!
158//! **`extents` is a tie, and that is the mechanism confirming itself.** A
159//! partial-row fetch of two facts lets the columnar arms read two columns
160//! instead of four, while the packed arm reads the same 25 bytes it always
161//! reads. The advantage is exactly proportional to how much of the row you want,
162//! and at half a row it is gone.
163//!
164//! ## What the fifth column costs
165//!
166//! **Footprint: exactly 8 bytes per object, in every arm.** This is arithmetic,
167//! not an estimate, and the byte-exact guards below hold it there: the packed
168//! record goes 25 → 33 B, and each columnar arm gains one `u64` buffer of `8n`
169//! bytes. 800 kB per 100 000 objects; 32 MB at 4e6. The only *relative* change
170//! between arms is that `PackedPayload` now saves four per-column IPC buffers
171//! instead of three — MEASURED 50 560 B at 100 000 rows against the 38 016 B it
172//! saved before (`the_packed_arm_holds_the_same_payload_but_a_smaller_section`,
173//! which asserts the scaling law rather than the constant).
174//!
175//! **Latency: NOT re-measured, and no claim is made.** Two reasons, and the
176//! second is the one that will still be true tomorrow.
177//!
178//! Load was the first: the harness refuses above 1-minute load 4.0 and oden was
179//! carrying another agent's build at 9–26 for most of this change. That one
180//! cleared on its own (it fell to 2.1), so it is not the reason this is still
181//! unmeasured.
182//!
183//! **A before/after of a znippy change cannot be built in this tree, and that is
184//! structural.** A before/after needs the old and the new source resolved at the
185//! same time, which needs two znippy checkouts, and there is nowhere to put the
186//! second one — MEASURED by trying all three placements:
187//!
188//! | where the second checkout goes | what happens |
189//! |---|---|
190//! | anywhere under `~/git` | `package collision in the lockfile: znippy-common v0.9.13 (…/znippy-pre-deltabase) and znippy-common v0.9.13 (…/znippy) are different` |
191//! | anywhere outside it | `failed to read …/znippy-zoomies/lbzip2/Cargo.toml` |
192//!
193//! The cycle is the cause, not the placement: znippy's `xtask` and `tests`
194//! depend on `../../nornir`, and `nornir` depends back on
195//! `../znippy/znippy-common` — an absolute reference to *the* canonical
196//! checkout. So a second checkout drags the canonical one into its own resolve
197//! and two different copies of one version land in one lockfile. Move it out of
198//! `~/git` to escape that and it loses the `../../znippy-zoomies/…`
199//! path dependencies instead. Symlinking the siblings back does not help: cargo
200//! canonicalises, so `nornir` resolves to the real one and points at the real
201//! znippy again.
202//!
203//! Anyone wanting this number should therefore plan on **one checkout, timed
204//! twice** — build the bench, `git checkout` the other revision of these files
205//! in place, build again — and should not spend the hour discovering the above.
206//! That mutates a shared checkout for as long as it takes to compile, which is
207//! why it was not done here while another agent was working in this crate.
208//!
209//! What can be said without measuring, and is deliberately weaker than a
210//! number: the payload gather is 9–28% of a lookup, a columnar full-row fetch
211//! goes from four strides to five, and the packed record goes from ~1.4 cache
212//! lines to ~1.5. Both arms get slightly worse and the packed arm's *relative*
213//! advantage on full-row fetches should narrow a little. An 8-byte-per-row
214//! effect is in any case inside the 8.9% median / 32.3% p90 band this sweep
215//! already reports, so the honest expectation is a null result rather than a
216//! regression. **Re-run `examples/index_layout_bench.rs` on a quiet box before
217//! quoting any of the ns tables above as current.**
218//!
219//! ## …and it loses the scans, by a lot
220//!
221//! ns per row, full column scan, position-cancelled:
222//!
223//! | objects | scan | A | B | **C** | C/B |
224//! |---:|---|---:|---:|---:|---:|
225//! | 4e6 | `sum_uncompressed` (uses 8 B of every row) | 0.36 | 0.36 | **1.19** | **3.3×** |
226//! | 4e6 | `count_type` (uses 1 B of every row) | 0.09 | 0.09 | **1.32** | **14.4×** |
227//! | 1e6 | `count_type` | 0.09 | 0.09 | **1.27** | **14.0×** |
228//! | 1e5 | `count_type` | 0.09 | 0.09 | **0.51** | **5.5×** |
229//!
230//! The 4e6 rows clear their bands (4.5% and 34.7%) with room to spare, and the
231//! direction is identical at every size. This is the trade stated in
232//! [`PackedPayload`]'s docs, landing exactly where predicted: `count_type` drags
233//! 25 bytes through cache to use one, so it pays ~14× for the privilege of the
234//! 5% it won on full-row fetches. A quota gate or a type histogram over a
235//! 4-million-object repository is 5 ms on the columnar arms and 5 ms × 3–14 on
236//! this one.
237//!
238//! ## The other figures
239//!
240//! | | FourTables | OneTableFourColumns | PackedPayload |
241//! |---|---:|---:|---:|
242//! | resident (IPC + stree), 4e6 | 326.635 MiB | 326.634 MiB | 325.203 MiB |
243//! | build, 4e6 objects | 3.956 s | 4.052 s | 3.975 s |
244//!
245//! * **A vs B is still a tie**, now confirmed with a harness that provably reads
246//!   the payload: `A/B` ranges 0.96–1.06 and every cell is inside its band. Four
247//!   sections cost exactly **1048 bytes more than one, constant at every size** —
248//!   the framing of three extra IPC streams, 0.0003% at 4e6.
249//! * **`PackedPayload` is the smallest on disk**, by three per-column IPC
250//!   buffers: 38 016 B at 100 000 rows, 75 456 B at 200 000, i.e. `3 · n/8`. Its
251//!   *payload* is byte-identical (25 = 8 + 8 + 1 + 8).
252//! * **Build is a tie across all three** — within 2.5% at 4e6, inside the band.
253//!   Build is the oid sort and the `stree`, not the framing or the packing.
254//!
255//! ## What this means for the shape of the index
256//!
257//! Neither the packed arm nor the columnar ones is right everywhere, and the
258//! measurement says which is which rather than leaving it to taste:
259//!
260//! * The negotiation path (`want`/`have`, full row) is **5% faster packed**, and
261//!   the ceiling on that is the `stree`, not the payload — the next real win on
262//!   that path is in the oid step, not here.
263//! * The wire path (`extents`) is **indifferent**.
264//! * Analytics (quota gates, type histograms) are **3–14× slower packed**.
265//! * The packed arm gives up being four typed Arrow columns. A
266//!   `FixedSizeBinary(25)` is opaque to DuckDB / Polars / DataFusion, which can
267//!   query the other two arms straight off the IPC bytes with no consumer code.
268//!   That is a real cost and it is not a performance one.
269//!
270//! ## The stree header was the next experiment, and it was a negative result
271//!
272//! What this sweep *can* say, which the two-arm version could not, is that
273//! **the oid step is where the remaining time is**: the `ordinals_batch` floor is
274//! 72–91% of every full-row lookup, and it is identical in all three arms. The
275//! floor itself grows 39 ns → 247 ns from 1e3 to 4e6 objects, which is cache and
276//! TLB behaviour in the `stree`, not in any payload column.
277//!
278//! That pointed at `oid_index`'s 24-byte header (24 mod 64 = 24, so the key
279//! array is off-line by construction). It has now been built and measured —
280//! [`crate::oid_index::OidLayout`] and `examples/oid_align_bench.rs`, three arms
281//! separating the header offset from the allocator. **A 64-aligned keyspace
282//! takes 5.2% off cache-misses, reproducibly, and does not move the clock at
283//! all**: 0 of 24 cells clear their band, and 0 of 6 clear it again at 4e6 with
284//! the band tightened to 5%. The header is not where the oid step's time is, and
285//! the reason is instructive — `stree`'s internal nodes live in the tree's own
286//! `Vec`, not in this section, so the header could only ever move the leaf
287//! touch, and the pipelined batch walk was already hiding it. The full numbers
288//! and the mechanism are in [`crate::oid_index`].
289
290use std::sync::Arc;
291
292use anyhow::{Result, anyhow, bail};
293use znippy_common::arrow::array::{
294    Array, FixedSizeBinaryArray, UInt8Array, UInt64Array,
295};
296use znippy_common::arrow::buffer::{Buffer, MutableBuffer, ScalarBuffer};
297use znippy_common::arrow::datatypes::{DataType, Field, Schema, SchemaRef};
298use znippy_common::arrow::ipc::reader::StreamDecoder;
299use znippy_common::arrow::ipc::writer::StreamWriter;
300use znippy_common::arrow::record_batch::RecordBatch;
301
302use crate::object::GitHashKind;
303use crate::oid_index::{GitOidIndex, OidEntry, OidLayout, build_section_with_layout};
304
305// ── the four facts ────────────────────────────────────────────────────────────
306
307/// Object type, in git's own **pack entry** encoding.
308///
309/// This is not [`crate::object::GitObjectKind`] and must not be merged with it:
310/// that enum is the four *loose* object types, which is all a canonical
311/// `"<type> <size>\0…"` header can express. A packed object also comes in the
312/// two delta forms. Owned by the `git-storage-trait` contract; re-exported here
313/// so `crate::index_layout::ObjType` stays a valid path.
314pub use git_storage_trait::ObjType;
315
316/// One object, as the caller hands it to [`ObjectIndex::build`]. Order is
317/// irrelevant — every implementation sorts by oid and derives ordinals itself.
318#[derive(Debug, Clone, PartialEq, Eq)]
319pub struct IndexEntry {
320    /// 20 bytes (sha1) or 32 (sha256). All entries in one index must agree.
321    pub oid: Vec<u8>,
322    /// Start of the object's bytes within the archive.
323    pub offset: u64,
324    /// Length of those bytes as stored (compressed / delta-encoded).
325    pub len: u64,
326    pub obj_type: ObjType,
327    /// Inflated, post-delta-resolution size. The fact `.idx` and `.rev`
328    /// together still cannot answer.
329    pub uncompressed_size: u64,
330    /// `objects.delta_base` — the **archive offset** of the entry this one
331    /// deltas against, `0` for none (PLAN §13).
332    ///
333    /// An offset and not an ordinal, on purpose: it is in the same coordinate
334    /// space as [`offset`](Self::offset), so it survives a rebuild of this table
335    /// in any order and a rebase of the pack is one addition
336    /// ([`crate::pack_walk::PackWalk::rebased`]). `0` cannot collide with a real
337    /// base because archive offset 0 is a pack's `PACK` magic. A `REF_DELTA`
338    /// records `0` until its oid is resolved to an offset —
339    /// [`obj_type`](Self::obj_type) is what distinguishes that from *no base*.
340    pub delta_base: u64,
341}
342
343/// What a lookup resolves to: the ordinal plus all five facts.
344#[derive(Debug, Clone, Copy, PartialEq, Eq)]
345pub struct IndexRow {
346    /// Row ordinal — the oid-lexicographic rank, and the join key `FourTables`
347    /// uses across its four sections.
348    pub ordinal: u32,
349    pub offset: u64,
350    pub len: u64,
351    pub obj_type: ObjType,
352    pub uncompressed_size: u64,
353    /// See [`IndexEntry::delta_base`]. An archive offset, `0` for none — never
354    /// an ordinal, and in particular never *this* row's ordinal space.
355    pub delta_base: u64,
356}
357
358// ── the trait ─────────────────────────────────────────────────────────────────
359
360/// A git object index over Apache Arrow IPC.
361///
362/// `build` is `where Self: Sized`, so it stays out of the vtable and
363/// `Box<dyn ObjectIndex>` still works — which is how the bench drives both arms
364/// through one loop.
365pub trait ObjectIndex: Send + Sync {
366    fn build(entries: &[IndexEntry]) -> Result<Self>
367    where
368        Self: Sized;
369
370    /// Resolve one oid. `None` for an absent oid **and** for an oid of the
371    /// wrong width.
372    fn lookup(&self, oid: &[u8]) -> Option<IndexRow>;
373
374    /// The batch path, and the one that matters: `have` negotiation sends up to
375    /// 1000 oids at a time, the push connectivity check sends thousands.
376    /// Positional — `out[i]` answers `oids[i]`.
377    ///
378    /// This is the **full-row** access pattern: all five facts of each object.
379    fn lookup_batch(&self, oids: &[&[u8]]) -> Vec<Option<IndexRow>>;
380
381    /// oid → ordinal and **stop**. No payload column is touched at all.
382    ///
383    /// Identical in every implementation, because every implementation shares
384    /// the same `stree`. It is here as the **floor**: whatever this costs is
385    /// what no payload layout can remove, and `lookup_batch` minus this is the
386    /// only part any of these layouts can change. Without it a three-way tie is
387    /// unreadable — it could mean the layouts are equivalent, or it could mean
388    /// the harness never read a payload byte.
389    fn ordinals_batch(&self, oids: &[&[u8]]) -> Vec<Option<u32>>;
390
391    /// The **partial-row** access pattern: byte extent only, two of the five
392    /// facts. This is `extents(&[oid])`, what the wire path actually asks for
393    /// when it is about to copy bytes out of a pack.
394    fn extents_batch(&self, oids: &[&[u8]]) -> Vec<Option<(u64, u64)>>;
395
396    /// Full **column scan**: total uncompressed bytes over every object. The
397    /// quota gate. Reads one 8-byte fact per row and nothing else.
398    fn sum_uncompressed(&self) -> u64;
399
400    /// Full **column scan** with a predicate: how many objects of this type.
401    /// Reads one *byte* per row and nothing else — the most column-shaped
402    /// query there is, and the one a row-packed layout should lose worst.
403    fn count_type(&self, t: ObjType) -> usize;
404
405    fn name(&self) -> &'static str;
406
407    fn len(&self) -> usize;
408
409    fn is_empty(&self) -> bool {
410        self.len() == 0
411    }
412
413    /// Total bytes of Arrow IPC this index holds resident. Four sections or
414    /// one, this counts the same payload, so it is comparable across arms.
415    fn ipc_bytes(&self) -> usize;
416
417    /// IPC bytes plus the `stree` oid section — everything the index keeps
418    /// alive, excluding the handful of `Arc`'d schema/metadata allocations.
419    fn resident_bytes(&self) -> usize;
420
421    // ── the durable form ─────────────────────────────────────────────────────
422    //
423    // **The projection is the durable index, and redb is its tail** — the
424    // pattern the read stack was named for and, until 2026-09-04, did not
425    // have. `ObjectReadStack` used to keep EVERY row in redb for ever and
426    // re-derive this projection from a full B-tree scan on every open (a
427    // 382 B/object RAM floor, measured 744–966 on the kernel mirror), while
428    // the redb file grew to 208.5 B/object for 61 B of content — 2.9 GB
429    // written, 4 GiB allocated, for 14 M objects. The three methods below are
430    // what lets the stack persist the projection instead, fold the redb rows
431    // into it, and empty redb of what it folded — so redb holds only what
432    // arrived since the last fold, which is the whole of what a B-tree is
433    // good for here.
434
435    /// Whether this arm has a single-buffer form the stack can write down and
436    /// read back with [`from_persisted`](Self::from_persisted). An arm that
437    /// answers `false` is never persisted: the stack then keeps every row in
438    /// redb and rebuilds from it, which is the pre-2026-09-04 behaviour, kept
439    /// as a correct fallback rather than made an error.
440    ///
441    /// A `Sized` fn and not an associated const: a const would make the trait
442    /// not dyn-compatible, and the layout benches hold `&dyn ObjectIndex`.
443    fn persists() -> bool
444    where
445        Self: Sized,
446    {
447        false
448    }
449
450    /// **The bytes this index is persisted as** — one self-contained Arrow IPC
451    /// stream holding one record batch, or `None` for an arm with no such form.
452    fn persisted_bytes(&self) -> Option<&[u8]> {
453        None
454    }
455
456    /// Reconstruct this index from a buffer [`persisted_bytes`](Self::persisted_bytes)
457    /// produced — typically a memory-mapped `objects.index`, so every array is a
458    /// view into the page cache rather than a copy of it. Refuses a buffer this
459    /// arm did not write (a different arm's layout, a different format stamp).
460    fn from_persisted(buf: Buffer) -> Result<Self>
461    where
462        Self: Sized,
463    {
464        let _ = buf;
465        bail!("this index layout has no persisted form")
466    }
467
468    /// The raw oid at `ordinal`. Every arm keeps a `FixedSizeBinary` oid
469    /// column, so this is one bounds-checked slice, copied out — owned rather
470    /// than borrowed so that a stack holding its projection behind a lock can
471    /// answer it too. Positional, like Arrow's own `value(i)`: an ordinal past
472    /// `len()` (or, on a stack, a tail-tagged one) is a programming error and
473    /// panics rather than answering.
474    fn oid_at(&self, ordinal: u32) -> Vec<u8>;
475
476    /// Every fact of the row at `ordinal`, as the entry that would rebuild it.
477    /// The fold's merge reads the projection back through this.
478    fn entry_at(&self, ordinal: u32) -> IndexEntry;
479
480    /// `(offset, len)` of the row at `ordinal` — positional, no oid, no
481    /// allocation. The crash-recovery diff scans the whole projection through
482    /// this, so it must cost a column read and nothing more.
483    fn extent_at(&self, ordinal: u32) -> (u64, u64);
484
485    /// **Every oid, back to back, in ordinal order** — `len() × oid_len` bytes.
486    /// This IS the ordinal space the derived tables address (`__gunnar_reach__`
487    /// bitmaps, the commit graph's token), and on a persisted arm it is the oid
488    /// column's own bytes: the thing `objects.ordinal` used to cache in a file
489    /// of its own, with a header and an epoch to validate it, is a contiguous
490    /// buffer this index already holds. The default is the positional loop; the
491    /// columnar arms hand back their column.
492    fn oids_raw(&self) -> Vec<u8> {
493        let n = self.len() as u32;
494        let mut out = Vec::new();
495        for o in 0..n {
496            out.extend_from_slice(&self.oid_at(o));
497        }
498        out
499    }
500
501    /// All rows, in ordinal (= oid) order. `len()` allocations of `oid`, which
502    /// is the price of handing `build` a slice; the fold pays it once per fold
503    /// and nothing on the read path calls it.
504    fn entries(&self) -> Vec<IndexEntry> {
505        (0..self.len() as u32).map(|o| self.entry_at(o)).collect()
506    }
507}
508
509/// Schema-metadata key under which a persisted index stamps
510/// [`PERSISTED_FORMAT`]. A buffer without it, or with another value, is not
511/// this crate's index and [`ObjectIndex::from_persisted`] refuses it.
512pub const PERSISTED_FORMAT_KEY: &str = "znippy.index.format";
513/// Schema-metadata key naming the arm that wrote the buffer, so one arm cannot
514/// misread another's bytes as its own.
515pub const PERSISTED_LAYOUT_KEY: &str = "znippy.index.layout";
516/// **Bump in the same commit as any change to what a persisted column means.**
517/// The Arrow schema already versions the column *set* — a renamed or missing
518/// column fails `col()` — so this exists for the change a schema cannot see: the
519/// same six names carrying different semantics. `1` is the layout in use when
520/// persistence was introduced (2026-09-04).
521pub const PERSISTED_FORMAT: u64 = 1;
522
523/// The schema metadata every persisted arm writes.
524fn persisted_metadata(layout: &str) -> std::collections::HashMap<String, String> {
525    [
526        (PERSISTED_FORMAT_KEY.to_string(), PERSISTED_FORMAT.to_string()),
527        (PERSISTED_LAYOUT_KEY.to_string(), layout.to_string()),
528    ]
529    .into_iter()
530    .collect()
531}
532
533/// Refuse a decoded batch whose schema was not stamped by `layout` at
534/// [`PERSISTED_FORMAT`]. The error names what was found, because "wrong
535/// format" with no value behind it sends the reader to a hex dump.
536fn check_persisted_stamp(batch: &RecordBatch, layout: &str) -> Result<()> {
537    let meta = batch.schema_ref().metadata();
538    match meta.get(PERSISTED_FORMAT_KEY).map(String::as_str) {
539        Some(v) if v == PERSISTED_FORMAT.to_string() => {}
540        other => bail!(
541            "persisted index carries {PERSISTED_FORMAT_KEY}={other:?}, this build reads \
542             {PERSISTED_FORMAT}"
543        ),
544    }
545    match meta.get(PERSISTED_LAYOUT_KEY).map(String::as_str) {
546        Some(v) if v == layout => Ok(()),
547        other => bail!(
548            "persisted index was written by layout {other:?}, this projection is {layout}"
549        ),
550    }
551}
552
553// ── shared machinery: both arms are built from exactly this ───────────────────
554
555/// Column names, one place, so the two arms cannot drift apart on spelling.
556pub const COL_OID: &str = "oid";
557pub const COL_OFFSET: &str = "offset";
558pub const COL_LEN: &str = "len";
559pub const COL_TYPE: &str = "object_type";
560pub const COL_SIZE: &str = "uncompressed_size";
561pub const COL_DELTA_BASE: &str = "delta_base";
562
563/// The six Arrow arrays that carry the five facts, in oid-lexicographic order.
564/// The byte extent is one fact in two columns because that is what it is —
565/// `(offset, len)` — and splitting it lets a scan of just the offsets stay
566/// contiguous.
567pub struct Columns {
568    pub oid: FixedSizeBinaryArray,
569    pub offset: UInt64Array,
570    pub len: UInt64Array,
571    pub obj_type: UInt8Array,
572    pub size: UInt64Array,
573    /// Archive offset of the base entry, `0` for none. Same coordinate space as
574    /// `offset`, which is what makes a whole-column comparison against the
575    /// `offset` column meaningful (a delta's base is an entry in this table).
576    pub delta_base: UInt64Array,
577}
578
579/// Sort indices of `entries` into oid-lexicographic order, which is also the
580/// ordinal order and the order `oid_index::build_section` puts its keys in.
581///
582/// Ties on the full oid are impossible in a well-formed index and are checked
583/// for in [`validate`] rather than being silently deduplicated.
584fn oid_order(entries: &[IndexEntry]) -> Vec<u32> {
585    let mut order: Vec<u32> = (0..entries.len() as u32).collect();
586    order.sort_unstable_by(|&a, &b| entries[a as usize].oid.cmp(&entries[b as usize].oid));
587    order
588}
589
590/// Reject the two things that would make an index silently wrong: mixed oid
591/// widths, and a duplicate oid (which would give one object two ordinals and
592/// make the two arms disagree on which one a lookup returns).
593fn validate(entries: &[IndexEntry], order: &[u32]) -> Result<GitHashKind> {
594    let Some(first) = entries.first() else {
595        // An empty index is legitimate (an empty push) and its hash kind is
596        // arbitrary; sha1 keeps the section 20-byte shaped.
597        return Ok(GitHashKind::Sha1);
598    };
599    let hash = match first.oid.len() {
600        20 => GitHashKind::Sha1,
601        32 => GitHashKind::Sha256,
602        n => bail!("oid width {n} is neither sha1 (20) nor sha256 (32)"),
603    };
604    for e in entries {
605        if e.oid.len() != hash.oid_len() {
606            bail!(
607                "mixed oid widths: {} and {} in one index",
608                hash.oid_len(),
609                e.oid.len()
610            );
611        }
612    }
613    for w in order.windows(2) {
614        if entries[w[0] as usize].oid == entries[w[1] as usize].oid {
615            bail!(
616                "duplicate oid {} — one object cannot hold two ordinals",
617                hex::encode(&entries[w[0] as usize].oid)
618            );
619        }
620    }
621    Ok(hash)
622}
623
624/// The `stree` oid keyspace, shared verbatim by both arms.
625///
626/// `lookup_row` is set to the ordinal here. That is not a redefinition of the
627/// field: [`crate::oid_index::build_section`] is the lower-level API and its
628/// contract lets the caller choose what row an oid points at. This index's rows
629/// *are* its ordinals, and both arms use the ordinal, so nothing is lost.
630pub struct OidResolver {
631    tree: GitOidIndex,
632    section_bytes: usize,
633}
634
635impl OidResolver {
636    fn build(entries: &[IndexEntry], order: &[u32], hash: GitHashKind) -> Result<Self> {
637        Self::build_with(entries, order, hash, OidLayout::default())
638    }
639
640    /// [`build`](Self::build) with the section's cache-line layout chosen by the
641    /// caller. Only `PackedPayload::build_with_oid_layout` uses it; see there.
642    fn build_with(
643        entries: &[IndexEntry],
644        order: &[u32],
645        hash: GitHashKind,
646        layout: OidLayout,
647    ) -> Result<Self> {
648        let oid_entries: Vec<OidEntry> = order
649            .iter()
650            .enumerate()
651            .map(|(rank, &i)| OidEntry {
652                oid: entries[i as usize].oid.clone(),
653                lookup_row: rank as u64,
654                ordinal: rank as u32,
655            })
656            .collect();
657        let section = build_section_with_layout(&oid_entries, hash, layout)?;
658        let section_bytes = section.len();
659        // `parse_as`, not `parse`: `Compact64Alloc` is a reader-side allocation
660        // choice that the section's bytes cannot express.
661        Ok(Self { tree: GitOidIndex::parse_as(section, layout)?, section_bytes })
662    }
663
664    /// The resolver over an already-sorted oid column — a persisted index being
665    /// read back. Row `i` of the column IS ordinal `i`, which is what
666    /// [`from_persisted`](ObjectIndex::from_persisted) relies on and what
667    /// `build` guaranteed when it wrote the column in `oid_order`.
668    fn from_oid_column(oid: &FixedSizeBinaryArray, hash: GitHashKind) -> Result<Self> {
669        let oid_entries: Vec<OidEntry> = (0..oid.len())
670            .map(|i| OidEntry { oid: oid.value(i).to_vec(), lookup_row: i as u64, ordinal: i as u32 })
671            .collect();
672        let layout = OidLayout::default();
673        let section = build_section_with_layout(&oid_entries, hash, layout)?;
674        let section_bytes = section.len();
675        Ok(Self { tree: GitOidIndex::parse_as(section, layout)?, section_bytes })
676    }
677
678    #[inline]
679    fn ordinal(&self, oid: &[u8]) -> Option<u32> {
680        self.tree.lookup(oid).map(|h| h.ordinal)
681    }
682
683    #[inline]
684    fn ordinals(&self, oids: &[&[u8]]) -> Vec<Option<u32>> {
685        self.tree
686            .lookup_batch(oids)
687            .into_iter()
688            .map(|h| h.map(|h| h.ordinal))
689            .collect()
690    }
691}
692
693/// Materialise the five arrays from `entries` in `order`.
694///
695/// Both arms call this and nothing else, so any difference the bench reports is
696/// a difference of IPC packing, not of array construction.
697pub fn columns(entries: &[IndexEntry], order: &[u32], oid_len: usize) -> Columns {
698    let n = order.len();
699    let mut offset: Vec<u64> = Vec::with_capacity(n);
700    let mut len: Vec<u64> = Vec::with_capacity(n);
701    let mut ty: Vec<u8> = Vec::with_capacity(n);
702    let mut size: Vec<u64> = Vec::with_capacity(n);
703    let mut delta_base: Vec<u64> = Vec::with_capacity(n);
704    for &i in order {
705        let e = &entries[i as usize];
706        offset.push(e.offset);
707        len.push(e.len);
708        ty.push(e.obj_type.code());
709        size.push(e.uncompressed_size);
710        delta_base.push(e.delta_base);
711    }
712    Columns {
713        oid: oid_column(entries, order, oid_len),
714        offset: UInt64Array::new(ScalarBuffer::from(offset), None),
715        len: UInt64Array::new(ScalarBuffer::from(len), None),
716        obj_type: UInt8Array::new(ScalarBuffer::from(ty), None),
717        size: UInt64Array::new(ScalarBuffer::from(size), None),
718        delta_base: UInt64Array::new(ScalarBuffer::from(delta_base), None),
719    }
720}
721
722/// The oid column alone. Every arm stores it, and no arm packs it into the
723/// payload: the oid is the *key* the stree resolves against, not one of the
724/// facts a resolved lookup fetches, so putting it next to the payload would
725/// widen every payload read for a field nobody reads at lookup time.
726pub fn oid_column(entries: &[IndexEntry], order: &[u32], oid_len: usize) -> FixedSizeBinaryArray {
727    let mut bytes: Vec<u8> = Vec::with_capacity(order.len() * oid_len);
728    for &i in order {
729        bytes.extend_from_slice(&entries[i as usize].oid);
730    }
731    FixedSizeBinaryArray::new(oid_len as i32, Buffer::from_vec(bytes), None)
732}
733
734/// The five facts of one object, packed little-endian into `PACKED_LEN` bytes.
735///
736/// ```text
737///   0  u64 LE  offset               8
738///   8  u64 LE  len                  8
739///  16  u8      object type code     1
740///  17  u64 LE  uncompressed_size    8
741///  25  u64 LE  delta_base           8
742///                                  ══
743///                                  33
744/// ```
745///
746/// Fixed order, fixed width, no padding, no nulls. The type byte sits between
747/// the extent and the sizes rather than after them so the record is 33 bytes
748/// instead of 40 — the whole point of the arm is how few bytes a full-row fetch
749/// drags through cache, and 33 against 40 is 18% of that budget.
750///
751/// **This was 25 bytes before `delta_base` (PLAN §13) joined the schema.** The
752/// column is appended rather than inserted so the first four fields keep the
753/// offsets every reader here and every hand-decode in the tests already uses;
754/// the width is asserted byte for byte by
755/// `the_packed_record_is_the_documented_33_bytes`.
756pub const PACKED_LEN: usize = 33;
757const P_OFFSET: usize = 0;
758const P_LEN: usize = 8;
759const P_TYPE: usize = 16;
760const P_SIZE: usize = 17;
761const P_DELTA_BASE: usize = 25;
762
763/// One `FixedSizeBinary(PACKED_LEN)` element per object, in `order`.
764pub fn packed_column(entries: &[IndexEntry], order: &[u32]) -> FixedSizeBinaryArray {
765    let mut bytes: Vec<u8> = Vec::with_capacity(order.len() * PACKED_LEN);
766    for &i in order {
767        let e = &entries[i as usize];
768        bytes.extend_from_slice(&e.offset.to_le_bytes());
769        bytes.extend_from_slice(&e.len.to_le_bytes());
770        bytes.push(e.obj_type.code());
771        bytes.extend_from_slice(&e.uncompressed_size.to_le_bytes());
772        bytes.extend_from_slice(&e.delta_base.to_le_bytes());
773    }
774    debug_assert_eq!(bytes.len(), order.len() * PACKED_LEN);
775    FixedSizeBinaryArray::new(PACKED_LEN as i32, Buffer::from_vec(bytes), None)
776}
777
778#[inline]
779fn le64(r: &[u8], at: usize) -> u64 {
780    u64::from_le_bytes(r[at..at + 8].try_into().unwrap())
781}
782
783fn oid_field(oid_len: usize) -> Field {
784    Field::new(COL_OID, DataType::FixedSizeBinary(oid_len as i32), false)
785}
786
787/// Serialise one batch as a self-contained Arrow IPC **stream**.
788fn to_ipc(batch: &RecordBatch) -> Result<Vec<u8>> {
789    let mut out = Vec::with_capacity(64 + batch.get_array_memory_size());
790    {
791        let mut w = StreamWriter::try_new(&mut out, batch.schema_ref())
792            .map_err(|e| anyhow!("ipc writer: {e}"))?;
793        w.write(batch).map_err(|e| anyhow!("ipc write: {e}"))?;
794        w.finish().map_err(|e| anyhow!("ipc finish: {e}"))?;
795    }
796    Ok(out)
797}
798
799/// Decode an IPC stream **zero-copy**, and return the owning [`Buffer`] with it.
800///
801/// The `Vec<u8>` a writer produces is only 1-byte aligned, so it is first copied
802/// once into a `MutableBuffer` (64-byte aligned). After that every array in the
803/// batch is a view into `buffer` — enforced by `with_require_alignment(true)`,
804/// which errors instead of quietly re-allocating.
805fn decode_ipc(bytes: &[u8]) -> Result<(Buffer, RecordBatch)> {
806    let mut mb = MutableBuffer::with_capacity(bytes.len());
807    mb.extend_from_slice(bytes);
808    decode_ipc_buffer(mb.into())
809}
810
811/// The half of [`decode_ipc`] that does not control the buffer's alignment,
812/// split out so a test can hand it a deliberately misaligned one and prove
813/// `with_require_alignment(true)` is load-bearing rather than decoration.
814fn decode_ipc_buffer(owner: Buffer) -> Result<(Buffer, RecordBatch)> {
815    // A persisted index is a file: through the IPC guard first, so hostile bytes
816    // are an `Err` and never an arrow-ipc panic or abort. It reads the metadata
817    // only and copies nothing.
818    znippy_common::ipc_guard::check_stream(owner.as_slice())
819        .map_err(|e| anyhow!("ipc decode: {e}"))?;
820    let mut cursor = owner.clone();
821    let mut decoder = StreamDecoder::new().with_require_alignment(true);
822    let mut found: Option<RecordBatch> = None;
823    while !cursor.is_empty() {
824        match decoder.decode(&mut cursor).map_err(|e| anyhow!("ipc decode: {e}"))? {
825            Some(b) if found.is_none() => found = Some(b),
826            Some(_) => bail!("index section holds more than one record batch"),
827            None => {}
828        }
829    }
830    decoder.finish().map_err(|e| anyhow!("ipc unfinished: {e}"))?;
831    found.ok_or_else(|| anyhow!("index section holds no record batch"))
832        .map(|b| (owner, b))
833}
834
835fn col<T: Array + Clone + 'static>(batch: &RecordBatch, name: &str) -> Result<T> {
836    batch
837        .column_by_name(name)
838        .ok_or_else(|| anyhow!("no `{name}` column"))?
839        .as_any()
840        .downcast_ref::<T>()
841        .cloned()
842        .ok_or_else(|| anyhow!("`{name}` has an unexpected type"))
843}
844
845/// True when `array`'s value bytes lie inside `ipc` — i.e. the decode really was
846/// zero-copy and not an aligned re-allocation behind our back.
847fn inside(ipc: &Buffer, values: &[u8]) -> bool {
848    if values.is_empty() {
849        // Nothing to point at; a zero-length column cannot disprove anything.
850        return true;
851    }
852    let base = ipc.as_ptr() as usize;
853    let p = values.as_ptr() as usize;
854    p >= base && p + values.len() <= base + ipc.len()
855}
856
857// ── shared read paths for the two columnar arms ───────────────────────────────
858
859/// The six arrays and **every** read path over them.
860///
861/// Both columnar arms hold one of these and neither has a hot path of its own,
862/// so they cannot drift apart (LAW 5) and the only thing the bench can see
863/// between them is how many IPC streams the arrays were framed into. That the
864/// two then tie is a fact about Arrow, not about this struct: `FourTables` and
865/// `OneTableFourColumns` both have **five payload columns in five separate
866/// buffers**, and a full-row fetch touches five distant strides either way. The
867/// number of *tables* was never the variable. The number of *columns* is, which
868/// is what [`PackedPayload`] changes.
869struct ColumnarPayload {
870    oid: FixedSizeBinaryArray,
871    offset: UInt64Array,
872    len: UInt64Array,
873    obj_type: UInt8Array,
874    size: UInt64Array,
875    delta_base: UInt64Array,
876}
877
878impl ColumnarPayload {
879    /// Five separate buffer reads, five strides.
880    #[inline]
881    fn row_at(&self, ordinal: u32) -> IndexRow {
882        let i = ordinal as usize;
883        IndexRow {
884            ordinal,
885            offset: self.offset.value(i),
886            len: self.len.value(i),
887            // A code the writer cannot produce cannot appear here: the column is
888            // built from `ObjType`, so the round trip is total. `Blob` is the
889            // only defensible fallback and it is unreachable in a section this
890            // crate wrote.
891            obj_type: ObjType::from_code(self.obj_type.value(i)).unwrap_or(ObjType::Blob),
892            uncompressed_size: self.size.value(i),
893            delta_base: self.delta_base.value(i),
894        }
895    }
896
897    /// Two of the five buffers.
898    #[inline]
899    fn extent_at(&self, ordinal: u32) -> (u64, u64) {
900        let i = ordinal as usize;
901        (self.offset.value(i), self.len.value(i))
902    }
903
904    /// The oid column's bytes, contiguous, in ordinal order. One copy out of
905    /// the (possibly mapped) buffer — the column is never sliced here, so its
906    /// value buffer is exactly `len × width` from offset 0, but the loop over
907    /// `value(i)` is kept rather than trusting that, because a wrong ordinal
908    /// space is believed by every bitmap built on it.
909    fn oids_raw(&self) -> Vec<u8> {
910        let n = self.oid.len();
911        let w = self.oid.value_length() as usize;
912        let mut out = Vec::with_capacity(n * w);
913        for i in 0..n {
914            out.extend_from_slice(self.oid.value(i));
915        }
916        out
917    }
918
919    /// All six, as the entry that would rebuild this row. The fold's merge
920    /// path; never the read path.
921    fn entry_at(&self, ordinal: u32) -> IndexEntry {
922        let i = ordinal as usize;
923        IndexEntry {
924            oid: self.oid.value(i).to_vec(),
925            offset: self.offset.value(i),
926            len: self.len.value(i),
927            obj_type: ObjType::from_code(self.obj_type.value(i)).unwrap_or(ObjType::Blob),
928            uncompressed_size: self.size.value(i),
929            delta_base: self.delta_base.value(i),
930        }
931    }
932
933    /// One contiguous `u64` run, start to end — the case columnar is for.
934    fn sum_uncompressed(&self) -> u64 {
935        self.size.values().iter().copied().fold(0u64, u64::wrapping_add)
936    }
937
938    /// One contiguous `u8` run: `n` bytes read to answer a question about `n`
939    /// rows, which is the least memory traffic any layout can do here.
940    fn count_type(&self, t: ObjType) -> usize {
941        let c = t.code();
942        self.obj_type.values().iter().filter(|&&x| x == c).count()
943    }
944
945    fn inside(&self, oid_ipc: &Buffer, extent_ipc: &Buffer, type_ipc: &Buffer, size_ipc: &Buffer) -> bool {
946        inside(oid_ipc, self.oid.value_data())
947            && inside(extent_ipc, self.offset.values().inner().as_slice())
948            && inside(extent_ipc, self.len.values().inner().as_slice())
949            && inside(extent_ipc, self.delta_base.values().inner().as_slice())
950            && inside(type_ipc, self.obj_type.values().inner().as_slice())
951            && inside(size_ipc, self.size.values().inner().as_slice())
952    }
953}
954
955/// The three schemas layout A frames its facts into, plus the oid schema.
956///
957/// `delta_base` rides in the **extent** section rather than in a fifth one, and
958/// the reason is what the section means: it is *where the bytes are*, and a
959/// delta base is an address in exactly the same coordinate space as `offset` —
960/// the two are compared against each other by every guard that proves the base
961/// resolves. Keeping it here also keeps layout A what the benchmark named it:
962/// **four IPC sections** against layout B's one. The section count is the
963/// variable under test, and adding a fifth section would have changed the
964/// experiment rather than the schema.
965fn extent_schema() -> SchemaRef {
966    Arc::new(Schema::new(vec![
967        Field::new(COL_OFFSET, DataType::UInt64, false),
968        Field::new(COL_LEN, DataType::UInt64, false),
969        Field::new(COL_DELTA_BASE, DataType::UInt64, false),
970    ]))
971}
972
973// ── Impl A — FourTables ───────────────────────────────────────────────────────
974
975/// Four Arrow IPC sections — oid, byte extent, type, size — joined by row
976/// ordinal.
977///
978/// This is git's own shape: `.idx`, `.rev` and the missing type/size files, each
979/// an independent artifact. A lookup resolves the oid to an ordinal and then
980/// indexes three further sections at that ordinal.
981///
982/// The name counts **sections**, which is this arm's whole difference from
983/// [`OneTableFourColumns`]. It carries five payload columns: `delta_base` joined
984/// the schema afterwards and shares the extent section, for the reason given on
985/// [`extent_schema`].
986pub struct FourTables {
987    oids: OidResolver,
988    /// Owning IPC buffers, kept alive because every array below is a view into
989    /// one of them. Four buffers, four allocations, four distinct page runs.
990    ipc: [Buffer; 4],
991    cols: ColumnarPayload,
992    rows: usize,
993}
994
995impl FourTables {
996    /// Section order: oid, extent, type, size.
997    pub fn ipc_section_lens(&self) -> [usize; 4] {
998        [self.ipc[0].len(), self.ipc[1].len(), self.ipc[2].len(), self.ipc[3].len()]
999    }
1000
1001    /// The oid column, straight out of its own section — proof that "four
1002    /// tables" still gives a contiguous scannable column.
1003    pub fn oid_column(&self) -> &FixedSizeBinaryArray {
1004        &self.cols.oid
1005    }
1006
1007    /// Every column's bytes lie inside the section they were decoded from.
1008    pub fn column_is_inside_ipc(&self) -> bool {
1009        self.cols.inside(&self.ipc[0], &self.ipc[1], &self.ipc[2], &self.ipc[3])
1010    }
1011}
1012
1013impl ObjectIndex for FourTables {
1014    fn build(entries: &[IndexEntry]) -> Result<Self> {
1015        let order = oid_order(entries);
1016        let hash = validate(entries, &order)?;
1017        let oid_len = hash.oid_len();
1018        let c = columns(entries, &order, oid_len);
1019        let oids = OidResolver::build(entries, &order, hash)?;
1020
1021        let s_oid = Arc::new(Schema::new(vec![oid_field(oid_len)]));
1022        let s_type = Arc::new(Schema::new(vec![Field::new(COL_TYPE, DataType::UInt8, false)]));
1023        let s_size = Arc::new(Schema::new(vec![Field::new(COL_SIZE, DataType::UInt64, false)]));
1024
1025        let b_oid = RecordBatch::try_new(s_oid, vec![Arc::new(c.oid)])?;
1026        let b_extent = RecordBatch::try_new(
1027            extent_schema(),
1028            vec![Arc::new(c.offset), Arc::new(c.len), Arc::new(c.delta_base)],
1029        )?;
1030        let b_type = RecordBatch::try_new(s_type, vec![Arc::new(c.obj_type)])?;
1031        let b_size = RecordBatch::try_new(s_size, vec![Arc::new(c.size)])?;
1032
1033        let (ipc_oid, r_oid) = decode_ipc(&to_ipc(&b_oid)?)?;
1034        let (ipc_extent, r_extent) = decode_ipc(&to_ipc(&b_extent)?)?;
1035        let (ipc_type, r_type) = decode_ipc(&to_ipc(&b_type)?)?;
1036        let (ipc_size, r_size) = decode_ipc(&to_ipc(&b_size)?)?;
1037
1038        Ok(Self {
1039            oids,
1040            ipc: [ipc_oid, ipc_extent, ipc_type, ipc_size],
1041            cols: ColumnarPayload {
1042                oid: col(&r_oid, COL_OID)?,
1043                offset: col(&r_extent, COL_OFFSET)?,
1044                len: col(&r_extent, COL_LEN)?,
1045                obj_type: col(&r_type, COL_TYPE)?,
1046                size: col(&r_size, COL_SIZE)?,
1047                delta_base: col(&r_extent, COL_DELTA_BASE)?,
1048            },
1049            rows: order.len(),
1050        })
1051    }
1052
1053    fn lookup(&self, oid: &[u8]) -> Option<IndexRow> {
1054        self.oids.ordinal(oid).map(|o| self.cols.row_at(o))
1055    }
1056
1057    fn lookup_batch(&self, oids: &[&[u8]]) -> Vec<Option<IndexRow>> {
1058        self.oids.ordinals(oids).into_iter().map(|o| o.map(|o| self.cols.row_at(o))).collect()
1059    }
1060
1061    fn ordinals_batch(&self, oids: &[&[u8]]) -> Vec<Option<u32>> {
1062        self.oids.ordinals(oids)
1063    }
1064
1065    fn extents_batch(&self, oids: &[&[u8]]) -> Vec<Option<(u64, u64)>> {
1066        self.oids.ordinals(oids).into_iter().map(|o| o.map(|o| self.cols.extent_at(o))).collect()
1067    }
1068
1069    fn sum_uncompressed(&self) -> u64 {
1070        self.cols.sum_uncompressed()
1071    }
1072
1073    fn count_type(&self, t: ObjType) -> usize {
1074        self.cols.count_type(t)
1075    }
1076
1077    fn name(&self) -> &'static str {
1078        "FourTables"
1079    }
1080
1081    fn oid_at(&self, ordinal: u32) -> Vec<u8> {
1082        self.cols.oid.value(ordinal as usize).to_vec()
1083    }
1084
1085    fn entry_at(&self, ordinal: u32) -> IndexEntry {
1086        self.cols.entry_at(ordinal)
1087    }
1088
1089    fn extent_at(&self, ordinal: u32) -> (u64, u64) {
1090        self.cols.extent_at(ordinal)
1091    }
1092
1093    fn oids_raw(&self) -> Vec<u8> {
1094        self.cols.oids_raw()
1095    }
1096
1097    fn len(&self) -> usize {
1098        self.rows
1099    }
1100
1101    fn ipc_bytes(&self) -> usize {
1102        self.ipc.iter().map(|b| b.len()).sum()
1103    }
1104
1105    fn resident_bytes(&self) -> usize {
1106        self.ipc_bytes() + self.oids.section_bytes
1107    }
1108}
1109
1110// ── Impl B — OneTableFourColumns ──────────────────────────────────────────────
1111
1112/// One Arrow IPC section, one table, the facts one column each.
1113///
1114/// Arrow is columnar, so this is **not** row-contiguous: `offset` is its own
1115/// buffer, `len` is its own buffer, and a full-row fetch still touches five
1116/// strides that are megabytes apart. What it saves over [`FourTables`] is the
1117/// framing of three IPC streams and nothing else — which is exactly what the
1118/// measurement found.
1119///
1120/// The name counts the columns it had when it was named and measured; since
1121/// `delta_base` (PLAN §13) there are five payload columns here and in
1122/// [`FourTables`] alike, so the two are still separated by section count and
1123/// nothing else.
1124pub struct OneTableFourColumns {
1125    oids: OidResolver,
1126    ipc: Buffer,
1127    cols: ColumnarPayload,
1128    rows: usize,
1129}
1130
1131impl OneTableFourColumns {
1132    /// What this arm writes under [`PERSISTED_LAYOUT_KEY`], and the only value
1133    /// [`ObjectIndex::from_persisted`] accepts there.
1134    const LAYOUT_NAME: &'static str = "OneTableFourColumns";
1135
1136    /// The one constructor both `build` and `from_persisted` end in, so the
1137    /// column wiring is spelled once.
1138    fn from_decoded(oids: OidResolver, ipc: Buffer, r: &RecordBatch, rows: usize) -> Result<Self> {
1139        Ok(Self {
1140            oids,
1141            ipc,
1142            cols: ColumnarPayload {
1143                oid: col(r, COL_OID)?,
1144                offset: col(r, COL_OFFSET)?,
1145                len: col(r, COL_LEN)?,
1146                obj_type: col(r, COL_TYPE)?,
1147                size: col(r, COL_SIZE)?,
1148                delta_base: col(r, COL_DELTA_BASE)?,
1149            },
1150            rows,
1151        })
1152    }
1153
1154    pub fn oid_column(&self) -> &FixedSizeBinaryArray {
1155        &self.cols.oid
1156    }
1157
1158    /// All five arrays are views into the one IPC buffer.
1159    pub fn column_is_inside_ipc(&self) -> bool {
1160        self.cols.inside(&self.ipc, &self.ipc, &self.ipc, &self.ipc)
1161    }
1162
1163    /// **The bytes this index would be persisted as** — the single Arrow IPC
1164    /// stream every column is a view into, exactly as [`to_ipc`] framed it.
1165    ///
1166    /// [`ObjectIndex::ipc_bytes`] reports this buffer's *length*, which is
1167    /// enough to compare two layouts' footprints and not enough to write one
1168    /// down. `examples/tail_write_bench.rs` needs the bytes themselves: its
1169    /// no-tail arm has to rewrite the whole index on every push, and a
1170    /// measurement of that write is only honest if the bytes going to the file
1171    /// are the index's own and not a stand-in of the same size.
1172    ///
1173    /// A borrowed slice and not a `Vec`: the buffer is already resident and
1174    /// aligned, and handing out a copy would put an allocation inside the very
1175    /// write this exists to measure.
1176    pub fn ipc_slice(&self) -> &[u8] {
1177        self.ipc.as_slice()
1178    }
1179}
1180
1181impl ObjectIndex for OneTableFourColumns {
1182    fn build(entries: &[IndexEntry]) -> Result<Self> {
1183        let order = oid_order(entries);
1184        let hash = validate(entries, &order)?;
1185        let oid_len = hash.oid_len();
1186        let c = columns(entries, &order, oid_len);
1187        let oids = OidResolver::build(entries, &order, hash)?;
1188
1189        // The stamp rides inside the IPC bytes, so the persisted file needs no
1190        // framing of its own and `from_persisted` can refuse a foreign buffer
1191        // by reading the schema it already had to decode.
1192        let schema: SchemaRef = Arc::new(Schema::new_with_metadata(
1193            vec![
1194                oid_field(oid_len),
1195                Field::new(COL_OFFSET, DataType::UInt64, false),
1196                Field::new(COL_LEN, DataType::UInt64, false),
1197                Field::new(COL_TYPE, DataType::UInt8, false),
1198                Field::new(COL_SIZE, DataType::UInt64, false),
1199                Field::new(COL_DELTA_BASE, DataType::UInt64, false),
1200            ],
1201            persisted_metadata(Self::LAYOUT_NAME),
1202        ));
1203        let batch = RecordBatch::try_new(
1204            schema,
1205            vec![
1206                Arc::new(c.oid),
1207                Arc::new(c.offset),
1208                Arc::new(c.len),
1209                Arc::new(c.obj_type),
1210                Arc::new(c.size),
1211                Arc::new(c.delta_base),
1212            ],
1213        )?;
1214        let (ipc, r) = decode_ipc(&to_ipc(&batch)?)?;
1215        Self::from_decoded(oids, ipc, &r, order.len())
1216    }
1217
1218    fn persists() -> bool {
1219        true
1220    }
1221
1222    fn persisted_bytes(&self) -> Option<&[u8]> {
1223        Some(self.ipc.as_slice())
1224    }
1225
1226    /// The buffer is decoded in place — `decode_ipc_buffer`, not `decode_ipc`,
1227    /// so a memory-mapped file stays mapped and every column is a view into
1228    /// it — and the `stree` is rebuilt over its oid column. The stree is the
1229    /// one thing this does not read back: its section is a reader-side
1230    /// structure `GitOidIndex::parse_as` derives, and building it is O(n) in
1231    /// the oid count against the O(n log n) sort-and-encode a rebuild from
1232    /// redb pays, with no B-tree walk in front of it.
1233    fn from_persisted(buf: Buffer) -> Result<Self> {
1234        let (ipc, r) = decode_ipc_buffer(buf)?;
1235        check_persisted_stamp(&r, Self::LAYOUT_NAME)?;
1236        let oid: FixedSizeBinaryArray = col(&r, COL_OID)?;
1237        let hash = match oid.value_length() {
1238            20 => GitHashKind::Sha1,
1239            32 => GitHashKind::Sha256,
1240            w => bail!("persisted oid column is {w} bytes wide, expected 20 or 32"),
1241        };
1242        let oids = OidResolver::from_oid_column(&oid, hash)?;
1243        let rows = r.num_rows();
1244        Self::from_decoded(oids, ipc, &r, rows)
1245    }
1246
1247    fn oid_at(&self, ordinal: u32) -> Vec<u8> {
1248        self.cols.oid.value(ordinal as usize).to_vec()
1249    }
1250
1251    fn entry_at(&self, ordinal: u32) -> IndexEntry {
1252        self.cols.entry_at(ordinal)
1253    }
1254
1255    fn extent_at(&self, ordinal: u32) -> (u64, u64) {
1256        self.cols.extent_at(ordinal)
1257    }
1258
1259    fn oids_raw(&self) -> Vec<u8> {
1260        self.cols.oids_raw()
1261    }
1262
1263    fn lookup(&self, oid: &[u8]) -> Option<IndexRow> {
1264        self.oids.ordinal(oid).map(|o| self.cols.row_at(o))
1265    }
1266
1267    fn lookup_batch(&self, oids: &[&[u8]]) -> Vec<Option<IndexRow>> {
1268        self.oids.ordinals(oids).into_iter().map(|o| o.map(|o| self.cols.row_at(o))).collect()
1269    }
1270
1271    fn ordinals_batch(&self, oids: &[&[u8]]) -> Vec<Option<u32>> {
1272        self.oids.ordinals(oids)
1273    }
1274
1275    fn extents_batch(&self, oids: &[&[u8]]) -> Vec<Option<(u64, u64)>> {
1276        self.oids.ordinals(oids).into_iter().map(|o| o.map(|o| self.cols.extent_at(o))).collect()
1277    }
1278
1279    fn sum_uncompressed(&self) -> u64 {
1280        self.cols.sum_uncompressed()
1281    }
1282
1283    fn count_type(&self, t: ObjType) -> usize {
1284        self.cols.count_type(t)
1285    }
1286
1287    fn name(&self) -> &'static str {
1288        "OneTableFourColumns"
1289    }
1290
1291    fn len(&self) -> usize {
1292        self.rows
1293    }
1294
1295    fn ipc_bytes(&self) -> usize {
1296        self.ipc.len()
1297    }
1298
1299    fn resident_bytes(&self) -> usize {
1300        self.ipc_bytes() + self.oids.section_bytes
1301    }
1302}
1303
1304// ── Impl C — PackedPayload ────────────────────────────────────────────────────
1305
1306/// **One** payload column: all five facts of one object packed adjacently into
1307/// a single `FixedSizeBinary(33)` element.
1308///
1309/// This is the arm the first two were both missing. `FourTables` and
1310/// `OneTableFourColumns` differ in how many *tables* they use and agree in
1311/// having **five payload columns**, so a full-row fetch costs five buffer reads
1312/// at five unrelated addresses in both. Here it costs one:
1313///
1314/// ```text
1315/// per row, byte for byte (little-endian, no padding, no nulls):
1316///   0  u64  offset
1317///   8  u64  len
1318///  16  u8   object type code (1 commit, 2 tree, 3 blob, 4 tag, 6 ofs-delta, 7 ref-delta)
1319///  17  u64  uncompressed_size
1320///  25  u64  delta_base — archive offset of the base entry, 0 for none
1321///  33  ── next row
1322/// ```
1323///
1324/// **It was 24 bytes narrower before `delta_base` (PLAN §13).** A 33-byte record
1325/// spans one 64-byte cache line 49% of the time and two the rest, so a full-row
1326/// fetch is ~1.5 lines against the five the columnar arms touch — where at 25
1327/// bytes it was ~1.4 against four. The trade moved slightly, in both directions
1328/// at once, which is why the numbers below were re-taken rather than scaled.
1329///
1330/// The oid stays in its own column. It is the key the `stree` resolves
1331/// *against*, not one of the facts a resolved lookup fetches, so packing it in
1332/// would grow every payload read by 20 bytes nobody reads at lookup time.
1333///
1334/// ## The trade, stated before it is measured
1335///
1336/// * **Full-row access should win** — one stride instead of four.
1337/// * **Column scans should lose, and lose badly.** `count_type` reads one byte
1338///   per row; the columnar arms stream `n` bytes to answer it, this arm drags
1339///   `33n` through cache to use `n`. `sum_uncompressed` reads 8 of every 33
1340///   instead of 8 of every 8.
1341/// * It is **no longer typed Arrow columns**. A `FixedSizeBinary(33)` is
1342///   opaque to DuckDB / Polars / DataFusion, which can query the other two arms
1343///   directly off the IPC bytes. That is a real cost and it is not a
1344///   performance one.
1345pub struct PackedPayload {
1346    oids: OidResolver,
1347    ipc: Buffer,
1348    oid: FixedSizeBinaryArray,
1349    payload: FixedSizeBinaryArray,
1350    rows: usize,
1351}
1352
1353/// Column name of the single packed payload column.
1354pub const COL_PACKED: &str = "packed";
1355
1356impl PackedPayload {
1357    /// [`ObjectIndex::build`] with the `stree` section's [`OidLayout`] chosen
1358    /// explicitly — the whole apparatus of the alignment experiment
1359    /// (`examples/oid_align_bench.rs`).
1360    ///
1361    /// It lives on this arm alone rather than on the trait because
1362    /// `ordinals_batch` is **the same code in all three arms** (they share one
1363    /// [`OidResolver`]), so a second arm would add a second Arrow payload to
1364    /// keep resident and would measure nothing new. This is the arm the payload
1365    /// sweep picked.
1366    pub fn build_with_oid_layout(entries: &[IndexEntry], layout: OidLayout) -> Result<Self> {
1367        let order = oid_order(entries);
1368        let hash = validate(entries, &order)?;
1369        let oid_len = hash.oid_len();
1370        let oid_arr = oid_column(entries, &order, oid_len);
1371        let packed = packed_column(entries, &order);
1372        let oids = OidResolver::build_with(entries, &order, hash, layout)?;
1373        Self::assemble(oids, oid_arr, packed, oid_len, order.len())
1374    }
1375
1376    /// The cache-line phase of this index's `stree` keyspace — 0 for
1377    /// [`OidLayout::Aligned64`]. The bench asserts on it before quoting a
1378    /// number, because two arms that landed on the same phase would be one arm
1379    /// measured twice.
1380    pub fn keyspace_phase(&self) -> usize {
1381        self.oids.tree.keyspace_phase()
1382    }
1383
1384    /// Everything after the oid resolver: framing the two columns into one IPC
1385    /// stream and decoding it back zero-copy. Shared with `build` so the two
1386    /// cannot drift (LAW 5) — the only difference between them must be the
1387    /// `stree` layout, or the experiment is measuring two indexes.
1388    fn assemble(
1389        oids: OidResolver,
1390        oid_arr: FixedSizeBinaryArray,
1391        packed: FixedSizeBinaryArray,
1392        oid_len: usize,
1393        rows: usize,
1394    ) -> Result<Self> {
1395        let schema: SchemaRef = Arc::new(Schema::new(vec![
1396            oid_field(oid_len),
1397            Field::new(COL_PACKED, DataType::FixedSizeBinary(PACKED_LEN as i32), false),
1398        ]));
1399        let batch = RecordBatch::try_new(schema, vec![Arc::new(oid_arr), Arc::new(packed)])?;
1400        let (ipc, r) = decode_ipc(&to_ipc(&batch)?)?;
1401        Ok(Self { oids, ipc, oid: col(&r, COL_OID)?, payload: col(&r, COL_PACKED)?, rows })
1402    }
1403
1404    pub fn oid_column(&self) -> &FixedSizeBinaryArray {
1405        &self.oid
1406    }
1407
1408    pub fn column_is_inside_ipc(&self) -> bool {
1409        inside(&self.ipc, self.oid.value_data()) && inside(&self.ipc, self.payload.value_data())
1410    }
1411
1412    /// One buffer read, one stride.
1413    #[inline]
1414    fn row_at(&self, ordinal: u32) -> IndexRow {
1415        let r = self.payload.value(ordinal as usize);
1416        IndexRow {
1417            ordinal,
1418            offset: le64(r, P_OFFSET),
1419            len: le64(r, P_LEN),
1420            obj_type: ObjType::from_code(r[P_TYPE]).unwrap_or(ObjType::Blob),
1421            uncompressed_size: le64(r, P_SIZE),
1422            delta_base: le64(r, P_DELTA_BASE),
1423        }
1424    }
1425
1426    /// The same one buffer read — a partial row costs a packed layout exactly
1427    /// what a full row costs, which is half the point of the trade.
1428    #[inline]
1429    fn extent_at(&self, ordinal: u32) -> (u64, u64) {
1430        let r = self.payload.value(ordinal as usize);
1431        (le64(r, P_OFFSET), le64(r, P_LEN))
1432    }
1433}
1434
1435impl ObjectIndex for PackedPayload {
1436    fn build(entries: &[IndexEntry]) -> Result<Self> {
1437        let order = oid_order(entries);
1438        let hash = validate(entries, &order)?;
1439        let oid_len = hash.oid_len();
1440        let oid_arr = oid_column(entries, &order, oid_len);
1441        let packed = packed_column(entries, &order);
1442        let oids = OidResolver::build(entries, &order, hash)?;
1443        Self::assemble(oids, oid_arr, packed, oid_len, order.len())
1444    }
1445
1446    fn lookup(&self, oid: &[u8]) -> Option<IndexRow> {
1447        self.oids.ordinal(oid).map(|o| self.row_at(o))
1448    }
1449
1450    fn lookup_batch(&self, oids: &[&[u8]]) -> Vec<Option<IndexRow>> {
1451        self.oids.ordinals(oids).into_iter().map(|o| o.map(|o| self.row_at(o))).collect()
1452    }
1453
1454    fn ordinals_batch(&self, oids: &[&[u8]]) -> Vec<Option<u32>> {
1455        self.oids.ordinals(oids)
1456    }
1457
1458    fn extents_batch(&self, oids: &[&[u8]]) -> Vec<Option<(u64, u64)>> {
1459        self.oids.ordinals(oids).into_iter().map(|o| o.map(|o| self.extent_at(o))).collect()
1460    }
1461
1462    /// Strided: 8 useful bytes out of every 25 touched.
1463    fn sum_uncompressed(&self) -> u64 {
1464        let d = self.payload.value_data();
1465        let mut acc = 0u64;
1466        let mut i = P_SIZE;
1467        while i + 8 <= d.len() {
1468            acc = acc.wrapping_add(le64(d, i));
1469            i += PACKED_LEN;
1470        }
1471        acc
1472    }
1473
1474    /// Strided: 1 useful byte out of every 25 touched.
1475    fn count_type(&self, t: ObjType) -> usize {
1476        let c = t.code();
1477        let d = self.payload.value_data();
1478        let mut n = 0usize;
1479        let mut i = P_TYPE;
1480        while i < d.len() {
1481            if d[i] == c {
1482                n += 1;
1483            }
1484            i += PACKED_LEN;
1485        }
1486        n
1487    }
1488
1489    fn name(&self) -> &'static str {
1490        "PackedPayload"
1491    }
1492
1493    fn oid_at(&self, ordinal: u32) -> Vec<u8> {
1494        self.oid.value(ordinal as usize).to_vec()
1495    }
1496
1497    fn entry_at(&self, ordinal: u32) -> IndexEntry {
1498        let r = self.row_at(ordinal);
1499        IndexEntry {
1500            oid: self.oid.value(ordinal as usize).to_vec(),
1501            offset: r.offset,
1502            len: r.len,
1503            obj_type: r.obj_type,
1504            uncompressed_size: r.uncompressed_size,
1505            delta_base: r.delta_base,
1506        }
1507    }
1508
1509    fn extent_at(&self, ordinal: u32) -> (u64, u64) {
1510        // The inherent `extent_at` (one 33-byte stride, two fields out of it).
1511        PackedPayload::extent_at(self, ordinal)
1512    }
1513
1514    fn oids_raw(&self) -> Vec<u8> {
1515        let n = self.oid.len();
1516        let mut out = Vec::with_capacity(n * self.oid.value_length() as usize);
1517        for i in 0..n {
1518            out.extend_from_slice(self.oid.value(i));
1519        }
1520        out
1521    }
1522
1523    fn len(&self) -> usize {
1524        self.rows
1525    }
1526
1527    fn ipc_bytes(&self) -> usize {
1528        self.ipc.len()
1529    }
1530
1531    fn resident_bytes(&self) -> usize {
1532        self.ipc_bytes() + self.oids.section_bytes
1533    }
1534}
1535
1536// ── deterministic workload generation, shared by tests and the bench ──────────
1537
1538/// splitmix64. Seeded, so a bench run is reproducible and a historized series is
1539/// comparable at all.
1540pub struct Rng(pub u64);
1541
1542impl Rng {
1543    pub fn next_u64(&mut self) -> u64 {
1544        self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
1545        let mut z = self.0;
1546        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
1547        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
1548        z ^ (z >> 31)
1549    }
1550
1551    pub fn fill(&mut self, out: &mut [u8]) {
1552        for c in out.chunks_mut(8) {
1553            let w = self.next_u64().to_le_bytes();
1554            let n = c.len();
1555            c.copy_from_slice(&w[..n]);
1556        }
1557    }
1558}
1559
1560/// `n` distinct entries with uniformly random oids.
1561///
1562/// Uniform randomness is the property under test, not a convenience: real oids
1563/// are hashes and share no prefixes, so a generator with structure would give
1564/// the stree an unrepresentative keyspace.
1565///
1566/// **Every `OfsDelta` entry carries a non-zero `delta_base`** — the offset of
1567/// the entry immediately before it, which is a real row of the same workload —
1568/// and every other entry carries `0`. LAW 2's identity-value trap is the whole
1569/// reason: a generator that left `delta_base` at `0` everywhere would let a
1570/// layout that never wrote the column, or read it from the wrong stride, pass
1571/// every comparison in this module. It is derived from `out.last()` rather than
1572/// from `rng`, so oids, offsets, lengths and types are bit-identical to what
1573/// this generator produced before the column existed and no earlier figure is
1574/// invalidated by the workload changing under it.
1575pub fn synthetic_entries(n: usize, oid_len: usize, seed: u64) -> Vec<IndexEntry> {
1576    let mut rng = Rng(seed);
1577    let mut seen = std::collections::HashSet::with_capacity(n * 2);
1578    let mut out: Vec<IndexEntry> = Vec::with_capacity(n);
1579    let mut off = 12u64; // past a pack header
1580    while out.len() < n {
1581        let mut oid = vec![0u8; oid_len];
1582        rng.fill(&mut oid);
1583        if !seen.insert(oid.clone()) {
1584            continue;
1585        }
1586        // Length distribution roughly like a real pack: mostly small, a long
1587        // tail. The exact shape does not affect a point lookup, but it keeps
1588        // the u64 columns from being all-identical, which would let a compressor
1589        // or a branch predictor flatter one arm over the other.
1590        let len = 32 + (rng.next_u64() % 4096);
1591        let ty = ObjType::ALL[(rng.next_u64() % 6) as usize];
1592        // An ofs-delta's base is the entry before it: always an offset that some
1593        // row of this same workload really starts at, never 0, and never a
1594        // forward reference. A ref-delta's base is named by oid and has no
1595        // offset until it is resolved, so it records the 0 sentinel — as does
1596        // every whole object.
1597        let delta_base = match (ty, out.last()) {
1598            (ObjType::OfsDelta, Some(prev)) => prev.offset,
1599            _ => 0,
1600        };
1601        out.push(IndexEntry {
1602            oid,
1603            offset: off,
1604            len,
1605            obj_type: ty,
1606            uncompressed_size: len * (1 + rng.next_u64() % 5),
1607            delta_base,
1608        });
1609        off += len;
1610    }
1611    out
1612}
1613
1614
1615#[cfg(test)]
1616mod tests {
1617    use super::*;
1618
1619    /// A persisted index is a file on disk: hostile bytes handed to
1620    /// `from_persisted` are an `Err`, never a panic or an abort.
1621    #[test]
1622    #[ignore = "run as a child by a_hostile_persisted_index_is_an_error"]
1623    fn hostile_child() {
1624        if crate::hostile_inputs::child_case().is_none() {
1625            return;
1626        }
1627        let entries = synthetic_entries(3, 20, 7);
1628        let ix = OneTableFourColumns::build(&entries).unwrap();
1629        let valid = ix.persisted_bytes().unwrap().to_vec();
1630        assert!(OneTableFourColumns::from_persisted(Buffer::from(valid.clone())).is_ok());
1631        crate::hostile_inputs::sweep(&valid, |_, b| {
1632            let mut mb = MutableBuffer::with_capacity(b.len());
1633            mb.extend_from_slice(b);
1634            let _ = OneTableFourColumns::from_persisted(mb.into());
1635            let _ = decode_ipc(b);
1636        });
1637    }
1638
1639    #[test]
1640    fn a_hostile_persisted_index_is_an_error() {
1641        crate::hostile_inputs::run_in_child("index_layout::tests::hostile_child", "persisted");
1642    }
1643
1644    /// All three arms over the same entries. Every semantic test runs against
1645    /// the triple, because "the three layouts are one index" is the claim the
1646    /// whole comparison rests on.
1647    fn arms(entries: &[IndexEntry]) -> (FourTables, OneTableFourColumns, PackedPayload) {
1648        (
1649            FourTables::build(entries).expect("A builds"),
1650            OneTableFourColumns::build(entries).expect("B builds"),
1651            PackedPayload::build(entries).expect("C builds"),
1652        )
1653    }
1654
1655    /// The core correctness claim: **the three layouts are the same index**.
1656    ///
1657    /// Asserted on applied output — the five facts of every row, plus the
1658    /// ordinal — for every present oid, for absent oids, and through the
1659    /// serial, batch, extent-only and ordinal-only paths. A row-count or an
1660    /// `is_some()` check would pass for three indexes that disagreed on every
1661    /// value.
1662    ///
1663    /// Seen RED by changing `ColumnarPayload::row_at` to read `self.len.value(i)`
1664    /// for `offset`: "layouts disagree on 80313d462e6994e9e819193e6fdbd5f8390e92e3
1665    ///   left: IndexRow { ordinal: 259, offset: 344, len: 344, .. }
1666    ///  right: IndexRow { ordinal: 259, offset: 12, len: 344, .. }".
1667    /// Restored.
1668    ///
1669    /// Seen RED a second time, for the packed arm specifically, by swapping the
1670    /// `len` and `uncompressed_size` writes in `packed_column` only, so the
1671    /// writer and the reader disagree about the record layout:
1672    /// "C disagrees with A on 80313d462e6994e9e819193e6fdbd5f8390e92e3
1673    ///   left: IndexRow { offset: 12, len: 344, uncompressed_size: 1720 }
1674    ///  right: IndexRow { offset: 12, len: 1720, uncompressed_size: 344 }".
1675    /// Restored. Note that A and B stayed green throughout — only a guard that
1676    /// compares the packed arm against them can see this class of bug.
1677    ///
1678    /// Seen RED a third time, when `delta_base` was added, by dropping
1679    /// [`FourTables`] read `COL_LEN` into `ColumnarPayload::delta_base` — the
1680    /// single most plausible way to wire a new column in wrong, since both are
1681    /// `u64` columns of the same section: "A and B disagree on
1682    /// 80313d462e6994e9e819193e6fdbd5f8390e92e3 / left: IndexRow { ordinal: 259,
1683    /// offset: 12, len: 344, obj_type: Tree, uncompressed_size: 1720,
1684    /// **delta_base: 344** } / right: IndexRow { … **delta_base: 0** }". B and C
1685    /// were right and A was wrong, and only the row-for-row comparison said so.
1686    /// Restored.
1687    #[test]
1688    fn the_three_layouts_return_identical_rows() {
1689        for &oid_len in &[20usize, 32] {
1690            let entries = synthetic_entries(500, oid_len, 0xA11CE);
1691            let (a, b, c) = arms(&entries);
1692            assert_eq!(a.len(), 500);
1693            assert_eq!(b.len(), 500);
1694            assert_eq!(c.len(), 500);
1695
1696            // Not a blind guard: the workload has to carry the column at
1697            // something other than its identity value, or every arm agreeing on
1698            // `delta_base: 0` would prove nothing about `delta_base` at all.
1699            let with_base = entries.iter().filter(|e| e.delta_base != 0).count();
1700            assert!(
1701                with_base >= 50,
1702                "only {with_base} of 500 entries carry a non-zero delta_base — this guard would \
1703                 sit on the identity value and could not see a column that was never written"
1704            );
1705
1706            let mut hits = 0usize;
1707            for e in &entries {
1708                let ra = a.lookup(&e.oid).unwrap_or_else(|| {
1709                    panic!("{} missed {}", a.name(), hex::encode(&e.oid))
1710                });
1711                let rb = b.lookup(&e.oid).unwrap_or_else(|| {
1712                    panic!("{} missed {}", b.name(), hex::encode(&e.oid))
1713                });
1714                let rc = c.lookup(&e.oid).unwrap_or_else(|| {
1715                    panic!("{} missed {}", c.name(), hex::encode(&e.oid))
1716                });
1717                assert_eq!(ra, rb, "A and B disagree on {}", hex::encode(&e.oid));
1718                assert_eq!(
1719                    ra,
1720                    rc,
1721                    "C disagrees with A on {}",
1722                    hex::encode(&e.oid)
1723                );
1724                // …and all three agree with the input, not just with each other.
1725                assert_eq!(rc.offset, e.offset);
1726                assert_eq!(rc.len, e.len);
1727                assert_eq!(rc.obj_type, e.obj_type);
1728                assert_eq!(rc.uncompressed_size, e.uncompressed_size);
1729                assert_eq!(
1730                    rc.delta_base,
1731                    e.delta_base,
1732                    "delta_base did not survive C for {}",
1733                    hex::encode(&e.oid)
1734                );
1735                assert_eq!(ra.delta_base, e.delta_base, "delta_base did not survive A");
1736                assert_eq!(rb.delta_base, e.delta_base, "delta_base did not survive B");
1737                hits += 1;
1738            }
1739            assert_eq!(hits, 500, "every entry must resolve");
1740
1741            // Absent oids: same answer everywhere, and that answer is None.
1742            let absent = synthetic_entries(200, oid_len, 0xBEEF_0000);
1743            for e in &absent {
1744                assert_eq!(a.lookup(&e.oid), None);
1745                assert_eq!(b.lookup(&e.oid), None);
1746                assert_eq!(c.lookup(&e.oid), None);
1747            }
1748
1749            // Every batch path, on a mix, positionally.
1750            let mut q: Vec<&[u8]> = Vec::new();
1751            for (i, e) in entries.iter().enumerate() {
1752                q.push(&e.oid);
1753                if i < absent.len() {
1754                    q.push(&absent[i].oid);
1755                }
1756            }
1757            let ba = a.lookup_batch(&q);
1758            let bb = b.lookup_batch(&q);
1759            let bc = c.lookup_batch(&q);
1760            assert_eq!(ba.len(), q.len());
1761            assert_eq!(ba, bb, "A/B batch paths disagree at oid_len {oid_len}");
1762            assert_eq!(ba, bc, "A/C batch paths disagree at oid_len {oid_len}");
1763            let n_hits = ba.iter().filter(|r| r.is_some()).count();
1764            assert_eq!(n_hits, 500, "expected exactly the 500 present oids to hit");
1765            for (i, r) in ba.iter().enumerate() {
1766                assert_eq!(*r, c.lookup(q[i]), "C batch/serial disagree at {i}");
1767            }
1768
1769            // The partial-row and ordinal-only paths agree with the full row.
1770            let ea = a.extents_batch(&q);
1771            let ec = c.extents_batch(&q);
1772            assert_eq!(ea, ec, "extent paths disagree");
1773            let oa = a.ordinals_batch(&q);
1774            let oc = c.ordinals_batch(&q);
1775            assert_eq!(oa, oc, "ordinal paths disagree");
1776            for i in 0..q.len() {
1777                assert_eq!(ea[i], ba[i].map(|r| (r.offset, r.len)), "extent != row at {i}");
1778                assert_eq!(oa[i], ba[i].map(|r| r.ordinal), "ordinal != row at {i}");
1779            }
1780        }
1781    }
1782
1783    /// **`delta_base` is an offset that locates a row of this very table**, in
1784    /// all three arms — which is the property an ordinal could not have.
1785    ///
1786    /// Read back through the index (never from the input entries), every
1787    /// non-zero `delta_base` is looked up against the set of `offset`s the index
1788    /// itself reports, and it has to land on one, earlier in the archive than
1789    /// the delta that names it. `offset` and `delta_base` are the same
1790    /// coordinate space, and this is the assertion that says so.
1791    ///
1792    /// Seen RED by making `synthetic_entries` write `prev.offset + 1` as the
1793    /// delta base — a value one byte off a real entry boundary, which is
1794    /// precisely how an off-by-one in a rebase or in a varint would look:
1795    /// "A: delta_base 4601 of ordinal 1256 lands on no entry offset in this
1796    /// index". Restored to `prev.offset`.
1797    ///
1798    /// Seen RED a second time, on the ordinal question itself, by writing the
1799    /// base's *row index* into the column instead of its offset:
1800    /// "A: delta_base 2 of ordinal 1256 lands on no entry offset in this index"
1801    /// — a small number that is a perfectly valid row address and an invalid
1802    /// archive address, which is §13's decision failing loudly rather than
1803    /// silently. Restored.
1804    #[test]
1805    fn delta_base_locates_a_real_entry_in_every_arm() {
1806        let entries = synthetic_entries(2000, 20, 0xDE17A);
1807        let (a, b, c) = arms(&entries);
1808
1809        // The offsets this index reports, taken from the index and not from the
1810        // input: `lookup` of every stored oid.
1811        let offsets: std::collections::HashSet<u64> =
1812            entries.iter().filter_map(|e| a.lookup(&e.oid)).map(|r| r.offset).collect();
1813        assert_eq!(offsets.len(), 2000, "every row must report a distinct offset");
1814
1815        let arms: [(&str, &dyn ObjectIndex); 3] = [("A", &a), ("B", &b), ("C", &c)];
1816        let mut checked = 0usize;
1817        for (name, idx) in arms {
1818            let mut with_base = 0usize;
1819            for e in &entries {
1820                let row = idx.lookup(&e.oid).expect("stored oid resolves");
1821                if row.delta_base == 0 {
1822                    assert_ne!(
1823                        row.obj_type,
1824                        ObjType::OfsDelta,
1825                        "{name}: an ofs-delta with no recorded base is a row that cannot be \
1826                         resolved at all"
1827                    );
1828                    continue;
1829                }
1830                with_base += 1;
1831                assert!(
1832                    offsets.contains(&row.delta_base),
1833                    "{name}: delta_base {} of ordinal {} lands on no entry offset in this index",
1834                    row.delta_base,
1835                    row.ordinal
1836                );
1837                assert!(
1838                    row.delta_base < row.offset,
1839                    "{name}: delta_base {} is not earlier in the archive than the delta at {}",
1840                    row.delta_base,
1841                    row.offset
1842                );
1843            }
1844            assert!(
1845                with_base > 100,
1846                "{name}: only {with_base} of 2000 rows carried a base — nothing was proven"
1847            );
1848            checked += with_base;
1849        }
1850        assert!(checked > 300, "three arms must each have checked real bases");
1851    }
1852
1853    /// The two column scans return the same answer from all three arms, and
1854    /// that answer is the one computed straight from the input entries.
1855    ///
1856    /// This is the guard that makes the scan half of the benchmark meaningful:
1857    /// a strided reader that skipped rows would be very fast and very wrong.
1858    ///
1859    /// Seen RED by advancing `PackedPayload::sum_uncompressed` by `PACKED_LEN + 8`
1860    /// instead of `PACKED_LEN`: "C sum_uncompressed: left 8826373087920709042,
1861    /// right 12349770". The stride slipped out of phase, so it summed bytes
1862    /// straddling field boundaries — a fast, wrong scan that no timing
1863    /// assertion would ever have noticed. Restored.
1864    #[test]
1865    fn column_scans_agree_across_arms_and_with_the_input() {
1866        let entries = synthetic_entries(2000, 20, 0x5CA7);
1867        let (a, b, c) = arms(&entries);
1868
1869        let want: u64 = entries.iter().map(|e| e.uncompressed_size).sum();
1870        assert_eq!(a.sum_uncompressed(), want, "A sum_uncompressed");
1871        assert_eq!(b.sum_uncompressed(), want, "B sum_uncompressed");
1872        assert_eq!(c.sum_uncompressed(), want, "C sum_uncompressed");
1873        assert!(want > 0, "the workload must actually carry sizes");
1874
1875        let mut total = 0usize;
1876        for t in ObjType::ALL {
1877            let want = entries.iter().filter(|e| e.obj_type == t).count();
1878            assert_eq!(a.count_type(t), want, "A count_type({})", t.as_str());
1879            assert_eq!(b.count_type(t), want, "B count_type({})", t.as_str());
1880            assert_eq!(c.count_type(t), want, "C count_type({})", t.as_str());
1881            assert!(want > 0, "{} must appear in the workload", t.as_str());
1882            total += want;
1883        }
1884        assert_eq!(total, entries.len(), "the six types must partition the index");
1885    }
1886
1887    /// The packed record is 33 bytes laid out exactly as documented, read
1888    /// straight out of the IPC buffer rather than through the accessor that is
1889    /// under test. A layout that changed silently would still round-trip
1890    /// through its own reader; this pins it to the bytes.
1891    ///
1892    /// Seen RED by moving the type byte from offset 16 to offset 24 in
1893    /// `packed_column`: "row 0 byte 16 is 0 but the type code is 6". Restored.
1894    ///
1895    /// Seen RED again for the fifth field, by writing `e.offset` where
1896    /// `packed_column` writes `e.delta_base`: "row 0 bytes 25..33 must be the
1897    /// delta base / left: 368 / right: 12". Restored. Writing the *offset*
1898    /// there is the failure mode worth catching: it is a plausible archive
1899    /// offset, it is even a real entry boundary, so nothing downstream would
1900    /// look odd — only a comparison against the input entry tells the two
1901    /// apart.
1902    #[test]
1903    fn the_packed_record_is_the_documented_33_bytes() {
1904        let entries = synthetic_entries(64, 20, 0x9F);
1905        let c = PackedPayload::build(&entries).unwrap();
1906        assert_eq!(PACKED_LEN, 33);
1907        assert_eq!(PACKED_LEN, 8 + 8 + 1 + 8 + 8, "offset+len+type+size+delta_base");
1908
1909        let mut sorted: Vec<&IndexEntry> = entries.iter().collect();
1910        sorted.sort_by(|x, y| x.oid.cmp(&y.oid));
1911        let d = c.payload.value_data();
1912        assert_eq!(d.len(), 64 * PACKED_LEN, "payload column is not 33 bytes per row");
1913        assert!(
1914            sorted.iter().filter(|e| e.delta_base != 0).count() >= 4,
1915            "the fixture must carry real delta bases or bytes 25..33 are all zero"
1916        );
1917
1918        for (i, e) in sorted.iter().enumerate() {
1919            let r = &d[i * PACKED_LEN..(i + 1) * PACKED_LEN];
1920            assert_eq!(
1921                u64::from_le_bytes(r[0..8].try_into().unwrap()),
1922                e.offset,
1923                "row {i} bytes 0..8 must be the offset"
1924            );
1925            assert_eq!(
1926                u64::from_le_bytes(r[8..16].try_into().unwrap()),
1927                e.len,
1928                "row {i} bytes 8..16 must be the len"
1929            );
1930            assert_eq!(
1931                r[16],
1932                e.obj_type.code(),
1933                "row {i} byte 16 is {} but the type code is {}",
1934                r[16],
1935                e.obj_type.code()
1936            );
1937            assert_eq!(
1938                u64::from_le_bytes(r[17..25].try_into().unwrap()),
1939                e.uncompressed_size,
1940                "row {i} bytes 17..25 must be the uncompressed size"
1941            );
1942            assert_eq!(
1943                u64::from_le_bytes(r[25..33].try_into().unwrap()),
1944                e.delta_base,
1945                "row {i} bytes 25..33 must be the delta base"
1946            );
1947        }
1948    }
1949
1950    /// The whole premise of the third arm, asserted as a byte count rather than
1951    /// as an argument: a full-row fetch touches **one** payload buffer of 33
1952    /// bytes per row, where the columnar arms touch five buffers whose bases are
1953    /// megabytes apart.
1954    ///
1955    /// Seen RED by asserting the columnar arm's payload buffers were within 25
1956    /// bytes of each other: "offset and size buffers are 1737664 bytes apart,
1957    /// not adjacent" at 100 000 rows. That distance is the fact the packed arm
1958    /// exists to change. Restored to assert the true distances.
1959    #[test]
1960    fn a_packed_row_is_one_stride_and_a_columnar_row_is_five() {
1961        let entries = synthetic_entries(100_000, 20, 0x0FF5);
1962        let (_, b, c) = arms(&entries);
1963
1964        // Columnar: five payload buffers, and consecutive facts of ONE row are
1965        // ~800 kB apart because each column is 100000 × 8 bytes long.
1966        let base = |s: &[u8]| s.as_ptr() as usize;
1967        let off = base(b.cols.offset.values().inner().as_slice());
1968        let len = base(b.cols.len.values().inner().as_slice());
1969        let size = base(b.cols.size.values().inner().as_slice());
1970        let dbase = base(b.cols.delta_base.values().inner().as_slice());
1971        assert!(
1972            len.abs_diff(off) >= 800_000,
1973            "offset and len columns are only {} bytes apart — this arm is supposed to be \
1974             columnar",
1975            len.abs_diff(off)
1976        );
1977        assert!(size.abs_diff(off) >= 800_000);
1978        assert!(
1979            dbase.abs_diff(off) >= 800_000 && dbase.abs_diff(size) >= 800_000,
1980            "delta_base is {} bytes from offset and {} from size — the fifth fact must be its \
1981             own stride, not a field inside another column",
1982            dbase.abs_diff(off),
1983            dbase.abs_diff(size)
1984        );
1985
1986        // Packed: the five facts of one row are inside 33 consecutive bytes.
1987        let d = c.payload.value_data();
1988        assert_eq!(d.len(), 100_000 * PACKED_LEN);
1989        let row7 = &d[7 * PACKED_LEN..8 * PACKED_LEN];
1990        let r = c.lookup(&entries.iter().min_by_key(|e| e.oid.clone()).unwrap().oid);
1991        assert!(r.is_some(), "the lexicographically first oid must resolve");
1992        // Every fact of row 7 is readable from those 33 bytes alone.
1993        let from_bytes = IndexRow {
1994            ordinal: 7,
1995            offset: u64::from_le_bytes(row7[0..8].try_into().unwrap()),
1996            len: u64::from_le_bytes(row7[8..16].try_into().unwrap()),
1997            obj_type: ObjType::from_code(row7[16]).unwrap(),
1998            uncompressed_size: u64::from_le_bytes(row7[17..25].try_into().unwrap()),
1999            delta_base: u64::from_le_bytes(row7[25..33].try_into().unwrap()),
2000        };
2001        assert_eq!(
2002            from_bytes,
2003            c.row_at(7),
2004            "one 33-byte slice must carry the whole row"
2005        );
2006    }
2007
2008    /// The ordinal is the oid-lexicographic rank in **all three** arms, and it
2009    /// is the join key `FourTables` relies on. Asserted against an
2010    /// independently sorted copy of the input rather than against another arm.
2011    ///
2012    /// Seen RED by making `oid_order` sort by `offset` instead of by `oid`:
2013    /// "A ordinal is not the rank — left: 206, right: 0" on the first row.
2014    /// Restored.
2015    #[test]
2016    fn ordinals_are_the_oid_lexicographic_rank_in_every_arm() {
2017        let entries = synthetic_entries(300, 20, 7);
2018        let (a, b, c) = arms(&entries);
2019        let mut sorted: Vec<&IndexEntry> = entries.iter().collect();
2020        sorted.sort_by(|x, y| x.oid.cmp(&y.oid));
2021        for (rank, e) in sorted.iter().enumerate() {
2022            let ra = a.lookup(&e.oid).unwrap();
2023            let rb = b.lookup(&e.oid).unwrap();
2024            let rc = c.lookup(&e.oid).unwrap();
2025            assert_eq!(ra.ordinal as usize, rank, "A ordinal is not the rank");
2026            assert_eq!(rb.ordinal as usize, rank, "B ordinal is not the rank");
2027            assert_eq!(rc.ordinal as usize, rank, "C ordinal is not the rank");
2028            // The oid column of each arm, read at that ordinal, is that oid.
2029            assert_eq!(a.oid_column().value(rank), e.oid.as_slice());
2030            assert_eq!(b.oid_column().value(rank), e.oid.as_slice());
2031            assert_eq!(c.oid_column().value(rank), e.oid.as_slice());
2032        }
2033    }
2034
2035    /// Zero-copy, asserted by pointer containment rather than by hope, in all
2036    /// three arms.
2037    ///
2038    /// `decode_ipc` uses `with_require_alignment(true)`, so a misaligned buffer
2039    /// is an error, not a silent re-allocation. This proves the positive side:
2040    /// each array's bytes are *inside* the IPC buffer it came from.
2041    ///
2042    /// Seen RED by swapping `decode_ipc_buffer`'s `StreamDecoder` for the
2043    /// high-level `StreamReader` over a `Cursor` (which owns its own
2044    /// allocations): "FourTables columns were copied out of IPC". Restored.
2045    ///
2046    /// A weaker edit did **not** turn it red and is worth recording: replacing
2047    /// the `MutableBuffer` copy with `Buffer::from_vec(bytes.to_vec())` left the
2048    /// test green, because glibc happened to hand back an 8-aligned allocation
2049    /// for a ~57 kB `Vec`. Alignment by luck is not alignment, which is what
2050    /// [`require_alignment_is_load_bearing`] pins down separately.
2051    #[test]
2052    fn every_column_is_a_zero_copy_view_into_its_ipc_section() {
2053        let entries = synthetic_entries(1000, 32, 11);
2054        let (a, b, c) = arms(&entries);
2055        assert!(a.column_is_inside_ipc(), "FourTables columns were copied out of IPC");
2056        assert!(b.column_is_inside_ipc(), "OneTable columns were copied out of IPC");
2057        assert!(c.column_is_inside_ipc(), "PackedPayload columns were copied out of IPC");
2058
2059        // And the payload really is there: 1000 × 32 oid bytes + 1000 × 8 for
2060        // each of offset/len/size/delta_base + 1000 × 1 type = 65000 bytes
2061        // minimum.
2062        assert!(
2063            b.ipc_bytes() >= 65_000,
2064            "one-table section is {} bytes, too small to hold the payload",
2065            b.ipc_bytes()
2066        );
2067        assert!(a.ipc_bytes() >= 65_000);
2068        assert!(c.ipc_bytes() >= 65_000);
2069    }
2070
2071    /// `with_require_alignment(true)` is the thing that makes "zero-copy" a
2072    /// contract instead of a hope: with it off, arrow silently allocates a fresh
2073    /// aligned buffer and copies, and the only symptom is that opening a large
2074    /// index got slower and doubled its peak memory.
2075    ///
2076    /// The same IPC bytes are decoded twice — once from a 64-byte-aligned
2077    /// buffer, once from the identical bytes sitting at offset 4 of the same
2078    /// allocation. The first must succeed and be a view; the second must be
2079    /// **refused**.
2080    ///
2081    /// Seen RED by dropping `.with_require_alignment(true)` from
2082    /// `decode_ipc_buffer`: the misaligned decode returned `Ok` and the test
2083    /// panicked with "a misaligned buffer must be refused, not silently
2084    /// copied". Restored.
2085    #[test]
2086    fn require_alignment_is_load_bearing() {
2087        let entries = synthetic_entries(64, 20, 77);
2088        let order = oid_order(&entries);
2089        let c = columns(&entries, &order, 20);
2090        let batch = RecordBatch::try_new(
2091            extent_schema(),
2092            vec![Arc::new(c.offset), Arc::new(c.len), Arc::new(c.delta_base)],
2093        )
2094        .unwrap();
2095        let ipc = to_ipc(&batch).unwrap();
2096
2097        // Aligned: 64 bytes of padding then the stream, sliced back to the
2098        // stream's start — the pointer is 64-aligned.
2099        let mut mb = MutableBuffer::with_capacity(64 + ipc.len());
2100        mb.extend_from_slice(&[0u8; 64]);
2101        mb.extend_from_slice(&ipc);
2102        let padded: Buffer = mb.into();
2103        let (owner, ok) = decode_ipc_buffer(padded.slice(64)).expect("aligned decode must work");
2104        assert_eq!(ok.num_rows(), 64, "the aligned decode must yield all 64 rows");
2105        assert!(inside(&owner, ok.column(0).to_data().buffers()[0].as_slice()));
2106
2107        // Misaligned by 4: byte-identical stream, 4-byte-offset pointer.
2108        let mut mb = MutableBuffer::with_capacity(4 + ipc.len());
2109        mb.extend_from_slice(&[0u8; 4]);
2110        mb.extend_from_slice(&ipc);
2111        let skewed: Buffer = mb.into();
2112        let err = match decode_ipc_buffer(skewed.slice(4)) {
2113            Ok(_) => panic!("a misaligned buffer must be refused, not silently copied"),
2114            Err(e) => e.to_string(),
2115        };
2116        assert!(
2117            err.contains("Misaligned"),
2118            "the refusal must name the misalignment, got: {err}"
2119        );
2120    }
2121
2122    /// Four sections cost strictly more IPC framing than one, and the amount is
2123    /// asserted as a byte count, not as "greater than".
2124    ///
2125    /// Each Arrow IPC stream carries a schema message, a record-batch message
2126    /// header and an end-of-stream marker. Three extra copies of that is the
2127    /// entire structural overhead of layout A, and on a tiny index it dominates.
2128    ///
2129    /// Seen RED by asserting `>= 900`: "expected ≥900 bytes of extra framing
2130    /// for three extra sections, got 728 (A=4704 B=3976)". The bound below is
2131    /// the one the measurement supports — 728 bytes, ~243 per extra section,
2132    /// which is the schema + batch-header + EOS framing and not payload.
2133    #[test]
2134    fn four_sections_pay_three_extra_ipc_frames() {
2135        let entries = synthetic_entries(64, 20, 3);
2136        let (a, b, _) = arms(&entries);
2137        let extra = a.ipc_bytes() as i64 - b.ipc_bytes() as i64;
2138        assert!(
2139            (600..1200).contains(&extra),
2140            "expected 600..1200 bytes of extra framing for three extra sections, got {extra} \
2141             (A={} B={})",
2142            a.ipc_bytes(),
2143            b.ipc_bytes()
2144        );
2145        // The four sections are separately sized and the oid one is the biggest.
2146        let lens = a.ipc_section_lens();
2147        assert_eq!(lens.len(), 4);
2148        assert!(lens[0] > lens[2], "oid section {} must exceed type section {}", lens[0], lens[2]);
2149    }
2150
2151    /// The packed arm stores the same *payload* bytes — 33 per row against
2152    /// 8 + 8 + 1 + 8 + 8 — so nothing in the timing comparison is a
2153    /// memory-footprint effect in disguise. Its IPC **section** is nonetheless
2154    /// smaller, and the amount is asserted as a scaling law rather than as a
2155    /// constant, at two sizes, so it is attributed rather than hand-waved.
2156    ///
2157    /// Four fewer columns is four fewer per-column IPC buffers, and the
2158    /// saving grows linearly with row count at about `4 · n/8` bytes. It was
2159    /// `3 · n/8` — 38 016 B at 100 000 rows — before `delta_base` gave the
2160    /// columnar arm a fifth payload column. That is the only footprint
2161    /// difference between the three arms.
2162    ///
2163    /// Seen RED by asserting the two sections were within 4096 bytes of each
2164    /// other: "packed payload is 4525512 bytes against 4563528 columnar", which
2165    /// is what sent me to count buffers instead of guessing. Seen RED a second
2166    /// time by asserting the saving was *constant* across the two sizes:
2167    /// "saving did not scale: 38016 at 100000 rows, 75456 at 200000". Seen RED
2168    /// a third time by leaving the `3 · n/8` bound in place after the fifth
2169    /// column landed: "at 100000 rows the saving is 50560 B, not the ~37500 B
2170    /// that four fewer columns accounts for" — 50 560 B is `4 · n/8` plus
2171    /// framing, so the bound is counting real buffers rather than tracking a
2172    /// moving target.
2173    #[test]
2174    fn the_packed_arm_holds_the_same_payload_but_a_smaller_section() {
2175        let mut savings = Vec::new();
2176        for &n in &[100_000usize, 200_000] {
2177            let entries = synthetic_entries(n, 20, 0xBEE5);
2178            let (_, b, c) = arms(&entries);
2179
2180            // The payload itself is byte-for-byte the same size.
2181            let payload_bytes = n * (8 + 8 + 1 + 8 + 8);
2182            assert_eq!(c.payload.value_data().len(), payload_bytes);
2183            assert_eq!(c.payload.value_data().len(), n * PACKED_LEN);
2184
2185            let saving = b.ipc_bytes() as i64 - c.ipc_bytes() as i64;
2186            assert!(
2187                saving > 0,
2188                "the two-column section ({}) must not be larger than the six-column one ({})",
2189                c.ipc_bytes(),
2190                b.ipc_bytes()
2191            );
2192            // Four fewer columns, at roughly n/8 bytes of per-column buffer each.
2193            let expect = 4 * (n as i64) / 8;
2194            assert!(
2195                (expect..expect + 4096).contains(&saving),
2196                "at {n} rows the saving is {saving} B, not the ~{expect} B that four fewer \
2197                 columns accounts for"
2198            );
2199            savings.push(saving);
2200        }
2201        assert!(
2202            savings[1] > savings[0] * 3 / 2,
2203            "saving did not scale: {} at 100000 rows, {} at 200000",
2204            savings[0],
2205            savings[1]
2206        );
2207    }
2208
2209    /// LAW 2 — the 8-byte prefix collision, carried through all three arms.
2210    ///
2211    /// Two oids sharing their first eight bytes share one stree key, so the tree
2212    /// alone cannot tell them apart. Every arm must still return each oid's own
2213    /// four facts, and a third oid on the same prefix that was never inserted
2214    /// must miss.
2215    ///
2216    /// Seen RED by replacing `OidResolver::ordinal`'s verified `tree.lookup`
2217    /// with the unverified `tree.candidate_run(key_for_oid(oid)).start` — i.e.
2218    /// trusting the 8-byte prefix: "collision resolved to the wrong row —
2219    /// left: 1000, right: 2000". Both colliding oids came back as the first
2220    /// candidate. Restored.
2221    #[test]
2222    fn a_prefix_collision_resolves_to_the_right_row_in_every_arm() {
2223        let prefix = [0xde, 0xad, 0xbe, 0xef, 0x01, 0x02, 0x03, 0x04];
2224        // The two colliding entries carry *different* delta bases, so a column
2225        // read at the wrong ordinal shows up here too and not only in `offset`.
2226        let mk = |last: u8, off: u64, ty: ObjType| {
2227            let mut oid = vec![0u8; 20];
2228            oid[..8].copy_from_slice(&prefix);
2229            oid[8] = last;
2230            IndexEntry {
2231                oid,
2232                offset: off,
2233                len: 10,
2234                obj_type: ty,
2235                uncompressed_size: off * 2,
2236                delta_base: off / 2,
2237            }
2238        };
2239        let mut entries = vec![mk(0xaa, 1000, ObjType::Commit), mk(0xbb, 2000, ObjType::OfsDelta)];
2240        entries.extend(synthetic_entries(200, 20, 99));
2241        let (a, b, c) = arms(&entries);
2242
2243        for (last, off, ty) in [(0xaau8, 1000u64, ObjType::Commit), (0xbb, 2000, ObjType::OfsDelta)]
2244        {
2245            let oid = mk(last, off, ty).oid;
2246            let ra = a.lookup(&oid).expect("colliding oid must resolve in A");
2247            let rb = b.lookup(&oid).expect("colliding oid must resolve in B");
2248            let rc = c.lookup(&oid).expect("colliding oid must resolve in C");
2249            assert_eq!(ra, rb);
2250            assert_eq!(ra, rc);
2251            assert_eq!(ra.offset, off, "collision resolved to the wrong row");
2252            assert_eq!(ra.obj_type, ty);
2253            assert_eq!(ra.delta_base, off / 2, "collision resolved to the wrong delta base");
2254        }
2255        let never = mk(0xcc, 0, ObjType::Blob).oid;
2256        assert_eq!(a.lookup(&never), None, "unstored oid on a colliding prefix must miss");
2257        assert_eq!(b.lookup(&never), None);
2258        assert_eq!(c.lookup(&never), None);
2259    }
2260
2261    /// All six pack type codes survive the round trip in every arm, including
2262    /// the two delta kinds that are the reason this fact is stored at all.
2263    ///
2264    /// Seen RED by mapping `ObjType::RefDelta` to code 6 in `code()`, colliding
2265    /// with `OfsDelta`: "type did not survive the column — left: OfsDelta,
2266    /// right: RefDelta". Restored.
2267    #[test]
2268    fn all_six_pack_types_round_trip_including_the_deltas() {
2269        let entries: Vec<IndexEntry> = ObjType::ALL
2270            .iter()
2271            .enumerate()
2272            .map(|(i, &t)| {
2273                let mut oid = vec![0u8; 32];
2274                oid[0] = i as u8 * 17;
2275                oid[31] = i as u8;
2276                IndexEntry {
2277                    oid,
2278                    offset: 100 + i as u64,
2279                    len: 5,
2280                    obj_type: t,
2281                    uncompressed_size: 900 + i as u64,
2282                    // Only the two delta codes carry a base, which is the
2283                    // relationship the type column exists to express.
2284                    delta_base: match t {
2285                        ObjType::OfsDelta => 100,
2286                        _ => 0,
2287                    },
2288                }
2289            })
2290            .collect();
2291        let (a, b, c) = arms(&entries);
2292        let mut seen: Vec<ObjType> = Vec::new();
2293        for e in &entries {
2294            let ra = a.lookup(&e.oid).unwrap();
2295            assert_eq!(ra, b.lookup(&e.oid).unwrap());
2296            assert_eq!(ra, c.lookup(&e.oid).unwrap());
2297            assert_eq!(ra.obj_type, e.obj_type, "type did not survive the column");
2298            seen.push(ra.obj_type);
2299        }
2300        assert_eq!(seen, ObjType::ALL.to_vec(), "all six codes must be distinct");
2301        assert_eq!(ObjType::from_code(0), None);
2302        assert_eq!(ObjType::from_code(5), None, "git leaves 5 unused; it must not be mapped");
2303        assert_eq!(ObjType::from_code(8), None);
2304    }
2305
2306    /// An empty index and a one-row index are clean, not panics, in every arm.
2307    ///
2308    /// Seen RED by replacing `validate`'s empty-input `Ok(GitHashKind::Sha1)`
2309    /// with `bail!("an empty index")`: "A builds: an empty index" — every arm
2310    /// failed to construct at all. Restored.
2311    #[test]
2312    fn degenerate_sizes_are_clean_in_every_arm() {
2313        let (a, b, c) = arms(&[]);
2314        assert!(a.is_empty() && b.is_empty() && c.is_empty());
2315        let probe = vec![7u8; 20];
2316        assert_eq!(a.lookup(&probe), None);
2317        assert_eq!(b.lookup(&probe), None);
2318        assert_eq!(c.lookup(&probe), None);
2319        assert_eq!(c.lookup_batch(&[&probe[..]]), vec![None]);
2320        assert_eq!(c.extents_batch(&[&probe[..]]), vec![None]);
2321        // A scan over an empty index is 0, not a panic or a wrap.
2322        assert_eq!(c.sum_uncompressed(), 0);
2323        assert_eq!(c.count_type(ObjType::Blob), 0);
2324
2325        let one = synthetic_entries(1, 20, 5);
2326        let (a, b, c) = arms(&one);
2327        assert_eq!(a.lookup(&one[0].oid).unwrap(), b.lookup(&one[0].oid).unwrap());
2328        assert_eq!(a.lookup(&one[0].oid).unwrap(), c.lookup(&one[0].oid).unwrap());
2329        assert_eq!(c.lookup(&one[0].oid).unwrap().offset, one[0].offset);
2330        assert_eq!(c.sum_uncompressed(), one[0].uncompressed_size);
2331    }
2332
2333    /// A wrong-width oid is a miss, never a panic and never a wrong row —
2334    /// the query side of P-4.
2335    ///
2336    /// The width check inside `oid_index::lookup` is only a fast path; what
2337    /// actually makes this true is the **full-oid compare**, because
2338    /// `key_for_oid` zero-pads and an 8-byte truncation of a stored oid
2339    /// therefore lands on that oid's key. Seen RED by the same
2340    /// unverified-`candidate_run` edit as
2341    /// [`a_prefix_collision_resolves_to_the_right_row_in_every_arm`]:
2342    /// "a 8-byte oid must miss — left: Some(IndexRow { ordinal: 47, offset: 12,
2343    /// len: 1505, obj_type: RefDelta, uncompressed_size: 3010 }), right: None".
2344    /// Restored.
2345    #[test]
2346    fn a_wrong_width_oid_misses_in_every_arm() {
2347        let entries = synthetic_entries(64, 20, 21);
2348        let (a, b, c) = arms(&entries);
2349        let short = &entries[0].oid[..8];
2350        let mut long = entries[0].oid.clone();
2351        long.extend_from_slice(&[0u8; 12]);
2352        for q in [short, &long[..]] {
2353            assert_eq!(a.lookup(q), None, "a {}-byte oid must miss", q.len());
2354            assert_eq!(b.lookup(q), None);
2355            assert_eq!(c.lookup(q), None);
2356        }
2357        assert_eq!(a.lookup_batch(&[short, &long]), vec![None, None]);
2358        assert_eq!(b.lookup_batch(&[short, &long]), vec![None, None]);
2359        assert_eq!(c.lookup_batch(&[short, &long]), vec![None, None]);
2360    }
2361
2362    /// A duplicate oid, or a mix of sha1 and sha256, is refused at build.
2363    /// Silently keeping one of two identical oids would give the arms different
2364    /// ordinals for the same object and make every later comparison a lie.
2365    ///
2366    /// Seen RED by short-circuiting the duplicate scan in `validate` with
2367    /// `if false && …`: the build succeeded and the test panicked with
2368    /// "a duplicate oid must be refused at build". Restored.
2369    #[test]
2370    fn build_refuses_duplicates_and_mixed_widths() {
2371        fn why<T>(r: Result<T>, what: &str) -> String {
2372            match r {
2373                Ok(_) => panic!("{what} must be refused at build"),
2374                Err(e) => e.to_string(),
2375            }
2376        }
2377        let mut dup = synthetic_entries(4, 20, 1);
2378        dup.push(dup[0].clone());
2379        let err = why(FourTables::build(&dup), "a duplicate oid");
2380        assert!(err.contains("duplicate oid"), "{err}");
2381        assert!(OneTableFourColumns::build(&dup).is_err());
2382        assert!(PackedPayload::build(&dup).is_err());
2383
2384        let mut mixed = synthetic_entries(4, 20, 2);
2385        mixed.push(synthetic_entries(1, 32, 3).pop().unwrap());
2386        let err = why(PackedPayload::build(&mixed), "a mixed-width index");
2387        assert!(err.contains("mixed oid widths"), "{err}");
2388        assert!(FourTables::build(&mixed).is_err());
2389        assert!(OneTableFourColumns::build(&mixed).is_err());
2390    }
2391
2392    /// Every arm is `Send + Sync`, which is what lets a server share one index
2393    /// across connection handlers. A compile-time assertion, plus an actual
2394    /// cross-thread lookup so it is not only a type-level claim.
2395    ///
2396    /// Seen RED by dropping `Send + Sync` from the `ObjectIndex` supertrait
2397    /// list: "error[E0277]: `dyn index_layout::ObjectIndex` cannot be shared
2398    /// between threads safely" and the same for `Send` — the crate stopped
2399    /// compiling. Restored.
2400    #[test]
2401    fn an_index_can_be_shared_across_threads() {
2402        fn assert_send_sync<T: Send + Sync>() {}
2403        assert_send_sync::<FourTables>();
2404        assert_send_sync::<OneTableFourColumns>();
2405        assert_send_sync::<PackedPayload>();
2406
2407        let entries = synthetic_entries(256, 20, 42);
2408        let a: Arc<dyn ObjectIndex> = Arc::new(FourTables::build(&entries).unwrap());
2409        let b: Arc<dyn ObjectIndex> = Arc::new(OneTableFourColumns::build(&entries).unwrap());
2410        let c: Arc<dyn ObjectIndex> = Arc::new(PackedPayload::build(&entries).unwrap());
2411        let oids: Vec<Vec<u8>> = entries.iter().map(|e| e.oid.clone()).collect();
2412        let mut handles = Vec::new();
2413        for idx in [a, b, c] {
2414            let oids = oids.clone();
2415            handles.push(std::thread::spawn(move || {
2416                let refs: Vec<&[u8]> = oids.iter().map(|o| o.as_slice()).collect();
2417                idx.lookup_batch(&refs).iter().filter(|r| r.is_some()).count()
2418            }));
2419        }
2420        for h in handles {
2421            assert_eq!(h.join().unwrap(), 256, "every oid must resolve off-thread");
2422        }
2423    }
2424}