Skip to main content

alkgit_substrate/
packgen.rs

1//! The substrate's `GitPackGen` implement ([`PackGen`]) — pack
2//! generation over the real Find front ([`ObjectFront`]'s per-session
3//! [`SessionFind`] views) and the negotiation ack seam's real predicate
4//! (the trait's `common_haves`, ADR-014 §5) — substrate.md §object
5//! storage "Packs"; ADR-004 is authoritative for the pipeline.
6//!
7//! # The pipeline (ADR-004, composed exactly)
8//!
9//! `run_pipeline` (the module's pipeline function) is the wave-2
10//! doubles' composition re-landed on the substrate Find (the POC-O
11//! `packgen.rs` reference shape):
12//!
13//! ```text
14//! peel wants to commit tips (tag chains walked; peeled tag objects kept)
15//!   boundary_haves peeled identically (the closure-subtraction sets)
16//!   → commit-ancestry walk (gix_traverse::commit::Simple, Parents::All,
17//!     non-commit tips as-is)
18//!   → count::objects_unthreaded (TreeContents) over the closure inputs
19//!   [+ AsIs over the peel-exposed tag chains]
20//!   → counted-object existence pre-check
21//!   → entry::iter_from_counts → InOrderIter
22//!   → bytes::FromEntriesIter (V2, sha1) → the sink
23//! ```
24//!
25//! **The two-stage closure is mandatory** (ADR-004: TreeContents does
26//! not follow commit parents; feeding tip commits alone produces
27//! incomplete packs — POC-2's course correction). Missing objects abort
28//! with [`StorageError::ObjectMissing`] naming the missing object
29//! (never a broken pack emitted downstream): every counted object is
30//! existence-checked before the byte pipeline opens, and the entry
31//! stage's own `missing_objects` statistics remain the abort posture.
32//!
33//! Generation is negotiation-agnostic: boundary sets in, pack out. The
34//! `limits` parameter is accepted and unused for v1 — the doubles' own
35//! posture; ADR-009 budgets the receive side, and ADR-004 pins no
36//! generation-side size budget (`max_pack_size` is
37//! [`GitPackIngest`](alkgit::GitPackIngest) territory).
38//!
39//! # Both serving shapes (substrate.md §object storage "Packs")
40//!
41//! The mode selection is the substrate impl's internal posture — the
42//! constructor level (the trade: a per-call selection would surface a
43//! mode parameter the frozen trait signature does not carry).
44//!
45//! - *Direct* ([`PackGen::direct`]): the generated pack streams through
46//!   the sink, no storage round-trip.
47//! - *Through-store* ([`PackGen::through_store`]): the pack is put once
48//!   into the store (the pack rides the canonical blob family —
49//!   `PACK_ALGORITHM` below — with the store's own tier dispatch
50//!   routing big packs to the large tier), then served through
51//!   `read_range` windows into the sink — every window's
52//!   out-of-band slice digest is re-derived and verified mid-flight
53//!   (the store does not verify a range read; the front-line impl
54//!   re-derives, the same posture [`ObjectFront`]'s loose reads carry).
55//!   A request-digest [`PackCache`] lets repeat fetches ride the stored
56//!   pack without regeneration; releases and evictions are pins the
57//!   next sweep reclaims ([`PackCache::release_all`] is the
58//!   `PackHousekeeping` consumer's release path).
59//!   *Large tier required for through-store serving* — a kv-only shape
60//!   rejects over-threshold packs, the rejection riding the store's own
61//!   put-time kv-only overflow (mapped to [`StorageError::Io`]).
62//!
63//! # Threading and the one bridge
64//!
65//! The ADR-004 pipeline runs on `tokio::task::spawn_blocking` with
66//! owned handles moved in (backend.md §concurrency model: the permit
67//! stays wire-side, the impl's threading invisible). Every sync→async
68//! hop inside the pipeline goes through [`ObjectFront::block_on`] — the
69//! odb-front task's one-place bridge rule ([`SessionFind`]'s sync legs
70//! already own theirs). The through-store put rides the async path
71//! directly (generate's own async context): the pipeline streams the
72//! pack through a bounded duplex pipe (`PACK_PIPE_CAPACITY`, the
73//! backpressure boundary) into `Store::put_as` — no unbounded buffer on
74//! the async path.
75//!
76//! # Failure posture on the pack put
77//!
78//! A generation that fails *after* the put began leaves the pack put's
79//! fate to the stream: an early pipeline end closes the pipe and the
80//! put commits what it received — a truncated or empty orphan pack that
81//! holds its pin only across the call. The error is returned and any
82//! put-returned pin is dropped, so the orphan is sweep-eligible: a
83//! failed fetch is a failed fetch, not partial repo state (the
84//! substrate's GC-decreed recovery shape). Nothing partial is ever
85//! *served*: the pack never reaches the sink on an error path, and a
86//! cached pack is inserted only once both the pipeline and the put
87//! succeeded. The put error wins over the pipeline's pipe-closed noise
88//! — the honest cause (a kv-only over-threshold rejection, a backing
89//! failure) is what the caller sees.
90
91use std::collections::{HashMap, HashSet, VecDeque};
92use std::io;
93use std::sync::atomic::AtomicBool;
94use std::sync::Arc;
95
96use alkblobs::{Algorithm, Key, Pin};
97use gix_hash::ObjectId;
98use gix_object::Kind;
99use gix_pack::data::output::bytes::FromEntriesIter;
100use gix_pack::data::output::count;
101use gix_pack::data::output::entry::iter_from_counts;
102use gix_pack::data::output::entry::iter_from_counts::{Mode, Options, Outcome};
103use gix_traverse::commit::{Parents, Simple};
104use sha2::Digest as _;
105
106use alkgit::{GitPackGen, Limits, RepoRecord, StorageError};
107use async_trait::async_trait;
108
109use crate::odb::{store_leg_error, ObjectFront, SessionFind};
110
111/// The derivation family the through-store pack rides: the canonical
112/// blob family's sha256 member (an ordinary canonical-key blob — the
113/// pack is cache, not a git object; git objects address through the
114/// git-object family alone, and the pack key is the store's own
115/// content derivation, never an oid).
116pub(crate) const PACK_ALGORITHM: Algorithm = Algorithm::GitBlobSha256;
117
118/// The bounded pipe between the blocking pipeline and the async pack
119/// put (`tokio::io::duplex`) — the through-store path's backpressure
120/// boundary (no unbounded buffer: a store slower than the pipeline
121/// throttles generation to the pipe width).
122const PACK_PIPE_CAPACITY: usize = 256 * 1024;
123
124/// The through-store serving window (`read_range` slice, 1 MiB — the
125/// POC-O datum). Each window's out-of-band SHA-256 slice digest is
126/// re-derived and compared mid-flight.
127const SERVE_WINDOW: u64 = 1024 * 1024;
128
129/// The default request-digest cache capacity (the POC-O datum: a small
130/// bound — the pack cache holds at most this many generated packs).
131pub(crate) const DEFAULT_CACHE_ENTRIES: usize = 4;
132
133/// The request digest of a fetch pack request: SHA-256 over the
134/// canonical (sorted, tagged) want and have lists — two requests with
135/// the same wants-and-recognized-haves set are the same request, so
136/// repeat fetches of the same shape ride the stored pack. POC-O's
137/// recipe verbatim.
138fn request_digest(wants: &[ObjectId], boundary_haves: &[ObjectId]) -> [u8; 32] {
139    let mut digest = sha2::Sha256::new();
140    let mut wants = wants.to_vec();
141    wants.sort_unstable();
142    for want in wants {
143        digest.update(b"W");
144        digest.update(want.as_bytes());
145    }
146    let mut haves = boundary_haves.to_vec();
147    haves.sort_unstable();
148    for have in haves {
149        digest.update(b"H");
150        digest.update(have.as_bytes());
151    }
152    digest.finalize().into()
153}
154
155/// The peel result of one want or have: the tag objects walked through
156/// (the chain's closure members) and the terminal object's id.
157struct PeeledTip {
158    chain: Vec<ObjectId>,
159    terminal: ObjectId,
160    terminal_is_commit: bool,
161}
162
163/// Peels `tip` through its tag chain (ADR-004: tips must be peeled
164/// before the commit-ancestry walk), returning the chain and the
165/// terminal kind. Errors on the first missing object of the chain with
166/// [`StorageError::ObjectMissing`] — a tip that cannot be resolved is
167/// the abort, never a partial pack.
168fn peel_tip(find: &SessionFind, tip: ObjectId) -> Result<PeeledTip, StorageError> {
169    let mut chain = Vec::new();
170    let mut current = tip;
171    loop {
172        let mut buf = Vec::new();
173        let data = match gix_object::FindExt::find(find, &current, &mut buf) {
174            Ok(data) => data,
175            Err(error) => return Err(from_pipeline_error(find, error, tip)),
176        };
177        let decoded = data.decode();
178        let next = match decoded {
179            Ok(gix_object::ObjectRef::Tag(tag)) => {
180                chain.push(current);
181                tag.target()
182            }
183            Ok(gix_object::ObjectRef::Commit(_)) => {
184                return Ok(PeeledTip {
185                    chain,
186                    terminal: current,
187                    terminal_is_commit: true,
188                });
189            }
190            Ok(_) => {
191                return Ok(PeeledTip {
192                    chain,
193                    terminal: current,
194                    terminal_is_commit: false,
195                });
196            }
197            Err(error) => return Err(StorageError::Io(error.to_string())),
198        };
199        current = next;
200    }
201}
202
203/// The missing-object abort (ADR-004): the recorded miss names the
204/// missing object, falling back to the id the stage was working on.
205fn pipeline_missing(find: &SessionFind, fallback: ObjectId) -> StorageError {
206    StorageError::ObjectMissing {
207        oid: find.missed_object().unwrap_or(fallback),
208    }
209}
210
211/// Maps a pipeline error to the seam's error model: a not-found is the
212/// missing-object abort (ADR-004), anything else is a backing-store
213/// failure (ADR-018 §7).
214fn from_pipeline_error(
215    find: &SessionFind,
216    error: gix_error::Error,
217    fallback: ObjectId,
218) -> StorageError {
219    if error.is_not_found() {
220        pipeline_missing(find, fallback)
221    } else {
222        StorageError::Io(error.to_string())
223    }
224}
225
226/// The ADR-004 generation pipeline over one session's Find — the
227/// doubles' wave-2 composition re-landed on the substrate Find front.
228/// `W` is any blocking sink (the wire's sideband bridge on the direct
229/// shape; the pack-put pipe on the through-store one). The two-stage
230/// closure, the missing-object aborts and the entry-statistics check
231/// are ADR-004's pinned shape; O(counts) memory throughout.
232fn run_pipeline<W: io::Write>(
233    find: &SessionFind,
234    wants: Vec<ObjectId>,
235    boundary_haves: Vec<ObjectId>,
236    sink: &mut W,
237) -> Result<(), StorageError> {
238    let no_want = ObjectId::null(gix_hash::Kind::Sha1);
239    let first_want = wants.first().copied().unwrap_or(no_want);
240
241    let mut boundary_commits = HashSet::new();
242    let mut boundary_objects = HashSet::new();
243    for have in boundary_haves {
244        let peeled = peel_tip(find, have)?;
245        boundary_objects.extend(peeled.chain.iter().copied());
246        boundary_objects.insert(peeled.terminal);
247        if peeled.terminal_is_commit {
248            boundary_commits.insert(peeled.terminal);
249        }
250    }
251
252    let mut commit_tips = Vec::new();
253    let mut non_commit_tips = Vec::new();
254    let mut tag_chains = Vec::new();
255    for want in wants {
256        let peeled = peel_tip(find, want)?;
257        for chain_oid in peeled.chain {
258            if !boundary_objects.contains(&chain_oid) {
259                tag_chains.push(chain_oid);
260            }
261        }
262        if peeled.terminal_is_commit {
263            commit_tips.push(peeled.terminal);
264        } else if !boundary_objects.contains(&peeled.terminal) {
265            non_commit_tips.push(peeled.terminal);
266        }
267    }
268
269    let mut walk = Simple::filtered(commit_tips, find.clone(), |tip: &gix_hash::oid| {
270        !boundary_commits.contains(tip)
271    })
272    .parents(Parents::All);
273    let mut survivors = Vec::new();
274    for info in walk.by_ref() {
275        let info = info.map_err(|error| from_pipeline_error(find, error, first_want))?;
276        survivors.push(info.id);
277    }
278
279    let counter = gix_utils::progress::Discard;
280    let interrupt = AtomicBool::new(false);
281    let mut closure_inputs = survivors;
282    closure_inputs.extend(non_commit_tips);
283    let (mut counts, _count_outcome) = count::objects_unthreaded(
284        find,
285        &mut closure_inputs
286            .into_iter()
287            .map(Ok::<ObjectId, gix_error::Error>),
288        &counter,
289        &interrupt,
290        count::objects::ObjectExpansion::TreeContents,
291    )
292    .map_err(|error| from_pipeline_error(find, error, first_want))?;
293    let (tag_counts, _tag_outcome) = count::objects_unthreaded(
294        find,
295        &mut tag_chains.into_iter().map(Ok::<ObjectId, gix_error::Error>),
296        &counter,
297        &interrupt,
298        count::objects::ObjectExpansion::AsIs,
299    )
300    .map_err(|error| from_pipeline_error(find, error, first_want))?;
301    let mut counted: HashSet<ObjectId> = counts.iter().map(|count| count.id).collect();
302    for tag_count in tag_counts {
303        if counted.contains(&tag_count.id) {
304            continue;
305        }
306        counted.insert(tag_count.id);
307        counts.push(tag_count);
308    }
309
310    for count in &counts {
311        if !gix_pack::Find::contains(find, &count.id) {
312            return Err(StorageError::ObjectMissing { oid: count.id });
313        }
314    }
315
316    let counts_len = counts.len();
317    let mut entries = gix_parallel::InOrderIter::from(
318        iter_from_counts(
319            counts,
320            find.clone(),
321            Box::new(gix_utils::progress::Discard),
322            Options {
323                thread_limit: Some(1),
324                mode: Mode::PackCopyAndBaseObjects,
325                allow_thin_pack: false,
326                chunk_size: 10,
327                version: gix_pack::data::Version::V2,
328                compression: gix_zlib::Compression::DEFAULT,
329            },
330        )
331        .map_err(|error| from_pipeline_error(find, error, first_want))?,
332    );
333    let mut from_entries = FromEntriesIter::new(
334        entries.by_ref(),
335        sink,
336        u32::try_from(counts_len)
337            .map_err(|_| StorageError::Invalid("the pack's object count exceeds u32".to_owned()))?,
338        gix_pack::data::Version::V2,
339        gix_hash::Kind::Sha1,
340    );
341    for step in from_entries.by_ref() {
342        step.map_err(|error| from_pipeline_error(find, error, first_want))?;
343    }
344    if from_entries.digest().is_none() {
345        return Err(StorageError::Io(
346            "the pack trailer was never written".to_string(),
347        ));
348    }
349    let entry_statistics: Outcome = {
350        use gix_parallel::Finalize as _;
351        entries
352            .inner
353            .finalize()
354            .map_err(|error| from_pipeline_error(find, error, first_want))?
355    };
356    if entry_statistics.missing_objects > 0 {
357        let missing = find
358            .missed_object()
359            .unwrap_or_else(|| ObjectId::null(gix_hash::Kind::Sha1));
360        return Err(StorageError::ObjectMissing { oid: missing });
361    }
362    Ok(())
363}
364
365/// The blocking pipeline's through-store end: a sync writer over the
366/// pack-put pipe's write half. Every write bridges through
367/// [`ObjectFront::block_on`] — the one-place rule (this sink exists
368/// only inside the `spawn_blocking` pipeline task; in an async context
369/// the bridge is the POC-O panic shape and never taken).
370struct PipeSink {
371    stream: tokio::io::DuplexStream,
372    front: ObjectFront,
373}
374
375impl io::Write for PipeSink {
376    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
377        use tokio::io::AsyncWriteExt;
378        self.front
379            .block_on(AsyncWriteExt::write(&mut self.stream, buf))
380    }
381
382    fn flush(&mut self) -> io::Result<()> {
383        use tokio::io::AsyncWriteExt;
384        self.front.block_on(AsyncWriteExt::flush(&mut self.stream))
385    }
386}
387
388/// The pack cache's entry: the stored pack's canonical key, the pin
389/// holding it against the sweep for exactly the cache's custody span,
390/// and the byte length the windows serve against.
391struct CachedPack {
392    key: Key,
393    _pin: Pin,
394    len: u64,
395}
396
397/// The request-digest pack cache of the through-store shape: repeat
398/// fetches of the same (canonicalized) want/have set ride the stored
399/// pack without regeneration. Evictions (capacity bounded) and
400/// releases ([`PackCache::release_all`]) drop the entry's pins — the
401/// sweep reclaims the pack blobs the next time it runs (substrate.md
402/// §object storage: "releases/evictions are pins the next sweep
403/// reclaims").
404pub struct PackCache {
405    max_entries: usize,
406    entries: parking_lot::Mutex<HashMap<[u8; 32], CachedPack>>,
407    order: parking_lot::Mutex<VecDeque<[u8; 32]>>,
408}
409
410impl PackCache {
411    /// The cache holding at most `max_entries` generated packs (a `0`
412    /// clamps to 1 — a zero-capacity cache would evict on every insert
413    /// but must not loop). The store handle stays with the
414    /// [`PackGen`] front; the cache is the digest-to-pack mapping.
415    pub fn new(max_entries: usize) -> Self {
416        PackCache {
417            max_entries: max_entries.max(1),
418            entries: parking_lot::Mutex::new(HashMap::new()),
419            order: parking_lot::Mutex::new(VecDeque::new()),
420        }
421    }
422
423    /// The cached pack behind `digest`, if any: the pack's key (the
424    /// windows serve against it through the store) and its length.
425    fn lookup(&self, digest: [u8; 32]) -> Option<(Key, u64)> {
426        self.entries
427            .lock()
428            .get(&digest)
429            .map(|cached| (cached.key.clone(), cached.len))
430    }
431
432    /// Caches the just-generated pack. When the cache is at capacity
433    /// the oldest digest's entry (and the pin protecting it) drops
434    /// first — the eviction's pack becomes sweep-eligible.
435    fn insert(&self, digest: [u8; 32], key: Key, len: u64, pin: Pin) {
436        let mut entries = self.entries.lock();
437        let mut order = self.order.lock();
438        while entries.len() >= self.max_entries {
439            match order.pop_front() {
440                Some(evict) => {
441                    entries.remove(&evict);
442                }
443                None => break,
444            }
445        }
446        entries.insert(
447            digest,
448            CachedPack {
449                key,
450                _pin: pin,
451                len,
452            },
453        );
454        order.push_back(digest);
455    }
456
457    /// The cache's release path — the `PackHousekeeping` consumer's
458    /// action: drop every cached entry (their pins release) and return
459    /// the released count. The released packs are reclaimed by the next
460    /// sweep.
461    pub fn release_all(&self) -> usize {
462        let mut entries = self.entries.lock();
463        let mut order = self.order.lock();
464        order.clear();
465        let released = entries.len();
466        entries.clear();
467        released
468    }
469}
470
471/// The through-store shape's serving mode enum for [`PackGen`].
472enum Serving {
473    /// The generated pack streams through the sink; no storage
474    /// round-trip, no cache.
475    Direct,
476    /// The pack is put once, then served ranged from the store through
477    /// the request-digest cache — shared with the composition's
478    /// maintenance chain, whose `PackHousekeeping` job runs the
479    /// cache's release path (`Arc`: one cache, two holders).
480    ThroughStore(Arc<PackCache>),
481}
482
483/// The substrate's [`GitPackGen`] implement: ADR-004's pipeline over
484/// the object front's real Find, the ADR-014 §5 ack predicate, and one
485/// of the two serving shapes (constructor-level posture — through the
486/// frozen trait signature runs no mode parameter; the wire layer is
487/// unchanged either way).
488///
489/// The object pool is global, not per-repo-namespaced (the wave-7
490/// recorded interpretation): the `&RepoRecord` parameter is accepted
491/// and unused here — the Find front reads the git-object family
492/// directly, per-repo coverage is liveness-decided.
493pub struct PackGen {
494    front: ObjectFront,
495    serving: Serving,
496}
497
498impl PackGen {
499    /// The direct shape: the generated pack streams through the sink,
500    /// no storage round-trip.
501    pub fn direct(front: ObjectFront) -> Self {
502        PackGen {
503            front,
504            serving: Serving::Direct,
505        }
506    }
507
508    /// The through-store shape with the default request-digest cache
509    /// (4 entries).
510    ///
511    /// The fresh private cache is a deliberate per-piece probe surface
512    /// (the documented-`durable()` posture, review 006 R-6's second
513    /// instance): the cache's pins are reachable by no maintenance
514    /// chain, so its release path is eviction alone and served packs
515    /// go reclaim-only at sweep — an honest probe shape, not a
516    /// production composition. The production through-store serving
517    /// shape is the composition entry's ([`crate::compose`]'s
518    /// `PackServing::ThroughStore`, the cache shared with the chain's
519    /// housekeeping), and a hand-wired production embedder passes the
520    /// chain-held handle explicitly ([`PackGen::through_store_with`]).
521    pub fn through_store(front: ObjectFront) -> Self {
522        PackGen::through_store_with(front, Arc::new(PackCache::new(DEFAULT_CACHE_ENTRIES)))
523    }
524
525    /// The through-store shape with an explicit cache — shared
526    /// (`Arc`): the same handle the composition's maintenance chain
527    /// holds, whose `PackHousekeeping` job runs
528    /// [`PackCache::release_all`].
529    pub fn through_store_with(front: ObjectFront, cache: Arc<PackCache>) -> Self {
530        PackGen {
531            front,
532            serving: Serving::ThroughStore(cache),
533        }
534    }
535
536    /// One through-store generation: the pipeline streams the pack
537    /// through the bounded pipe into the store's put (tier dispatch
538    /// runs on the store's side), and the put-returned pin comes back
539    /// for the cache to hold. On any failure the pin drops (an orphan
540    /// pack is sweep-eligible) and the error surfaces.
541    async fn store_pack(
542        &self,
543        find: SessionFind,
544        wants: Vec<ObjectId>,
545        boundary_haves: Vec<ObjectId>,
546    ) -> Result<(Key, Pin, u64), StorageError> {
547        let store = Arc::clone(self.front.store());
548        let (pipe_write, pipe_read) = tokio::io::duplex(PACK_PIPE_CAPACITY);
549        let sink = PipeSink {
550            stream: pipe_write,
551            front: self.front.clone(),
552        };
553        let pipeline = tokio::task::spawn_blocking(move || {
554            let mut sink = sink;
555            run_pipeline(&find, wants, boundary_haves, &mut sink)
556        });
557        let put = store.put_as(PACK_ALGORITHM, None, pipe_read);
558        let put = put.await;
559        let piped = pipeline
560            .await
561            .map_err(|join| StorageError::Io(format!("the pack generation task failed: {join}")))?;
562        match (put, piped) {
563            (Ok((key, pin)), Ok(())) => {
564                let meta = store
565                    .stat(&key.as_bytes())
566                    .await
567                    .map_err(|e| store_leg_error("alkblobs stat", e))?
568                    .ok_or_else(|| {
569                        StorageError::Invalid(
570                            "the pack put returned a key the store no longer stats".to_owned(),
571                        )
572                    })?;
573                Ok((key, pin, meta.len))
574            }
575            (Err(put_error), _) => Err(store_leg_error("the pack put", put_error)),
576            (Ok((_key, pin)), Err(pipeline_error)) => {
577                drop(pin);
578                Err(pipeline_error)
579            }
580        }
581    }
582
583    /// Serves the stored pack's bytes through `read_range` windows into
584    /// the sink, every window's out-of-band slice digest re-derived and
585    /// verified mid-flight. One spawn_blocking task owns the sink; the
586    /// store reads bridge through [`ObjectFront::block_on`].
587    async fn serve_stored(
588        &self,
589        key: &Key,
590        len: u64,
591        sink: Box<dyn io::Write + Send>,
592    ) -> Result<(), StorageError> {
593        let front = self.front.clone();
594        let key_bytes = key.as_bytes().to_vec();
595        let store = Arc::clone(self.front.store());
596        let serving = tokio::task::spawn_blocking(move || -> Result<(), StorageError> {
597            let mut sink = sink;
598            let mut offset = 0u64;
599            while offset < len {
600                let window = SERVE_WINDOW.min(len - offset);
601                let (slice, digest) = front
602                    .block_on(store.read_range(&key_bytes, offset, window))
603                    .map_err(|e| store_leg_error("alkblobs read_range", e))?;
604                let derived: [u8; 32] = sha2::Sha256::digest(&slice).into();
605                if derived != digest {
606                    return Err(StorageError::Invalid(format!(
607                        "the stored pack's window at {offset} fails its slice digest (mid-flight corruption)"
608                    )));
609                }
610                sink.write_all(&slice)
611                    .map_err(|e| StorageError::Io(format!("the pack sink failed: {e}")))?;
612                offset += window;
613            }
614            sink.flush()
615                .map_err(|e| StorageError::Io(format!("the pack sink failed: {e}")))
616        });
617        serving
618            .await
619            .map_err(|join| StorageError::Io(format!("the pack serving task failed: {join}")))?
620    }
621}
622
623#[async_trait]
624impl GitPackGen for PackGen {
625    /// closure(wants) − closure(boundary_haves) → pack streamed into
626    /// the sink (ADR-004). Missing objects abort with
627    /// [`StorageError::ObjectMissing`] — never a broken pack on the
628    /// sink.
629    async fn generate(
630        &self,
631        _repo: &RepoRecord,
632        wants: Vec<ObjectId>,
633        boundary_haves: Vec<ObjectId>,
634        _limits: Limits,
635        sink: Box<dyn io::Write + Send>,
636    ) -> Result<(), StorageError> {
637        match &self.serving {
638            Serving::Direct => {
639                let front = self.front.clone();
640                let direct = tokio::task::spawn_blocking(move || {
641                    let find = front.session();
642                    let mut sink = sink;
643                    run_pipeline(&find, wants, boundary_haves, &mut sink)?;
644                    sink.flush()
645                        .map_err(|e| StorageError::Io(format!("the pack sink failed: {e}")))
646                });
647                direct.await.map_err(|join| {
648                    StorageError::Io(format!("the pack generation task failed: {join}"))
649                })?
650            }
651            Serving::ThroughStore(cache) => {
652                let digest = request_digest(&wants, &boundary_haves);
653                if let Some((key, len)) = cache.lookup(digest) {
654                    return self.serve_stored(&key, len, sink).await;
655                }
656                let find = self.front.session();
657                let (key, pin, len) = self.store_pack(find, wants, boundary_haves).await?;
658                cache.insert(digest, key.clone(), len, pin);
659                self.serve_stored(&key, len, sink).await
660            }
661        }
662    }
663
664    /// The negotiation ack seam's real predicate (ADR-014 §5, the same
665    /// predicate on the ack rounds and the done-round boundary alike): a
666    /// have is recognized iff it **exists in the object store and is a
667    /// commit** — the is-commit half over the loose header the odb layer
668    /// owns ([`ObjectFront::kind`], the bounded header peek — the
669    /// predicate never reads a have's body, so a hostile have set cannot
670    /// walk the store's object contents). A missing have is
671    /// `kind`'s `None` (unrecognized — subtracts nothing); a non-sha1
672    /// have is `StorageError::Invalid` (OQ-05). One seam predicate, no
673    /// wire-side refinement (ADR-010). The caller's order is preserved
674    /// verbatim.
675    async fn common_haves(
676        &self,
677        _repo: &RepoRecord,
678        haves: Vec<ObjectId>,
679    ) -> Result<Vec<ObjectId>, StorageError> {
680        let mut recognized = Vec::new();
681        for oid in haves {
682            if self.front.kind(&oid).await? == Some(Kind::Commit) {
683                recognized.push(oid);
684            }
685        }
686        Ok(recognized)
687    }
688}
689
690#[cfg(test)]
691mod tests {
692    use alkblobs::Store;
693    use alkgit::{GitPackGen as _, Limits, RepoRecord, StorageError, Visibility};
694    use gix_hash::ObjectId;
695    use gix_object::Kind;
696    use std::io::{self, Write};
697    use std::sync::Arc;
698
699    use super::{request_digest, run_pipeline, PackCache, PackGen};
700    use crate::odb::{key_to_oid, ObjectFront};
701    use crate::testutil::scratch_dir as scratch_root;
702
703    fn repo() -> RepoRecord {
704        RepoRecord {
705            repo_id: "substrate/packgen-tests".to_owned(),
706            visibility: Visibility::Public,
707            grants: Default::default(),
708        }
709    }
710
711    async fn put_object(front: &ObjectFront, kind: Kind, content: &[u8]) -> ObjectId {
712        let (key, _pin) = front.put(kind, content).await.unwrap();
713        key_to_oid(&key).expect("the put's key is the family key")
714    }
715
716    fn tree_bytes(entries: &[(&str, &ObjectId)]) -> Vec<u8> {
717        let mut tree = Vec::new();
718        for (name, oid) in entries {
719            tree.extend_from_slice(format!("100644 {name}\0").as_bytes());
720            tree.extend_from_slice(oid.as_bytes());
721        }
722        tree
723    }
724
725    fn commit_bytes(tree: &ObjectId, parents: &[&ObjectId]) -> Vec<u8> {
726        let mut body = format!("tree {tree}\n");
727        for parent in parents {
728            body.push_str(&format!("parent {parent}\n"));
729        }
730        body.push_str(
731            "author A <a@example.invalid> 1750000000 +0000\n\
732             committer A <a@example.invalid> 1750000000 +0000\n\nthe message\n",
733        );
734        body.into_bytes()
735    }
736
737    fn tag_bytes(target: &ObjectId) -> Vec<u8> {
738        format!(
739            "object {target}\ntype commit\ntag v1.0.0\n\
740             tagger A <a@example.invalid> 1750000000 +0000\n\nannotated\n"
741        )
742        .into_bytes()
743    }
744
745    /// The wave-7 fixture, built into `front`: two commits with distinct
746    /// root trees, an annotated tag on the base commit (the tag-chain
747    /// path's member), and a stray blob (the non-commit want and the
748    /// unrecognized-have member).
749    struct Fixture {
750        base: ObjectId,
751        tip: ObjectId,
752        tag: ObjectId,
753        stray: ObjectId,
754    }
755
756    async fn build_fixture(front: &ObjectFront) -> Fixture {
757        let blob = put_object(front, Kind::Blob, b"hello\n").await;
758        let stray = put_object(front, Kind::Blob, b"stray\n").await;
759        let other = put_object(front, Kind::Blob, b"second file\n").await;
760        let base_tree = put_object(front, Kind::Tree, &tree_bytes(&[("hello", &blob)])).await;
761        let tip_tree = put_object(
762            front,
763            Kind::Tree,
764            &tree_bytes(&[("hello", &blob), ("second", &other)]),
765        )
766        .await;
767        let base = put_object(front, Kind::Commit, &commit_bytes(&base_tree, &[])).await;
768        let tip = put_object(front, Kind::Commit, &commit_bytes(&tip_tree, &[&base])).await;
769        let tag = put_object(front, Kind::Tag, &tag_bytes(&base)).await;
770        Fixture {
771            base,
772            tip,
773            tag,
774            stray,
775        }
776    }
777
778    /// The test sink: a shared buffer behind the seam's `Box<dyn
779    /// io::Write + Send>` (the bytes come back out through the clone).
780    #[derive(Clone, Default)]
781    struct BytesSink(Arc<parking_lot::Mutex<Vec<u8>>>);
782
783    impl BytesSink {
784        fn bytes(&self) -> Vec<u8> {
785            self.0.lock().clone()
786        }
787    }
788
789    impl Write for BytesSink {
790        fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
791            self.0.lock().extend_from_slice(buf);
792            Ok(buf.len())
793        }
794
795        fn flush(&mut self) -> io::Result<()> {
796            Ok(())
797        }
798    }
799
800    async fn generate(
801        packgen: &PackGen,
802        wants: Vec<ObjectId>,
803        boundary: Vec<ObjectId>,
804    ) -> Result<Vec<u8>, StorageError> {
805        let sink = BytesSink::default();
806        packgen
807            .generate(
808                &repo(),
809                wants,
810                boundary,
811                Limits::default(),
812                Box::new(sink.clone()),
813            )
814            .await?;
815        Ok(sink.bytes())
816    }
817
818    fn cached_count(packgen: &PackGen) -> usize {
819        match &packgen.serving {
820            super::Serving::ThroughStore(cache) => cache.cached(),
821            super::Serving::Direct => panic!("the packgen is not through-store"),
822        }
823    }
824
825    #[tokio::test]
826    async fn generate_packs_the_fixture_over_the_real_find() {
827        let front = ObjectFront::new(Arc::new(Store::mem_only()));
828        let fx = build_fixture(&front).await;
829        let packgen = PackGen::direct(front.clone());
830        let pack = generate(&packgen, vec![fx.tip], vec![]).await.unwrap();
831
832        assert!(pack.starts_with(b"PACK"), "the pack header wrote");
833        let object_count = u32::from_be_bytes([pack[8], pack[9], pack[10], pack[11]]);
834        assert_eq!(
835            object_count, 6,
836            "closure(tip) − closure(nothing) = tip, base, two trees, two blobs"
837        );
838        let trailer = &pack[pack.len() - 20..];
839        assert!(
840            trailer.iter().any(|byte| *byte != 0),
841            "the trailer carries the sha1 digest"
842        );
843    }
844
845    #[tokio::test]
846    async fn direct_output_is_byte_identical_to_the_doubles_pipeline() {
847        let front = ObjectFront::new(Arc::new(Store::mem_only()));
848        let fx = build_fixture(&front).await;
849        let wants = vec![fx.tip, fx.tag];
850        let boundary = vec![fx.base];
851        let packgen = PackGen::direct(front.clone());
852        let pack = generate(&packgen, wants.clone(), boundary.clone())
853            .await
854            .unwrap();
855
856        let find = front.session();
857        let reference = tokio::task::spawn_blocking(move || {
858            let mut reference = Vec::new();
859            run_pipeline(&find, wants, boundary, &mut reference)?;
860            Ok::<_, StorageError>(reference)
861        })
862        .await
863        .unwrap()
864        .unwrap();
865        assert_eq!(
866            pack, reference,
867            "the direct shape is the doubles' ADR-004 pipeline, byte for byte \
868             (the boundary have trims the commit walk; the tag want rides the AsIs stage)"
869        );
870    }
871
872    #[tokio::test]
873    async fn a_missing_want_aborts_naming_the_missing_object() {
874        let front = ObjectFront::new(Arc::new(Store::mem_only()));
875        let missing = ObjectId::from([0xA5u8; 20]);
876        let packgen = PackGen::direct(front.clone());
877        let error = generate(&packgen, vec![missing], vec![]).await.unwrap_err();
878        match error {
879            StorageError::ObjectMissing { oid } => {
880                assert_eq!(oid, missing, "the abort names the missing object");
881            }
882            other => panic!("a missing want is the ObjectMissing abort, not {other}"),
883        }
884    }
885
886    /// A commit whose tree references an oid the store does not hold:
887    /// the peel and the commit walk succeed, TreeContents misses — the
888    /// abort names the missing closure member, never a partial pack.
889    #[tokio::test]
890    async fn a_missing_closure_member_aborts_naming_it() {
891        let front = ObjectFront::new(Arc::new(Store::mem_only()));
892        let ghost = ObjectId::from([0xEEu8; 20]);
893        let tree = put_object(&front, Kind::Tree, &tree_bytes(&[("ghost", &ghost)])).await;
894        let commit = put_object(&front, Kind::Commit, &commit_bytes(&tree, &[])).await;
895        let packgen = PackGen::direct(front);
896        let error = generate(&packgen, vec![commit], vec![]).await.unwrap_err();
897        match error {
898            StorageError::ObjectMissing { oid } => {
899                assert_eq!(oid, ghost, "the abort names the missing member");
900            }
901            other => panic!("a missing member is the ObjectMissing abort, not {other}"),
902        }
903    }
904
905    #[tokio::test]
906    async fn the_ack_predicate_recognizes_only_existing_commits() {
907        let front = ObjectFront::new(Arc::new(Store::mem_only()));
908        let fx = build_fixture(&front).await;
909        let missing = ObjectId::from([0x3Cu8; 20]);
910        let packgen = PackGen::direct(front.clone());
911        let recognized = packgen
912            .common_haves(&repo(), vec![fx.tip, fx.stray, fx.tag, missing, fx.base])
913            .await
914            .unwrap();
915        assert_eq!(
916            recognized,
917            vec![fx.tip, fx.base],
918            "exists + is-commit: the blob and the tag are unrecognized, a \
919             missing have subtracts nothing, the caller's order is verbatim"
920        );
921    }
922
923    #[tokio::test]
924    async fn the_through_store_shape_serves_the_stored_pack() {
925        let front = ObjectFront::new(Arc::new(Store::mem_only()));
926        let fx = build_fixture(&front).await;
927        let wants = vec![fx.tip];
928        let boundary: Vec<ObjectId> = vec![];
929        let direct_packgen = PackGen::direct(front.clone());
930        let direct = generate(&direct_packgen, wants.clone(), boundary.clone())
931            .await
932            .unwrap();
933
934        let packgen = PackGen::through_store(front.clone());
935        let first = generate(&packgen, wants.clone(), boundary.clone())
936            .await
937            .unwrap();
938        assert_eq!(first, direct, "the stored pack is the generated pack");
939        assert_eq!(cached_count(&packgen), 1, "one request, one cached pack");
940
941        let digest = request_digest(&wants, &boundary);
942        let (key, len) = match &packgen.serving {
943            super::Serving::ThroughStore(cache) => cache.lookup(digest).unwrap(),
944            super::Serving::Direct => unreachable!(),
945        };
946        let stored = front.store().stat(&key.as_bytes()).await.unwrap();
947        assert_eq!(stored.map(|meta| meta.len), Some(len), "put-once stat");
948
949        let second = generate(&packgen, wants, boundary).await.unwrap();
950        assert_eq!(second, direct, "the repeat fetch rides the stored pack");
951        assert_eq!(cached_count(&packgen), 1, "no regeneration on the hit");
952    }
953
954    #[tokio::test]
955    async fn cache_eviction_and_release_are_the_pin_paths() {
956        let front = ObjectFront::new(Arc::new(Store::mem_only()));
957        let fx = build_fixture(&front).await;
958        let packgen = PackGen::through_store_with(front.clone(), Arc::new(PackCache::new(1)));
959        let first = generate(&packgen, vec![fx.tip], vec![]).await.unwrap();
960        assert_eq!(cached_count(&packgen), 1);
961
962        let second = generate(&packgen, vec![fx.tag], vec![]).await.unwrap();
963        assert_ne!(&first, &second, "the two requests differ");
964        assert_eq!(
965            cached_count(&packgen),
966            1,
967            "capacity held; the first evicted"
968        );
969
970        let cache = match &packgen.serving {
971            super::Serving::ThroughStore(cache) => cache,
972            super::Serving::Direct => unreachable!(),
973        };
974        assert!(
975            cache
976                .lookup(request_digest(std::slice::from_ref(&fx.tip), &[]))
977                .is_none(),
978            "the evicted digest is gone (its pin dropped — sweep-eligible)"
979        );
980        assert!(
981            cache
982                .lookup(request_digest(std::slice::from_ref(&fx.tag), &[]))
983                .is_some(),
984            "the newest entry is the cache's"
985        );
986        assert_eq!(cache.release_all(), 1, "the housekeeping release path");
987        assert_eq!(cache.cached(), 0, "the release path drains the cache");
988    }
989
990    #[tokio::test]
991    async fn a_kv_only_store_rejects_an_over_threshold_pack() {
992        let front = ObjectFront::new(Arc::new(Store::kv_only_mem(256)));
993        let fx = build_fixture(&front).await;
994        assert!(front.stat(&fx.tip).await.unwrap().is_some());
995        let packgen = PackGen::through_store(front.clone());
996        let error = generate(&packgen, vec![fx.tip], vec![]).await.unwrap_err();
997        match &error {
998            StorageError::Io(reason) => {
999                assert!(
1000                    reason.contains("kv-only"),
1001                    "the kv-only put-time rejection is what surfaces: {reason}"
1002                );
1003            }
1004            other => panic!("the over-threshold pack is the put's rejection, not {other}"),
1005        }
1006    }
1007
1008    #[tokio::test]
1009    async fn the_direct_shape_never_rounds_through_the_store() {
1010        let front = ObjectFront::new(Arc::new(Store::kv_only_mem(256)));
1011        let fx = build_fixture(&front).await;
1012        let packgen = PackGen::direct(front.clone());
1013        let pack = generate(&packgen, vec![fx.tip], vec![]).await.unwrap();
1014        assert!(
1015            pack.starts_with(b"PACK"),
1016            "a pack the through-store shape would have rejected streams direct"
1017        );
1018    }
1019
1020    /// The interop row (alkblobs' `git_interop.rs` standard): the
1021    /// generated direct pack indexes into a real git repo and fscks
1022    /// clean — skipped with a message when no `git` is installed
1023    /// (tests only; the serving path spawns nothing).
1024    #[tokio::test]
1025    async fn the_generated_pack_indexes_clean_under_git() {
1026        if std::process::Command::new("git")
1027            .arg("--version")
1028            .output()
1029            .is_err()
1030        {
1031            eprintln!("skipping (no git binary installed)");
1032            return;
1033        }
1034        let dir = scratch_root("packgen", "git-index");
1035        let init = std::process::Command::new("git")
1036            .args(["init", "-q"])
1037            .current_dir(&dir)
1038            .env("GIT_CONFIG_GLOBAL", "/dev/null")
1039            .env("GIT_CONFIG_SYSTEM", "/dev/null")
1040            .output()
1041            .unwrap();
1042        assert!(init.status.success(), "git init failed: {init:?}");
1043
1044        let front = ObjectFront::new(Arc::new(Store::mem_only()));
1045        let fx = build_fixture(&front).await;
1046        let packgen = PackGen::direct(front.clone());
1047        let pack = generate(&packgen, vec![fx.tip], vec![]).await.unwrap();
1048        let pack_path = dir.join("incoming.pack");
1049        std::fs::write(&pack_path, &pack).unwrap();
1050
1051        let index = std::process::Command::new("git")
1052            .args(["index-pack", "--stdin"])
1053            .current_dir(&dir)
1054            .env("GIT_CONFIG_GLOBAL", "/dev/null")
1055            .env("GIT_CONFIG_SYSTEM", "/dev/null")
1056            .stdin(std::process::Stdio::from(
1057                std::fs::File::open(&pack_path).unwrap(),
1058            ))
1059            .output()
1060            .unwrap();
1061        assert!(
1062            index.status.success(),
1063            "git index-pack failed: {}",
1064            String::from_utf8_lossy(&index.stderr)
1065        );
1066        let fsck = std::process::Command::new("git")
1067            .args(["fsck", "--strict"])
1068            .current_dir(&dir)
1069            .env("GIT_CONFIG_GLOBAL", "/dev/null")
1070            .env("GIT_CONFIG_SYSTEM", "/dev/null")
1071            .output()
1072            .unwrap();
1073        assert!(
1074            fsck.status.success(),
1075            "git fsck --strict failed: {}",
1076            String::from_utf8_lossy(&fsck.stderr)
1077        );
1078        std::fs::remove_dir_all(&dir).unwrap_or_default();
1079    }
1080
1081    // The `cached` probe is test-only: the pack cache's occupancy is
1082    // observable nowhere else on the public surface.
1083    impl PackCache {
1084        fn cached(&self) -> usize {
1085            self.entries.lock().len()
1086        }
1087    }
1088}