znippy_plugin_git/reach.rs
1//! `__gunnar_reach__` — reachability bitmaps.
2//!
3//! For each *selected* commit, the set of objects reachable from it — the commit
4//! itself, its ancestors, and every tree and blob those commits point at —
5//! recorded as a roaring bitmap over **object ordinals**.
6//!
7//! The ordinal space is the archive's oid-lexicographic ordering of its distinct
8//! objects, which is also the order the sorted lookup sub-index is in and the
9//! order `__gunnar_oid__` records in its `ordinal` column. So a bitmap position
10//! is resolvable to an object with the oid index alone.
11//!
12//! What it buys: `want − have` becomes `reach(want) ANDNOT reach(have)` — a
13//! bitmap operation instead of a graph traversal. **No speedup is claimed here.**
14//! Nothing in this repository has measured it against a real repository with real
15//! merge history; the structure is built and correct, the number is not earned
16//! (LAW 7).
17//!
18//! Cost, stated plainly: the builder is O(commits × bitmap width) in time and
19//! holds live bitmaps for commits whose children are not yet processed, plus one
20//! memoized tree-closure bitmap per distinct tree. On a wide DAG that is not
21//! cheap. `ReachPolicy::max_commits` bounds how many bitmaps are *kept*, not how
22//! many are computed.
23
24use std::collections::HashMap;
25use std::path::Path;
26use std::sync::Arc;
27
28use anyhow::{Result, anyhow};
29use roaring::RoaringBitmap;
30use znippy_common::GUNNAR_REACH_MODULE;
31use znippy_common::arrow::array::{Array, BinaryArray, BinaryBuilder, StringArray, StringBuilder};
32use znippy_common::arrow::datatypes::{DataType, Field, Schema};
33use znippy_common::arrow::record_batch::RecordBatch;
34use znippy_common::read_reserved_section_bytes;
35
36use crate::graph::CommitNode;
37use crate::object::{GitObjectKind, tree_entries};
38
39/// Which commits get a bitmap.
40#[derive(Debug, Clone, Copy)]
41pub struct ReachPolicy {
42 /// Upper bound on how many bitmaps are sealed. Tips are taken first (a
43 /// commit with no child inside the archive — the branch heads a clone
44 /// negotiates against), then the remaining commits are sampled at an even
45 /// stride through generation order.
46 pub max_commits: usize,
47}
48
49impl Default for ReachPolicy {
50 fn default() -> Self {
51 // git's own bitmap selection is of this order: cover the tips, then
52 // sample the history so a `have` usually lands near a bitmapped commit.
53 Self { max_commits: 512 }
54 }
55}
56
57/// One sealed bitmap.
58#[derive(Debug, Clone, PartialEq)]
59pub struct ReachEntry {
60 pub commit: String,
61 pub bitmap: RoaringBitmap,
62}
63
64pub fn reach_schema() -> Arc<Schema> {
65 Arc::new(Schema::new(vec![
66 Field::new("commit_oid", DataType::Utf8, false),
67 Field::new("cardinality", DataType::UInt64, false),
68 Field::new("bitmap", DataType::Binary, false),
69 ]))
70}
71
72/// **Where a tree's bytes come from** — a lookup, not a table.
73///
74/// It was `&HashMap<String, Vec<u8>>`: every tree payload in the repository,
75/// resident, keyed by a hex `String`. MEASURED on oden 2026-08-30, the real
76/// `torvalds/linux` mirror through [`crate::git_ops::GitStore`]: **28.24 GB**
77/// of tree payloads — 3.24× the 8.13 GiB pack — held on disk in the eager
78/// exploded pool so that this map could be rebuilt into RAM on **every** fold.
79/// Peak RSS 50.9 GiB. The appliance has 16.
80///
81/// So the map became a lookup. The eager arm is still a `HashMap` and costs
82/// exactly what it did (`Cow::Borrowed`, no copy); the store's arm resolves the
83/// tree the way any other read does — from the exploded cache if it is there,
84/// by inflating its own delta chain out of the mmapped pack if it is not
85/// ([`crate::resolve::materialize_at`]) — and the walk then puts it in the
86/// cache for the next one.
87///
88/// `None` is *"not a tree this archive holds"* and is an ordinary answer, not
89/// a fault: a blob is a leaf, a gitlink is a commit in another repository, and
90/// a thin push's base may be outside the archive. Every caller below treats it
91/// as "stop here", which is what it always meant.
92pub trait TreeSource: Sync {
93 fn tree(&self, oid_hex: &str) -> Option<std::borrow::Cow<'_, [u8]>>;
94}
95
96/// The eager arm: a map that is already in memory. Unchanged in cost — the
97/// `Cow` borrows, so no payload is copied to satisfy the trait.
98impl TreeSource for HashMap<String, Vec<u8>> {
99 fn tree(&self, oid_hex: &str) -> Option<std::borrow::Cow<'_, [u8]>> {
100 self.get(oid_hex).map(|v| std::borrow::Cow::Borrowed(v.as_slice()))
101 }
102}
103
104/// **oid → ordinal, and ordinal → oid, over one flat buffer and nothing else.**
105///
106/// The ordinal space is `oids_raw` — every distinct oid in the archive,
107/// fixed-width and back to back, in **oid-lexicographic (= raw byte) order**,
108/// which is exactly the order the ordinal numbers it. Because that buffer is
109/// already sorted and already the inverse map (ordinal `o` is the slice at
110/// `o * oid_len`), both directions are answered *from it*:
111///
112/// - `ordinal_of_raw` / `ordinal_of_hex` — a **binary search** over the buffer,
113/// O(log n) with no probe table to keep in step with it;
114/// - `oid` — a slice.
115///
116/// This is what replaced [`Derived::oids`] (a hex `Vec<String>`) and
117/// [`Derived::ordinal`] (a `HashMap<String, u32>`) — two per-oid `String`
118/// allocations, built at every fold, that a comment then had to promise agreed
119/// with the flat buffer. One representation, no promise needed (LAW 5). It owns
120/// nothing: it is a `Copy` view (`&[u8]` + a length), so constructing it at open
121/// allocates zero bytes.
122///
123/// [`Derived::oids`]: crate::git_ops
124/// [`Derived::ordinal`]: crate::git_ops
125#[derive(Clone, Copy)]
126pub struct OrdinalSpace<'a> {
127 /// The distinct oids, fixed-width, sorted, back to back. `oids_raw`.
128 raw: &'a [u8],
129 /// Width of one oid: 20 (sha1) or 32 (sha256).
130 oid_len: usize,
131}
132
133impl<'a> OrdinalSpace<'a> {
134 /// Borrow a flat, **sorted**, fixed-width oid buffer as the ordinal space.
135 /// The sort is the caller's invariant (every builder produces it in
136 /// oid-lexicographic order); the binary search below rests on it.
137 pub fn new(raw: &'a [u8], oid_len: usize) -> Self {
138 debug_assert!(oid_len > 0 && raw.len() % oid_len == 0, "ragged ordinal space");
139 Self { raw, oid_len }
140 }
141
142 /// How many oids the space holds.
143 pub fn len(&self) -> usize {
144 if self.oid_len == 0 { 0 } else { self.raw.len() / self.oid_len }
145 }
146
147 pub fn is_empty(&self) -> bool {
148 self.raw.is_empty()
149 }
150
151 /// ordinal → raw oid, a slice into the flat buffer. `None` for an ordinal
152 /// outside the space.
153 pub fn oid(&self, ordinal: u32) -> Option<&'a [u8]> {
154 let at = (ordinal as usize).checked_mul(self.oid_len)?;
155 self.raw.get(at..at + self.oid_len)
156 }
157
158 /// raw oid → ordinal, by binary search over the sorted flat buffer.
159 ///
160 /// This is the exact inverse of [`Self::oid`]: the buffer is sorted, so the
161 /// index a search lands on IS the ordinal, and it equals what the old
162 /// `HashMap<String, u32>` returned for the same oid — that map keyed the
163 /// same sorted list by its own hex spelling.
164 pub fn ordinal_of_raw(&self, oid: &[u8]) -> Option<u32> {
165 if oid.len() != self.oid_len {
166 return None;
167 }
168 let (mut lo, mut hi) = (0usize, self.len());
169 while lo < hi {
170 let mid = lo + (hi - lo) / 2;
171 let at = mid * self.oid_len;
172 match self.raw[at..at + self.oid_len].cmp(oid) {
173 std::cmp::Ordering::Less => lo = mid + 1,
174 std::cmp::Ordering::Greater => hi = mid,
175 std::cmp::Ordering::Equal => return Some(mid as u32),
176 }
177 }
178 None
179 }
180
181 /// hex oid → ordinal. Decodes the hex into a **stack** buffer (≤ 32 bytes),
182 /// so a lookup allocates nothing, then binary-searches the raw form. A hex
183 /// string that is the wrong length or not hex is simply absent.
184 pub fn ordinal_of_hex(&self, hex: &str) -> Option<u32> {
185 let mut buf = [0u8; 32];
186 let dst = buf.get_mut(..self.oid_len)?;
187 hex::decode_to_slice(hex, dst).ok()?;
188 self.ordinal_of_raw(dst)
189 }
190}
191
192/// **Carry a reachability table across a fold** — the same bitmaps, renumbered
193/// into the new ordinal space, for the commits the new graph still holds.
194///
195/// A bitmap is a fact about an immutable commit: the set of objects reachable
196/// from it never changes while the commit exists. What a fold changes is the
197/// NUMBERING — the ordinal space is the sorted oid buffer, and every oid a push
198/// adds shifts the ordinals above it — so an entry survives a fold exactly by
199/// mapping each set bit through `old ordinal → new ordinal`. That map is
200/// monotone (both spaces are sorted, and the new one is a superset), so it is
201/// one merge pass over the two buffers and one in-order pass over each bitmap.
202///
203/// Returns `None` — *rebuild from scratch* — when the carry cannot be exact:
204/// an old oid that the new space lacks (an object was removed under the table,
205/// which a retain/gc does) or an entry whose commit `keep` no longer knows.
206/// An entry for a commit that is gone is dropped, not carried; a BIT that is
207/// gone refuses the whole carry, because an entry claiming an object the store
208/// no longer holds would be a silent over-claim on the next negotiation.
209///
210/// Commits added since the carried build have no entry; [`accumulate`] walks
211/// a bitmapless commit down to the nearest carried ancestor, so a request
212/// stays exact and pays only for the new commits' trees. The caller bounds how
213/// far that is allowed to grow (`git_ops::REACH_CARRY_MAX_FRACTION`).
214pub fn carry_reach(
215 old: &[ReachEntry],
216 old_space: &OrdinalSpace<'_>,
217 new_space: &OrdinalSpace<'_>,
218 keep: impl Fn(&str) -> bool,
219) -> Option<Vec<ReachEntry>> {
220 if old.is_empty() || old_space.is_empty() {
221 return None;
222 }
223 // old ordinal → new ordinal, by one merge over the two sorted buffers.
224 let mut remap: Vec<Option<u32>> = Vec::with_capacity(old_space.len());
225 let mut j = 0u32;
226 let new_len = new_space.len() as u32;
227 for o in 0..old_space.len() as u32 {
228 let oid = old_space.oid(o)?;
229 let mut hit = None;
230 while j < new_len {
231 let cand = new_space.oid(j)?;
232 match cand.cmp(oid) {
233 std::cmp::Ordering::Less => j += 1,
234 std::cmp::Ordering::Equal => {
235 hit = Some(j);
236 j += 1;
237 break;
238 }
239 std::cmp::Ordering::Greater => break,
240 }
241 }
242 remap.push(hit);
243 }
244 let mut out = Vec::with_capacity(old.len());
245 for e in old {
246 if !keep(&e.commit) {
247 continue;
248 }
249 let mut mapped = Vec::with_capacity(e.bitmap.len() as usize);
250 for b in e.bitmap.iter() {
251 match remap.get(b as usize).copied().flatten() {
252 Some(n) => mapped.push(n),
253 None => return None,
254 }
255 }
256 // Monotone map ⇒ `mapped` is ascending; the sorted constructor is the
257 // cheap one and asserts exactly that.
258 let bitmap = RoaringBitmap::from_sorted_iter(mapped).ok()?;
259 out.push(ReachEntry { commit: e.commit.clone(), bitmap });
260 }
261 Some(out)
262}
263
264#[cfg(test)]
265mod carry_tests {
266 use super::*;
267
268 fn space(oids: &[u8]) -> Vec<u8> {
269 // 1-byte "oids" are enough for the arithmetic; the space is sorted.
270 let mut v = oids.to_vec();
271 v.sort_unstable();
272 v
273 }
274
275 /// The carry renumbers every bit through the merge, drops an entry whose
276 /// commit is gone, and refuses when a set bit's object is gone — red in
277 /// every arm before the function existed, since a fold used to drop the
278 /// table outright.
279 #[test]
280 fn a_carry_renumbers_bits_drops_dead_commits_and_refuses_a_missing_object() {
281 let old_raw = space(&[10, 20, 30, 40]);
282 let old = OrdinalSpace::new(&old_raw, 1);
283 // Two oids inserted below 30 and one above 40: ordinals shift by 2 and 2.
284 let new_raw = space(&[10, 15, 20, 25, 30, 40, 50]);
285 let new = OrdinalSpace::new(&new_raw, 1);
286 let entries = vec![
287 ReachEntry { commit: "keep".into(), bitmap: [0u32, 2, 3].into_iter().collect() },
288 ReachEntry { commit: "gone".into(), bitmap: [1u32].into_iter().collect() },
289 ];
290 let carried = carry_reach(&entries, &old, &new, |c| c == "keep").expect("exact carry");
291 assert_eq!(carried.len(), 1, "the dead commit's entry is dropped, not carried");
292 assert_eq!(carried[0].commit, "keep");
293 let bits: Vec<u32> = carried[0].bitmap.iter().collect();
294 assert_eq!(bits, vec![0, 4, 5], "10→0, 30→4, 40→5 in the new numbering");
295
296 // An object removed from the space under a kept entry: refuse the carry.
297 let shrunk_raw = space(&[10, 20, 40]);
298 let shrunk = OrdinalSpace::new(&shrunk_raw, 1);
299 assert!(carry_reach(&entries, &old, &shrunk, |_| true).is_none(), "30 is gone; a bitmap naming it must not survive");
300 // Nothing to carry is not a carry.
301 assert!(carry_reach(&[], &old, &new, |_| true).is_none());
302 }
303}
304
305/// What the builder needs to know about every object in the archive.
306pub struct ObjectFacts<'a> {
307 /// oid ⇄ ordinal — the bitmap space, over the flat `oids_raw` buffer.
308 pub ordinal: OrdinalSpace<'a>,
309 /// Tree payloads, by oid hex. Blobs are not needed (they are leaves) and
310 /// are deliberately not asked for.
311 pub trees: &'a dyn TreeSource,
312 /// Raw oid width, needed to walk tree records.
313 pub oid_len: usize,
314}
315
316/// Build the reachability bitmaps.
317///
318/// `commits` must already carry generation numbers (see
319/// [`crate::graph::assign_generations`]) and be ordered parents-before-children,
320/// which that function guarantees.
321pub fn build_reach(
322 commits: &[CommitNode],
323 facts: &ObjectFacts<'_>,
324 policy: ReachPolicy,
325) -> Vec<ReachEntry> {
326 let n = commits.len();
327 if n == 0 || policy.max_commits == 0 {
328 return Vec::new();
329 }
330 let pos: HashMap<&str, usize> = commits
331 .iter()
332 .enumerate()
333 .map(|(i, c)| (c.oid.as_str(), i))
334 .collect();
335
336 let selected = select_commits(commits, &pos, policy);
337 let keep: Vec<bool> = {
338 let mut k = vec![false; n];
339 for &i in &selected {
340 k[i] = true;
341 }
342 k
343 };
344
345 // How many children still need each commit's bitmap. Once it hits zero and
346 // the commit is not selected, the bitmap is dropped — that is what keeps
347 // peak memory to the DAG's frontier rather than the whole history.
348 let mut pending: Vec<usize> = vec![0; n];
349 let mut parent_idx: Vec<Vec<usize>> = Vec::with_capacity(n);
350 for c in commits {
351 let mut ps: Vec<usize> = c.parents.iter().filter_map(|p| pos.get(p.as_str()).copied()).collect();
352 ps.sort_unstable();
353 ps.dedup();
354 for &p in &ps {
355 pending[p] += 1;
356 }
357 parent_idx.push(ps);
358 }
359
360 let mut tree_memo: HashMap<String, RoaringBitmap> = HashMap::new();
361 let mut live: HashMap<usize, RoaringBitmap> = HashMap::new();
362
363 for i in 0..n {
364 let c = &commits[i];
365 let mut bm = RoaringBitmap::new();
366 if let Some(o) = facts.ordinal.ordinal_of_hex(c.oid.as_str()) {
367 bm.insert(o);
368 }
369 if let Some(t) = &c.tree {
370 // Borrowed out of the memo, not cloned out of it. See
371 // [`tree_closure`]'s own note: this is once per commit and the thing
372 // being copied is a whole tree closure, which on a repository-shaped
373 // history is most of the object graph.
374 if let Some(tc) = tree_closure(t, facts, &mut tree_memo) {
375 bm |= tc;
376 }
377 }
378 for &p in &parent_idx[i] {
379 if let Some(pb) = live.get(&p) {
380 bm |= pb;
381 }
382 pending[p] -= 1;
383 if pending[p] == 0 && !keep[p] {
384 live.remove(&p);
385 }
386 }
387 // **Nothing is cloned here.** A kept commit's bitmap is inserted into
388 // `live` like any other and harvested below; the `out.push(ReachEntry {
389 // bitmap: bm.clone() })` that used to stand here made a second, deep
390 // copy of every selected commit's roaring bitmap while `live` held the
391 // first — and on the live selection path `keep` is *every* commit, so
392 // that was one full duplicate of the entire index. MEASURED on oden
393 // 2026-08-14, a 1 581-commit / 12 455-object store: the whole build was
394 // 530 218 allocations.
395 //
396 // Safe by the retention rule directly above: an entry is dropped from
397 // `live` only when `pending[p] == 0 && !keep[p]`, so a kept one is never
398 // dropped and is still there when the harvest runs.
399 if pending[i] > 0 || keep[i] {
400 live.insert(i, bm);
401 }
402 }
403
404 // The harvest: **move** each selected commit's bitmap out of `live`.
405 //
406 // `selected` is sorted ascending (`select_commits` sorts and dedups), and
407 // the loop above pushed in `0..n` order filtered by `keep`, so this produces
408 // the identical order the clone-per-iteration version did. That is not a
409 // detail to leave implicit — `decode_reach`/`build_reach_batch` round-trip
410 // this vector positionally.
411 let mut out: Vec<ReachEntry> = Vec::with_capacity(selected.len());
412 for &i in &selected {
413 // `expect` rather than `unwrap_or_default`: a missing entry here means
414 // the retention rule above let a kept commit be dropped, and answering
415 // with an EMPTY bitmap would be a silent under-send — a clone served
416 // one object per branch, exiting zero. Panicking names the commit.
417 let bitmap = live.remove(&i).unwrap_or_else(|| {
418 panic!(
419 "commit {} was selected for a reachability bitmap and its bitmap is not live at \
420 the end of the build; an empty one here would under-send a clone",
421 commits[i].oid
422 )
423 });
424 out.push(ReachEntry {
425 commit: commits[i].oid.clone(),
426 bitmap,
427 });
428 }
429 out
430}
431
432/// Tips first, then an even sample through the rest, capped at `max_commits`.
433fn select_commits(
434 commits: &[CommitNode],
435 pos: &HashMap<&str, usize>,
436 policy: ReachPolicy,
437) -> Vec<usize> {
438 let n = commits.len();
439 let mut has_child = vec![false; n];
440 for c in commits {
441 for p in &c.parents {
442 if let Some(&pi) = pos.get(p.as_str()) {
443 has_child[pi] = true;
444 }
445 }
446 }
447 let mut chosen: Vec<usize> = (0..n).filter(|&i| !has_child[i]).collect();
448 chosen.truncate(policy.max_commits);
449
450 if chosen.len() < policy.max_commits {
451 let room = policy.max_commits - chosen.len();
452 let rest: Vec<usize> = (0..n).filter(|&i| has_child[i]).collect();
453 if !rest.is_empty() {
454 let stride = rest.len().div_ceil(room).max(1);
455 for &i in rest.iter().step_by(stride).take(room) {
456 chosen.push(i);
457 }
458 }
459 }
460 chosen.sort_unstable();
461 chosen.dedup();
462 chosen
463}
464
465/// Every object reachable from a tree, memoized per tree oid.
466///
467/// Iterative (an explicit post-order stack), not recursive: a deep source tree
468/// would otherwise be a stack-depth bet.
469///
470/// # It **borrows** out of the memo rather than cloning out of it
471///
472/// It returned `RoaringBitmap` by value until 2026-08-14, which meant a deep
473/// copy of a whole tree closure on every call — including, and especially, the
474/// memo *hit* on the first line, which is the common case: consecutive commits
475/// in a history overwhelmingly share a root tree or find one already computed.
476/// The thing being copied is not small. On a repository-shaped history a root
477/// tree's closure is most of the object graph, so this was one full copy of it
478/// per commit, 1 581 times on the store this was measured against.
479///
480/// `None` means the memo has no entry for `root` even after the walk, which the
481/// walk only produces for an oid this archive does not hold. The caller ORs
482/// nothing in, which is what `unwrap_or_default()` did and is the same answer —
483/// it is `Option` rather than an empty bitmap so that "absent" and "reaches
484/// nothing" stay different words at the call site.
485pub(crate) fn tree_closure<'m>(
486 root: &str,
487 facts: &ObjectFacts<'_>,
488 memo: &'m mut HashMap<String, RoaringBitmap>,
489) -> Option<&'m RoaringBitmap> {
490 if memo.contains_key(root) {
491 // Deliberately re-looked-up at the bottom rather than returned from
492 // here: NLL cannot see that the early borrow ends on this path, and the
493 // alternative is a second `HashMap` probe on the hit path against a deep
494 // copy of the whole closure. The probe wins by orders of magnitude.
495 return memo.get(root);
496 }
497 // (oid, children_already_expanded)
498 let mut stack: Vec<(String, bool)> = vec![(root.to_string(), false)];
499 // Trees currently on the stack. A tree graph cannot contain a cycle, but a
500 // *corrupt* one can, and "cannot happen" is not a termination argument for
501 // bytes that arrived over the wire.
502 let mut visiting: std::collections::HashSet<String> = std::collections::HashSet::new();
503
504 while let Some((oid, expanded)) = stack.pop() {
505 if memo.contains_key(&oid) {
506 visiting.remove(&oid);
507 continue;
508 }
509 let Some(payload) = facts.trees.tree(&oid) else {
510 // Not a tree we hold (a blob, or an object outside the archive).
511 let mut bm = RoaringBitmap::new();
512 if let Some(o) = facts.ordinal.ordinal_of_hex(oid.as_str()) {
513 bm.insert(o);
514 }
515 memo.insert(oid, bm);
516 continue;
517 };
518 let children: Vec<String> = tree_entries(&payload, facts.oid_len)
519 .iter()
520 .map(|e| hex::encode(e.oid))
521 .collect();
522 if !expanded {
523 visiting.insert(oid.clone());
524 // A child that is already `visiting` is a back-edge: do not push it
525 // again, or the stack never drains.
526 let unresolved: Vec<String> = children
527 .iter()
528 .filter(|c| !memo.contains_key(*c) && !visiting.contains(*c))
529 .cloned()
530 .collect();
531 if !unresolved.is_empty() {
532 stack.push((oid, true));
533 for c in unresolved {
534 stack.push((c, false));
535 }
536 continue;
537 }
538 }
539 let mut bm = RoaringBitmap::new();
540 if let Some(o) = facts.ordinal.ordinal_of_hex(oid.as_str()) {
541 bm.insert(o);
542 }
543 for c in &children {
544 if let Some(cb) = memo.get(c) {
545 bm |= cb;
546 } else if let Some(o) = facts.ordinal.ordinal_of_hex(c.as_str()) {
547 // Back-edge or an object outside the archive: take the object
548 // itself and stop rather than loop forever.
549 bm.insert(o);
550 }
551 }
552 memo.insert(oid.clone(), bm);
553 visiting.remove(&oid);
554 }
555 memo.get(root)
556}
557
558/// **The bounded walk: OR into `acc` everything reachable from `tip`, stopping
559/// at every commit that already has a bitmap.**
560///
561/// # Why this function is what makes a sampled bitmap table legal
562///
563/// Until 2026-08-14 this file had no walk at all, and the consequence ran all
564/// the way up the stack. [`crate::git_ops::GitStore::reachable_oids`] answered a
565/// `want` with no bitmap by contributing *the object itself and nothing else* —
566/// a silent under-send, a clone that exits zero having served one object per
567/// branch. `crate::serve`'s `select` therefore had to refuse any request whose
568/// tips were not all bitmapped, and the only way to make that refusal never fire
569/// was to bitmap **every** commit: `ReachPolicy { max_commits: usize::MAX }`, on
570/// the request thread, holding the derived write lock, after every push (because
571/// `refold` clears the table). MEASURED: 530 218 of a fetch's 575 349
572/// allocations were that build, for a request that sent four objects.
573///
574/// With a walk, a missing bitmap is a *bounded amount of work* instead of a
575/// wrong answer, and the table can be sampled like the sealed archive's already
576/// is.
577///
578/// # Why it is bounded, stated as the property it rests on
579///
580/// **A stored bitmap is closed under reachability.** So reaching one accounts
581/// for the entire history behind it, and the walk stops there rather than
582/// descending. What it covers is therefore only what has been pushed since the
583/// last time the table was built — not the repository — whatever the repository's
584/// size. This is git's `add_to_include_set` rule, arrived at independently here
585/// over a different object store: nothing of gitoxide's or of git's is copied,
586/// and this crate links neither (LAW 5 applies to code, and these are different
587/// codebases with different indexes).
588///
589/// # The two phases are an ordering, not a style
590///
591/// The commit walk runs to **completion** before a single tree is opened. Git
592/// applies its skip rule one level down as well — `should_include_obj`, do not
593/// descend into a tree whose bit is already set — and that rule is worth nothing
594/// unless every stored bitmap this request will ever OR in is already in `acc`
595/// when the tree walk starts. Get the order wrong and the first unbitmapped
596/// commit costs a walk of every tree and blob in the repository, and the bounded
597/// commit walk buys exactly nothing.
598/// **The two per-fold lookup tables the reach walk keys into, paired as one
599/// view.** `bitmap` is the stored closure of a commit (the stopping rule);
600/// `node` is a commit's graph row (its parents and tree). Both were rebuilt as
601/// `HashMap<&str, _>` on every negotiation — O(commits) per fetch, for tables
602/// that cannot change between folds — and are now folded once and borrowed
603/// through this view. Both are keyed by **hex** oid, the form the walk carries
604/// (a [`CommitNode`]'s parents and tree are hex), so no lookup here allocates.
605///
606/// The index maps are `oid → row`; the view resolves that to a borrow of the
607/// row so the caller never indexes a table by hand and never pairs an index
608/// with the wrong table — the pair is built and handed out together by
609/// [`crate::git_ops`]'s `reach_tables_with`.
610#[derive(Clone, Copy)]
611pub(crate) struct ReachView<'a> {
612 pub reach: &'a [ReachEntry],
613 pub reach_index: &'a HashMap<String, u32>,
614 pub graph: &'a [CommitNode],
615 pub graph_index: &'a HashMap<String, u32>,
616}
617
618impl<'a> ReachView<'a> {
619 /// The stored bitmap of a commit, or `None` if it has none.
620 #[inline]
621 pub(crate) fn bitmap(&self, oid: &str) -> Option<&'a RoaringBitmap> {
622 self.reach_index
623 .get(oid)
624 .map(|&i| &self.reach[i as usize].bitmap)
625 }
626 /// A commit's graph row, or `None` if this archive holds no such commit.
627 #[inline]
628 pub(crate) fn node(&self, oid: &str) -> Option<&'a CommitNode> {
629 self.graph_index.get(oid).map(|&i| &self.graph[i as usize])
630 }
631 /// Whether the graph holds `oid` as a commit — the `want`/`have` test the
632 /// negotiation makes before it decides to walk.
633 #[inline]
634 pub(crate) fn is_commit(&self, oid: &str) -> bool {
635 self.graph_index.contains_key(oid)
636 }
637}
638
639pub(crate) fn accumulate(
640 tip: &str,
641 view: &ReachView<'_>,
642 facts: &ObjectFacts<'_>,
643 acc: &mut RoaringBitmap,
644) {
645 // ── phase 1: the commit walk, to completion ────────────────────────────
646 //
647 // Collect the commits that have no bitmap, ORing in every stored bitmap the
648 // frontier touches. Nothing here opens a tree.
649 let mut unbitmapped: Vec<&str> = Vec::new();
650 let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
651 let mut stack: Vec<&str> = vec![tip];
652 while let Some(oid) = stack.pop() {
653 if !seen.insert(oid) {
654 continue;
655 }
656 // The stopping rule. A stored bitmap is closed, so everything behind
657 // this commit is already accounted for and its parents are not walked.
658 if let Some(bm) = view.bitmap(oid) {
659 *acc |= bm;
660 continue;
661 }
662 let Some(node) = view.node(oid) else {
663 // Not a commit this archive holds as a graph row. It contributes
664 // itself if the index knows it, and nothing else — there is no
665 // history here to walk. A `want` that reaches this and is genuinely
666 // absent is refused by the caller, which is the one place that can
667 // tell "absent" from "not a commit".
668 if let Some(o) = facts.ordinal.ordinal_of_hex(oid) {
669 acc.insert(o);
670 }
671 continue;
672 };
673 unbitmapped.push(oid);
674 for p in &node.parents {
675 stack.push(p.as_str());
676 }
677 }
678
679 // ── phase 2: the trees, with the skip rule ─────────────────────────────
680 for oid in unbitmapped {
681 if let Some(o) = facts.ordinal.ordinal_of_hex(oid) {
682 acc.insert(o);
683 }
684 let Some(node) = view.node(oid) else { continue };
685 if let Some(tree) = &node.tree {
686 accumulate_tree(tree.as_str(), facts, acc);
687 }
688 }
689}
690
691/// [`accumulate`]'s second phase for one root tree: every object under it that
692/// is not already in `acc`.
693///
694/// The skip is git's `should_include_obj` and it is what keeps phase 2 bounded:
695/// a set bit means that object — and, for a tree, everything under it — is
696/// already accounted for, either by a stored bitmap ORed in during phase 1 or by
697/// an earlier commit in this same walk. Descending anyway would be correct and
698/// would also make the whole exercise pointless.
699///
700/// Iterative for the reason [`tree_closure`] is: a deep source tree is not a
701/// stack-depth bet. It needs no `visiting` set of its own — a corrupt cycle
702/// terminates here because a revisited oid's bit is already set.
703pub(crate) fn accumulate_tree(root: &str, facts: &ObjectFacts<'_>, acc: &mut RoaringBitmap) {
704 let mut stack: Vec<String> = vec![root.to_string()];
705 while let Some(oid) = stack.pop() {
706 let Some(o) = facts.ordinal.ordinal_of_hex(oid.as_str()) else {
707 // An object this archive does not hold. A thin push's base, or a
708 // submodule gitlink, which is a commit oid that lives in another
709 // repository entirely and must never be selected for emission.
710 continue;
711 };
712 if !acc.insert(o) {
713 // Already set: this object, and everything under it, is accounted
714 // for. `RoaringBitmap::insert` returns false when the bit was
715 // already present, so the test and the set are one operation.
716 continue;
717 }
718 // Only trees carry pointers. A blob is a leaf and `facts.trees`
719 // deliberately does not hold one.
720 if let Some(payload) = facts.trees.tree(oid.as_str()) {
721 for e in tree_entries(&payload, facts.oid_len) {
722 stack.push(hex::encode(e.oid));
723 }
724 }
725 }
726}
727
728/// Which objects a repack must hold to make the reachability pass exact.
729///
730/// Only commits and trees carry pointers; blobs are leaves. The builder
731/// therefore needs tree payloads and nothing else, which is why
732/// [`ObjectFacts::trees`] exists and there is no `blobs` field.
733pub fn needs_payload(kind: GitObjectKind) -> bool {
734 matches!(kind, GitObjectKind::Commit | GitObjectKind::Tree)
735}
736
737pub fn build_reach_batch(entries: &[ReachEntry]) -> Result<RecordBatch> {
738 use znippy_common::arrow::array::UInt64Builder;
739 let n = entries.len();
740 let mut oid_b = StringBuilder::with_capacity(n, n * 64);
741 let mut card_b = UInt64Builder::with_capacity(n);
742 let mut bm_b = BinaryBuilder::with_capacity(n, n * 128);
743 for e in entries {
744 oid_b.append_value(&e.commit);
745 card_b.append_value(e.bitmap.len());
746 let mut buf = Vec::new();
747 e.bitmap
748 .serialize_into(&mut buf)
749 .map_err(|err| anyhow!("reach bitmap serialize: {err}"))?;
750 bm_b.append_value(&buf);
751 }
752 RecordBatch::try_new(
753 reach_schema(),
754 vec![Arc::new(oid_b.finish()), Arc::new(card_b.finish()), Arc::new(bm_b.finish())],
755 )
756 .map_err(|e| anyhow!("reach batch: {e}"))
757}
758
759pub fn decode_reach(bytes: &[u8]) -> Result<Vec<ReachEntry>> {
760 // Through the IPC guard: the section comes out of an archive.
761 let reader = znippy_common::ipc_guard::stream_reader(bytes)
762 .map_err(|e| anyhow!("reach reader: {e}"))?;
763 let mut out = Vec::new();
764 for batch in reader {
765 let batch = batch.map_err(|e| anyhow!("reach batch read: {e}"))?;
766 let oids = batch
767 .column_by_name("commit_oid")
768 .and_then(|c| c.as_any().downcast_ref::<StringArray>())
769 .ok_or_else(|| anyhow!("reach: no `commit_oid` column"))?;
770 let bms = batch
771 .column_by_name("bitmap")
772 .and_then(|c| c.as_any().downcast_ref::<BinaryArray>())
773 .ok_or_else(|| anyhow!("reach: no `bitmap` column"))?;
774 for i in 0..batch.num_rows() {
775 let bitmap = RoaringBitmap::deserialize_from(bms.value(i))
776 .map_err(|e| anyhow!("reach bitmap deserialize: {e}"))?;
777 out.push(ReachEntry { commit: oids.value(i).to_string(), bitmap });
778 }
779 }
780 Ok(out)
781}
782
783/// Read the reachability bitmaps out of a sealed archive. `Ok(None)` when absent.
784pub fn read_reach(archive: &Path) -> Result<Option<Vec<ReachEntry>>> {
785 match read_reserved_section_bytes(archive, GUNNAR_REACH_MODULE)? {
786 Some(b) => Ok(Some(decode_reach(&b)?)),
787 None => Ok(None),
788 }
789}
790
791#[cfg(test)]
792mod tests {
793 use super::*;
794 use crate::graph::assign_generations;
795
796 fn hexid(c: char) -> String {
797 std::iter::repeat_n(c, 40).collect()
798 }
799
800 /// Build a tiny repo: two commits, the second adding a file.
801 /// A flat, sorted, fixed-width ordinal buffer from oids **already in
802 /// ordinal order** — the shape [`OrdinalSpace`] borrows. The old tests
803 /// hand-built a `HashMap<String, u32>` with the same ordinals; because the
804 /// ordinal IS the oid-lexicographic rank, that map and this buffer are two
805 /// spellings of one list.
806 fn ordinal_raw(oids_in_order: &[&str]) -> Vec<u8> {
807 let mut v = Vec::new();
808 for o in oids_in_order {
809 v.extend_from_slice(&hex::decode(o).unwrap());
810 }
811 v
812 }
813
814 /// Returns (commits, facts-owned-data).
815 #[allow(clippy::type_complexity)]
816 fn tiny_repo() -> (Vec<CommitNode>, Vec<u8>, HashMap<String, Vec<u8>>) {
817 let blob1 = hexid('1');
818 let blob2 = hexid('2');
819 let tree1 = hexid('3');
820 let tree2 = hexid('4');
821 let c1 = hexid('5');
822 let c2 = hexid('6');
823
824 let mut trees: HashMap<String, Vec<u8>> = HashMap::new();
825 let mut t1 = Vec::new();
826 t1.extend_from_slice(b"100644 a\0");
827 t1.extend_from_slice(&hex::decode(&blob1).unwrap());
828 trees.insert(tree1.clone(), t1);
829
830 let mut t2 = Vec::new();
831 t2.extend_from_slice(b"100644 a\0");
832 t2.extend_from_slice(&hex::decode(&blob1).unwrap());
833 t2.extend_from_slice(b"100644 b\0");
834 t2.extend_from_slice(&hex::decode(&blob2).unwrap());
835 trees.insert(tree2.clone(), t2);
836
837 // Ordinal = oid-lexicographic rank: hexid('1') < … < hexid('6'), so the
838 // sorted raw buffer is simply these six in this order.
839 let ordinal = ordinal_raw(&[&blob1, &blob2, &tree1, &tree2, &c1, &c2]);
840
841 let commits = assign_generations(vec![
842 CommitNode {
843 oid: c1.clone(),
844 parents: vec![],
845 tree: Some(hexid('3')),
846 committer_time: Some(1),
847 generation: 0,
848 },
849 CommitNode {
850 oid: c2.clone(),
851 parents: vec![c1.clone()],
852 tree: Some(hexid('4')),
853 committer_time: Some(2),
854 generation: 0,
855 },
856 ]);
857 (commits, ordinal, trees)
858 }
859
860 #[test]
861 fn bitmap_contains_exactly_the_reachable_objects() {
862 let (commits, ordinal, trees) = tiny_repo();
863 let facts = ObjectFacts { ordinal: OrdinalSpace::new(&ordinal, 20), trees: &trees, oid_len: 20 };
864 let entries = build_reach(&commits, &facts, ReachPolicy { max_commits: 16 });
865
866 let by_commit: HashMap<&str, &RoaringBitmap> =
867 entries.iter().map(|e| (e.commit.as_str(), &e.bitmap)).collect();
868
869 // c1 reaches: blob1(0), tree1(2), c1(4).
870 let b1: Vec<u32> = by_commit[hexid('5').as_str()].iter().collect();
871 assert_eq!(b1, vec![0, 2, 4], "c1 must not reach blob2/tree2/c2");
872
873 // c2 reaches everything.
874 let b2: Vec<u32> = by_commit[hexid('6').as_str()].iter().collect();
875 assert_eq!(b2, vec![0, 1, 2, 3, 4, 5]);
876 }
877
878 #[test]
879 fn want_minus_have_is_an_andnot() {
880 let (commits, ordinal, trees) = tiny_repo();
881 let facts = ObjectFacts { ordinal: OrdinalSpace::new(&ordinal, 20), trees: &trees, oid_len: 20 };
882 let entries = build_reach(&commits, &facts, ReachPolicy { max_commits: 16 });
883 let by: HashMap<&str, &RoaringBitmap> =
884 entries.iter().map(|e| (e.commit.as_str(), &e.bitmap)).collect();
885
886 let want = by[hexid('6').as_str()].clone();
887 let have = by[hexid('5').as_str()].clone();
888 let delta: Vec<u32> = (want - have).iter().collect();
889 // Exactly the objects the second commit introduced: blob2(1), tree2(3), c2(5).
890 assert_eq!(delta, vec![1, 3, 5]);
891 }
892
893 #[test]
894 fn selection_takes_tips_first_and_respects_the_cap() {
895 let (commits, ordinal, trees) = tiny_repo();
896 let facts = ObjectFacts { ordinal: OrdinalSpace::new(&ordinal, 20), trees: &trees, oid_len: 20 };
897 let entries = build_reach(&commits, &facts, ReachPolicy { max_commits: 1 });
898 assert_eq!(entries.len(), 1);
899 assert_eq!(entries[0].commit, hexid('6'), "the tip, not the root");
900 }
901
902 #[test]
903 fn batch_roundtrips_through_arrow_ipc() {
904 let (commits, ordinal, trees) = tiny_repo();
905 let facts = ObjectFacts { ordinal: OrdinalSpace::new(&ordinal, 20), trees: &trees, oid_len: 20 };
906 let entries = build_reach(&commits, &facts, ReachPolicy::default());
907 let batch = build_reach_batch(&entries).unwrap();
908 let mut buf = Vec::new();
909 {
910 let mut w = znippy_common::arrow::ipc::writer::StreamWriter::try_new(
911 &mut buf,
912 &reach_schema(),
913 )
914 .unwrap();
915 w.write(&batch).unwrap();
916 w.finish().unwrap();
917 }
918 let back = decode_reach(&buf).unwrap();
919 assert_eq!(back, entries);
920 }
921
922 #[test]
923 fn a_corrupt_tree_cycle_terminates() {
924 // t -> t (self-referential): must not hang or blow the stack.
925 let t = hexid('a');
926 let mut payload = Vec::new();
927 payload.extend_from_slice(b"40000 self\0");
928 payload.extend_from_slice(&hex::decode(&t).unwrap());
929 let trees: HashMap<String, Vec<u8>> = [(t.clone(), payload)].into_iter().collect();
930 let ordinal = ordinal_raw(&[&t, &hexid('b')]);
931 let facts = ObjectFacts { ordinal: OrdinalSpace::new(&ordinal, 20), trees: &trees, oid_len: 20 };
932 let commits = assign_generations(vec![CommitNode {
933 oid: hexid('b'),
934 parents: vec![],
935 tree: Some(t),
936 committer_time: None,
937 generation: 0,
938 }]);
939 let entries = build_reach(&commits, &facts, ReachPolicy::default());
940 assert_eq!(entries.len(), 1);
941 let bits: Vec<u32> = entries[0].bitmap.iter().collect();
942 assert_eq!(bits, vec![0, 1]);
943 }
944
945 /// **The binary search is the exact inverse of the slice, and it is the same
946 /// answer a `HashMap<String, u32>` over the sorted list would give.**
947 ///
948 /// Over a sorted raw space this asserts, at every ordinal: `oid(o)` is the
949 /// slice, `ordinal_of_raw`/`ordinal_of_hex` round-trip back to `o`, and an
950 /// oid the space does not hold is `None` both ways (the search must not land
951 /// on a neighbour). Seen RED by `Equal => Some(mid as u32 + 1)` in
952 /// `ordinal_of_raw`: every round trip below then names the next ordinal.
953 #[test]
954 fn ordinal_space_is_the_inverse_of_the_slice() {
955 // 32 distinct sha1-width oids, deliberately built OUT of order so the
956 // sort is doing real work, then sorted into the flat space.
957 let mut oids: Vec<[u8; 20]> = (0..32u8)
958 .map(|i| {
959 let mut o = [0u8; 20];
960 o[0] = i.wrapping_mul(37);
961 o[19] = i;
962 o
963 })
964 .collect();
965 oids.sort_unstable();
966 oids.dedup();
967 let mut raw = Vec::new();
968 for o in &oids {
969 raw.extend_from_slice(o);
970 }
971 let space = OrdinalSpace::new(&raw, 20);
972 assert_eq!(space.len(), oids.len());
973 for (o, oid) in oids.iter().enumerate() {
974 assert_eq!(space.oid(o as u32), Some(oid.as_slice()));
975 assert_eq!(space.ordinal_of_raw(oid), Some(o as u32));
976 assert_eq!(space.ordinal_of_hex(&hex::encode(oid)), Some(o as u32));
977 }
978 // Absent, wrong width, and out of range are all None — not a neighbour.
979 assert_eq!(space.ordinal_of_raw(&[0xffu8; 20]), None);
980 assert_eq!(space.ordinal_of_raw(&[0u8; 19]), None);
981 assert_eq!(space.oid(oids.len() as u32), None);
982 assert_eq!(space.ordinal_of_hex("not-hex"), None);
983 }
984
985 /// **The single representation owns nothing.** `OrdinalSpace` is a `Copy`
986 /// view of `&[u8] + usize` — a slice reference and a length — so building one
987 /// at open allocates zero bytes, which is the whole point of removing the hex
988 /// `Vec<String>` and the `HashMap<String, u32>`. Red if either an owning
989 /// field creeps back in (the size grows) or `Copy` is lost.
990 #[test]
991 fn ordinal_space_is_a_borrowed_view() {
992 assert_eq!(
993 std::mem::size_of::<OrdinalSpace<'_>>(),
994 std::mem::size_of::<&[u8]>() + std::mem::size_of::<usize>(),
995 "OrdinalSpace grew an owned field — it must stay a borrow of oids_raw"
996 );
997 fn assert_copy<T: Copy>() {}
998 assert_copy::<OrdinalSpace<'_>>();
999 }
1000}