znippy_plugin_git/exploded.rs
1//! **§14's exploded objects: the CONTRACT — who hands a resolved object over,
2//! and what the counters mean.**
3//!
4//! The store itself is [`crate::exploded_arrow`]: one Arrow IPC table, one row
5//! per object, payload in the row. This module is what both ends agree on —
6//! [`PayloadSink`] and the sinks that are not a store ([`NoSink`],
7//! [`CaptureOne`]), plus [`ExplodedStats`], so no caller can tell the medium
8//! apart by its instruments.
9//!
10//! # What §14 decided, and what it cost
11//!
12//! §14 fixed the shape — *the verbatim pack bytes are the truth and the resolved
13//! objects are a derived side table* — and left one sub-question open, "resolve
14//! eagerly or lazily?". **Decided 2026-08-08: EAGER**, built by the background
15//! indexer, with disk explicitly not a consideration ("we don't care if disk is
16//! tripled — we want wall speed minimum"). So there is no size cap, no threshold
17//! and no lazy fallback that skips an object to save space; what an operator can
18//! choose is [`crate::exploded_arrow::ExplodePolicy`], and that is a setting
19//! rather than a heuristic.
20//!
21//! ```text
22//! push ─► verbatim bytes fsynced ─► ack ← the truth, §14
23//! │
24//! └─ channel ─► indexer ─► resolve ─► ┬─► objects table (oid → extent)
25//! └─► THIS SINK ─► the Arrow table
26//! ```
27//!
28//! **The decision was right and the first medium was wrong.** MEASURED on
29//! `linux.git`, oden 2026-08-13, 11 697 976 objects: 6.4 GB of verbatim pack
30//! resolves to **16.8 GB** of content — 2.6×, inside the 3× that "tripled"
31//! budgeted — and redb held those 16.8 GB in a **204 GB** file, 12× page churn,
32//! while serialising the `gatling_for_each` fan-out behind one write transaction
33//! at 96.8% of a single core. The eager decision stands; redb is gone from this
34//! path.
35//!
36//! # Three readers, and the third is why it is not optional
37//!
38//! | reader | had to do | now does |
39//! |---|---|---|
40//! | a content read | resolve a delta chain, inflate its base | **one point lookup** |
41//! | a thin pack's external base ([`crate::resolve::BaseSource`]) | read and re-resolve a whole pack | **one point lookup** |
42//! | `Derived::graph` / `reachable()` **after a clean reopen** | nothing — the payloads were stored nowhere | **a scan of the commits** |
43//!
44//! The third row is a correctness hole, not a speed one. Commit and tree
45//! payloads were held only in RAM, so a store that shut down **cleanly** came
46//! back with every pack's `indexed` bit legitimately set, nothing re-queued, and
47//! an empty commit graph — MEASURED on oden 2026-08-08, 2687 rows and **0 of 551
48//! commits**, with `reachable()` quietly returning a commit alone instead of its
49//! closure and `gc` seeing an empty live set.
50//!
51//! # Droppable, and that is not a caveat
52//!
53//! Every row is re-derivable from the verbatim pack bytes, so the table can be
54//! deleted at any time without consulting a client:
55//! [`crate::git_ops::Absorber::adopt_journal`] compares its row count against
56//! the `objects` table's and, if it is short, declares **no** pack absorbed — so
57//! every pack is re-queued and re-exploded. Absent means fall back and rebuild;
58//! it never means wrong. That is §13.12's `indexed`-bit logic applied unchanged
59//! to a derived table, and
60//! [`crate::git_ops::tests::dropping_the_exploded_table_and_reopening_still_answers`]
61//! asserts it by deleting the file.
62//!
63//! # What it costs, measured
64//!
65//! MEASURED on oden 2026-08-08, release, one real 2687-object / 5.4 MiB pack,
66//! four runs per column, **1-minute loadavg 1.87–2.04**. The two columns are the
67//! same binary with the sink swapped to [`NoSink`] and the fold stubbed out, so
68//! nothing but the table differs between them.
69//!
70//! | | without the table | with it | |
71//! |---|---:|---:|---:|
72//! | **ack** (`put_pack` returns) | 31.0–34.2 ms | 31.0–31.3 ms | **unchanged** |
73//! | **drain** (one pack absorbed) | 166.7–168.6 ms | 316.2–329.8 ms | 1.9x |
74//! | **indexer throughput** | 15 940–16 120 rows/s | 8 146–8 499 rows/s | **0.51x** |
75//!
76//! **The ack path is unchanged by construction, not by measurement.** `put_pack`
77//! walks, checks the closure, appends verbatim, fsyncs twice and queues 24 bytes,
78//! and not one of those lines touches this. The measured ack ranges overlap and
79//! the wider one is the *left* column, which is how a null result looks; no ack
80//! figure here is evidence of anything.
81//!
82//! Those figures are redb's. They are kept because the ack column is a
83//! construction argument that still holds, and the drain column is the honest
84//! record of what the eager decision cost when it was taken — not a claim about
85//! what the Arrow table costs, which has not been measured on that pack.
86
87use std::sync::Mutex;
88
89use anyhow::Result;
90
91use crate::object::GitObjectKind;
92
93/// Where every object the resolver produces is handed over.
94///
95/// A **sink** rather than a return value: [`crate::resolve::resolve_walked`]
96/// already drops a blob payload the moment nothing else in the pack deltas
97/// against it, and returning every payload instead would mean the whole pack
98/// inflated in RAM at once. Streaming it out keeps the resolver's peak footprint
99/// exactly what it was before this table existed.
100pub trait PayloadSink: Sync {
101 /// One resolved object: its oid, the type its chain resolves to, and the
102 /// inflated payload. Called **once per pack entry**, never for a delta's
103 /// intermediate state.
104 fn explode(&self, oid: &[u8], kind: GitObjectKind, payload: &[u8]) -> Result<()>;
105
106 /// **Will this sink read the payload of an object of this kind?**
107 ///
108 /// The resolver asks *before* it produces one, and does not call
109 /// [`explode`](Self::explode) at all when the answer is `no`. That is not
110 /// an optimisation the resolver invented: since lane Q the shipped policy
111 /// is [`ExplodePolicy::Commits`](crate::exploded_arrow::ExplodePolicy),
112 /// which drops trees and blobs — 90 % of the objects and essentially all of
113 /// the bytes — on the sink's own doorstep. Asking first is what lets the
114 /// resolver hand its objects over **in entry order** without buffering
115 /// those bytes to do it, and the ordered hand-over is what makes the stream
116 /// the same at one worker and at four.
117 ///
118 /// The default is `yes` for everything, which is the old behaviour exactly.
119 /// A sink that answers `no` is responsible for its own counters — it will
120 /// not be told about the object at all.
121 fn wants(&self, _kind: GitObjectKind) -> bool {
122 true
123 }
124
125 /// Write down whatever is buffered. Called by the resolver when the memory
126 /// watchdog says the box is short ([`crate::memguard`]) and at the end of a
127 /// pack; a sink that buffers nothing has nothing to do.
128 fn flush(&self) -> Result<()> {
129 Ok(())
130 }
131}
132
133/// A sink that keeps nothing. What every caller that only wants oids passes, and
134/// what makes "this resolve built no exploded rows" a visible choice rather than
135/// an omission.
136pub struct NoSink;
137
138impl PayloadSink for NoSink {
139 fn explode(&self, _oid: &[u8], _kind: GitObjectKind, _payload: &[u8]) -> Result<()> {
140 Ok(())
141 }
142 /// It keeps nothing, so it is never handed anything — and a resolve with
143 /// `NoSink` therefore copies no payload at all on its way out.
144 fn wants(&self, _kind: GitObjectKind) -> bool {
145 false
146 }
147}
148
149/// A sink that keeps **one** object: the fallback path's, for when the table
150/// cannot answer and the content has to come back out of the verbatim truth.
151///
152/// It exists because [`crate::resolve::Resolved::payload`] is not a complete
153/// answer to "what does this object contain" and never was — the resolver drops
154/// a blob payload the moment nothing else in the pack deltas against it, so
155/// `payload` is `None` for most blobs in most packs. Re-deriving a blob's
156/// content by reading that field therefore returned `None` for exactly the
157/// commonest object in a repository. Through the sink the payload is seen before
158/// it is dropped, so the fallback answers for every object the pack holds.
159pub struct CaptureOne<'a> {
160 want: &'a [u8],
161 got: Mutex<Option<(GitObjectKind, Vec<u8>)>>,
162}
163
164impl<'a> CaptureOne<'a> {
165 pub fn new(want: &'a [u8]) -> Self {
166 Self {
167 want,
168 got: Mutex::new(None),
169 }
170 }
171
172 /// What the resolve produced for that oid, if it produced it.
173 pub fn take(self) -> Option<(GitObjectKind, Vec<u8>)> {
174 self.got.into_inner().ok().flatten()
175 }
176
177 /// Has the wanted oid gone past? Lets a scan stop early.
178 pub fn seen(&self) -> bool {
179 self.got.lock().map(|g| g.is_some()).unwrap_or(false)
180 }
181}
182
183impl PayloadSink for CaptureOne<'_> {
184 fn explode(&self, oid: &[u8], kind: GitObjectKind, payload: &[u8]) -> Result<()> {
185 if oid == self.want
186 && let Ok(mut g) = self.got.lock()
187 {
188 *g = Some((kind, payload.to_vec()));
189 }
190 Ok(())
191 }
192}
193
194/// Counters, all of them **applied output**: rows that exist, lookups that
195/// happened. Nothing here is configuration echoed back.
196#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
197pub struct ExplodedStats {
198 /// Rows on disk plus rows buffered for the next flush.
199 pub rows: u64,
200 /// Rows this process has committed.
201 pub written: u64,
202 /// Content reads answered **from this table**.
203 pub served: u64,
204 /// Content reads that fell through and re-resolved a whole pack. The number
205 /// that distinguishes "the table answered" from "the answer happened to be
206 /// right", which identical bytes cannot.
207 pub rederived: u64,
208 /// Content reads nothing could answer.
209 pub absent: u64,
210}