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