Skip to main content

alkgit_substrate/
liveness.rs

1//! The GC liveness sources over alkgit's own durable rows (waves 7
2//! and 9): the ref-table closure — every ref row's full reachable
3//! graph walk over the substrate object front — and, under the `lfs`
4//! feature, the media rows source ([`LfsMedia`], substrate.md §GC
5//! sources 1 and 2; ADR-019 §6) — both registered as in-process
6//! [`alkblobs::LivenessSource`]s on the composition's store at the
7//! composition entry ([`crate::compose`]).
8//!
9//! # The walk (the owning discipline)
10//!
11//! The live set is the union over all repos' oid rows of the full
12//! graph closure of the row's tip oid: commits (parents included —
13//! the owning walk; gix-fsck's `Connectivity::check_commit` does not
14//! walk parents, so it is not the walk here), trees (sub-trees and
15//! blobs), blobs, and tags (peeled to their target and walked on).
16//! Gitlink tree entries (submodule modes) neither mark nor walk: the
17//! pinned commit belongs to its other repo, whose own refs cover it.
18//! Symref rows (the seeded unborn `HEAD`) carry no oid — unborn
19//! contributes nothing; when the symref's target is a real row, that
20//! row's own closure covers the object. Keys are assembled map-free
21//! ([`crate::odb::oid_key`]): `[0x03]` + oid bytes, the git-object
22//! family's physical encoding.
23//!
24//! The walk is best-effort by the seam's shape
25//! ([`alkblobs::LivenessSource::live_keys`] returns keys, not
26//! errors): a missing or unreadable object stops that branch (the key
27//! is absent from the pool or unread — both safe) and the walk
28//! continues; the next rebuild retries. A corrupt entry surfaces the
29//! front's verification error at the walk read — the same
30//! [`StorageError::Invalid`] the serving reads carry.
31//!
32//! # The LFS media source (the second alkgit-owned liveness source,
33//! the `lfs` feature)
34//!
35//! [`LfsMedia`] (wave 9, ADR-022 §4; substrate.md §GC source 2) serves
36//! the LFS media family's liveness from the pointer-lifecycle rows
37//! ([`crate::lfs::LfsRows`]). The mark consult is **a row read, not a
38//! graph walk** — media is unstructured content, there is nothing
39//! reachable *through* it — and the keys form **map-free**: the row's
40//! oid *is* the digest, so the key is `[0x04] + oid bytes`
41//! ([`lfs_media_key`], the odb front's `oid_key` analog for the
42//! 32-byte sha256 form; the oid is the raw sha256 of the content,
43//! byte-exact the `GitObjectSha256` derivation — lfs.md §2, ADR-022
44//! §1). One key with N rows is one live key (rows dedup through the
45//! key set — the cross-reference refcount's mark-side reading; *any*
46//! row re-protects the key, the refcount column's contract realized).
47//!
48//! The invalidation posture is the pinned simple-honest shape: the
49//! mark consult **rebuilds per mark** (the row read is cheap; the
50//! refs closure's fingerprint machinery buy the walk's cost — nothing
51//! to skip here, so nothing to key a memo on). What the arbitration
52//! discipline *does* need is the refs closure's **write-counter**
53//! half: `RefClosure`'s `contains` consult cannot answer exactly without an
54//! epoch signal, and the LFS rows' dangerous window is a row
55//! *landing* between a sweep's mark and a staged candidate's
56//! arbitration (the stale index says unreferenced; deleting it eats
57//! referenced media). [`crate::lfs::VersionedLfsRows`] bumps the
58//! shared counter on every `assert` — the coverage-increasing event,
59//! and the only one whose staleness is dangerous: a removal's stale
60//! answers are the safe side (a stale "live" holds a candidate; a
61//! stale "dead" is exact for a candidate whose rows left). The
62//! same-window answer pattern as the refs source: index hit exactly,
63//! cold memo live (the safe default), miss after a moved counter live
64//! until the next mark's rebuild resolves it.
65//!
66//! The mark consult bridges with the front's
67//! [`block_in_place`](crate::odb::ObjectFront::block_in_place)
68//! posture (the sqlite/pg rows twins are async — spawn_blocking /
69//! native I/O inside); the arbitration consult never bridges. Same
70//! floor: the multi-thread runtime (test-pinned). The failed mark
71//! consult answers empty with the memo untouched — the same residual
72//! class the refs closure documents (the stale memo answers at
73//! arbitration; the next mark corrects).
74//!
75//! The cross-process posture is the refs closure's, stated honestly:
76//! the in-process source is instance-local (alkblobs ADR-012 §6.6's
77//! allow-and-document reading); the fleet-wide answer is the
78//! materialized liveness table
79//! ([`alkblobs::LivenessRegistration::FleetTable`] — wave-10 fleet
80//! assembly's wiring, not this module's). The in-flight LFS uploads
81//! are protected fleet-wide by the DB-backed pins (alkblobs ADR-008)
82//! — the put side is pins', not this source's.
83//!
84//! # The sync verb / async store bridge
85//!
86//! [`alkblobs::LivenessSource`]'s verbs are sync (the sweep consults
87//! `live_keys` from its async body, `contains` under the arbitration
88//! lock). The walk bridges with
89//! [`crate::odb::ObjectFront::block_in_place`] — the odb-front task's
90//! async-context posture (the POC-O panic shape; the blocking half is
91//! the pack pipelines'). Consequence: the multi-thread runtime is the
92//! floor — a current-thread runtime panics (test-pinned).
93//! `contains` never bridges: it answers from the memo's in-memory
94//! index only ([`RefClosure::keys_builds`] proves no consult derives).
95//! The walk consumes the front's async read legs directly (the same
96//! verified reads the sync Find legs bridge to) — the walk runs
97//! inside the bridge's future, where the sync legs' own `block_on`
98//! is illegal (the POC-O panic shape).
99//!
100//! # Memoization and the invalidation decision (the honest posture)
101//!
102//! POC-O's shape was the closure memoized by the refs table's version
103//! counter. The wave-6 refs rows carry no version counter, so this
104//! task pins the mechanism; the three candidates resolved as two
105//! composable half-decisions:
106//!
107//! - **The memo's rebuild key is a durable refs fingerprint** (the
108//!   durable-marker option, realized read-side — no schema change).
109//!   [`RefRows::snapshot`] returns the rows plus a fingerprint over
110//!   their canonical form; a sweep's mark consult reads it (one cheap
111//!   refs-rows read, ordered by repo/name) and rebuilds the walk only
112//!   when it moved. Because the fingerprint is computed from the
113//!   durable rows, cross-process ref writes are seen at mark time by
114//!   construction — the fleet-shape invalidation is the *snapshot
115//!   read*, not process-local state.
116//! - **The arbitration window is covered by an in-process write
117//!   counter** (the POC-O shape carried). A push landing between this
118//!   sweep's mark and the next sweep's execution-time arbitration
119//!   cannot move the fingerprint consult (arbitration runs under the
120//!   store's short-held lock — no bridging, no reads). The
121//!   composition's refs writes therefore bump a shared in-process
122//!   counter ([`crate::refs::VersionedRefTable`] on the sqlite
123//!   assembly), and [`alkblobs::LivenessSource::contains`] answers
124//!   conservatively — "live" (unknown) — whenever the counter moved
125//!   past the memo's build: the affected candidates cancel and
126//!   resolve on the next sweep's mark, which rebuilds against the new
127//!   rows. Over-invalidation costs one delayed sweep; the alternative
128//!   deletes a pushed object. A cold (never-built) memo answers
129//!   "live" too — the safe default until the first mark; direct
130//!   deletes under it refuse ([`alkblobs::Error::GcRefuse`]), the
131//!   ADR-012 §6.4 deregister-delete-re-register correction flow is
132//!   the escape hatch, and an index hit always answers exactly.
133//! - **The cross-process posture, stated honestly**: the in-process
134//!   source is instance-local (alkblobs ADR-012 §6.6's allow-and-
135//!   document reading — the SQL protocol sees tables, not callbacks).
136//!   Single-node shapes (the default sqlite assembly) are exact.
137//!   Fleet deployments carry one residual window: a completed push
138//!   from *another* instance between mark and execution-time
139//!   arbitration (this instance's counter does not move and the mark
140//!   has not come yet). The in-flight push itself is protected
141//!   fleet-wide by the DB-backed pins (alkblobs ADR-008), and the
142//!   fleet-wide answer is the materialized liveness table
143//!   ([`alkblobs::LivenessRegistration::FleetTable`] — the embedder's
144//!   own table the store core reads at SQL arbitration); that
145//!   materialization is the fleet assembly's wiring — the wave-8
146//!   composition entry is the unifier and the wave-10 pg assembly
147//!   exercises it (the pg `GitRefs` twin landed with the wave-7 pg
148//!   refs task), not this module's.
149//!
150//! # Registration
151//!
152//! [`RefClosure::register`] registers the source on the store
153//! (`Store::register_liveness_source`, the live-shared seam —
154//! consulted live every sweep, never a snapshot at registration).
155//! The composition's construction wires [`RefClosure::new`] over the
156//! object front, the refs rows seam, and the assembly's write
157//! counter, then registers: zero registered liveness sources make
158//! every sweep abort with
159//! [`alkblobs::GcAbortCause::NoLivenessSources`] — this source is
160//! what the wave-7 maintenance chain's sweep dispatch requires. The
161//! wave-8 composition entry unifies the construction; until then the
162//! pieces + this wiring are the proof surface (the tests run the full
163//! chain, sqlite co-tenancy included).
164
165use std::collections::HashSet;
166use std::sync::atomic::{AtomicU64, Ordering};
167use std::sync::Arc;
168
169use alkblobs::Key;
170use async_trait::async_trait;
171use gix_hash::ObjectId;
172use gix_object::Kind;
173
174use alkgit::StorageError;
175
176use crate::odb::oid_key;
177
178/// One refs row as the liveness seam reads it: the resolved repo id,
179/// the validated ref name, and the row's state — an oid tip or the
180/// symref (unborn) shape. The rows cross [`RefRows::snapshot`] as
181/// they are, not as [`alkgit::RefLine`] listings: liveness walks
182/// tips, it does not advertise.
183#[derive(Clone, Debug, PartialEq, Eq)]
184pub struct RefRow {
185    pub repo_id: String,
186    pub name: String,
187    pub state: RefRowState,
188}
189
190/// A refs row's state as the CAS tables store it (the refs module's
191/// row shapes, surfaced for the liveness seam).
192#[derive(Clone, Debug, PartialEq, Eq)]
193pub enum RefRowState {
194    /// The ordinary ref row: the tip oid.
195    Oid(ObjectId),
196    /// The symref row (the seeded `HEAD → refs/heads/main`): no oid
197    /// of its own — unborn, contributes nothing to the closure.
198    Symref(String),
199}
200
201/// A refs-rows snapshot: every row of every repo, in the canonical
202/// order (repo id, then name — sorted), plus the fingerprint the
203/// memo keys on.
204#[derive(Clone, Debug, PartialEq, Eq)]
205pub struct RefRowsSnapshot {
206    pub rows: Vec<RefRow>,
207    pub fingerprint: u64,
208}
209
210/// The refs rows' read seam — the durable state the closure walks and
211/// fingerprints. Implemented by the refs tables
212/// ([`crate::refs::InMemoryRefTable`], the `sqlite` twin) and
213/// forwarded by the composition's write-counting wrapper
214/// ([`crate::refs::VersionedRefTable`]). Engine twins carry their
215/// own: snapshotting is a plain ordered read of the rows the CAS
216/// writes.
217#[async_trait]
218pub trait RefRows: Send + Sync {
219    /// The full canonical snapshot: all repos' rows, name-sorted,
220    /// with the fingerprint over the canonical form.
221    async fn snapshot(&self) -> Result<RefRowsSnapshot, StorageError>;
222}
223
224/// The canonical fingerprint over a snapshot's rows: the row count
225/// folded into an FNV-1a pass over each row's canonical bytes
226/// (length-prefixed strings, the state discriminant, oid bytes /
227/// target bytes). The order is the canonical one (repo id, then
228/// name), which the impls guarantee (B-tree maps; `ORDER BY
229/// repo_id, name`); a fingerprint move means the durable rows moved
230/// (cross-process — the snapshot reads the rows, never local state).
231pub(crate) fn fingerprint_rows(rows: &[RefRow]) -> u64 {
232    fn fold(state: &mut u64, part: &[u8]) {
233        for b in part {
234            *state ^= u64::from(*b);
235            *state = state.wrapping_mul(0x0100_0000_0193_e739);
236        }
237        *state ^= 0x0f4e_a559_f1bf_2acd;
238        *state = state.wrapping_mul(0x0100_0000_0193_e739);
239    }
240    // Canonical (repo id, then name) order regardless of what the
241    // caller passes — the impls hand sorted rows; the fold is
242    // order-normalized anyway so the fingerprint cannot drift on
243    // input order.
244    let mut sorted: Vec<&RefRow> = rows.iter().collect();
245    sorted.sort_by(|a, b| (&a.repo_id, &a.name).cmp(&(&b.repo_id, &b.name)));
246    let mut state = 0xcbf2_9ce4_8422_2325u64;
247    fold(&mut state, &(sorted.len() as u64).to_le_bytes());
248    for row in sorted {
249        fold(&mut state, row.repo_id.as_bytes());
250        fold(&mut state, row.name.as_bytes());
251        match &row.state {
252            RefRowState::Oid(oid) => {
253                fold(&mut state, &[0x01]);
254                fold(&mut state, oid.as_bytes());
255            }
256            RefRowState::Symref(target) => {
257                fold(&mut state, &[0x02]);
258                fold(&mut state, target.as_bytes());
259            }
260        }
261    }
262    state
263}
264
265/// The memo — the closure the sweep's mark consults and the
266/// arbitration's `contains` answers from, keyed on the fingerprint
267/// and the write counter. Held under a short lock only: `contains`
268/// reads it under the store's arbitration lock, the rebuild replaces
269/// it back, and the walk itself never holds it.
270struct Memo {
271    fingerprint: u64,
272    build_writes: u64,
273    keys: HashSet<Key>,
274}
275
276/// The ref-table-closure [`alkblobs::LivenessSource`] — the first
277/// alkgit-owned liveness source (substrate.md §GC; ADR-019 §6). See
278/// the module docs for the walk, the bridge, and the invalidation
279/// decision.
280pub struct RefClosure {
281    front: crate::odb::ObjectFront,
282    rows: Arc<dyn RefRows>,
283    writes: Arc<AtomicU64>,
284    memo: parking_lot::Mutex<Option<Memo>>,
285    builds: AtomicU64,
286}
287
288impl RefClosure {
289    /// The closure source over the object front, the refs rows seam,
290    /// and the composition's shared write counter (bumped by the
291    /// refs writes — [`crate::refs::VersionedRefTable`] on the sqlite
292    /// assembly).
293    pub fn new(
294        front: crate::odb::ObjectFront,
295        rows: Arc<dyn RefRows>,
296        writes: Arc<AtomicU64>,
297    ) -> Arc<Self> {
298        Arc::new(RefClosure {
299            front,
300            rows,
301            writes,
302            memo: parking_lot::Mutex::new(None),
303            builds: AtomicU64::new(0),
304        })
305    }
306
307    /// Register the source with the store — the live-shared seam
308    /// (`Store::register_liveness_source`; alkblobs store-api.md
309    /// §Lifecycle). Registered at the composition's construction;
310    /// deregistration/re-registration around a direct delete is the
311    /// ADR-012 §6.4 embedder correction flow (out of scope here).
312    ///
313    /// The composition carries this source for the store's lifetime
314    /// (registration once, at construction — the returned token is
315    /// deregister-at-most-once and is the composition's to keep).
316    pub fn register(
317        self: &Arc<Self>,
318        store: &alkblobs::Store,
319    ) -> Result<alkblobs::Registration, alkblobs::Error> {
320        store.register_liveness_source(alkblobs::LivenessRegistration::InProcess(
321            self.clone() as Arc<dyn alkblobs::LivenessSource>
322        ))
323    }
324
325    /// How many closure walks have run — the memoization pin (a
326    /// fingerprint hit consumes no walk; the arbitration consult
327    /// never triggers one).
328    pub fn keys_builds(&self) -> u64 {
329        self.builds.load(Ordering::Relaxed)
330    }
331
332    /// The mark consult's build half: the cheap refs snapshot, the
333    /// fingerprint hit-or-rebuild, the walk over the front. The
334    /// counter loads **before** the snapshot (the epoch discipline:
335    /// every write's bump lands *after* its commit, so a memo's
336    /// counter-load can never pair an included bump with a
337    /// snapshot-read that missed the same write's committed rows —
338    /// the poisoned memo shape; anything the read missed moves the
339    /// counter past the captured epoch, which answers
340    /// conservatively-live at arbitration).
341    async fn rebuild_or_cached_keys(&self) -> Result<Vec<Key>, StorageError> {
342        let build_writes = self.writes.load(Ordering::Relaxed);
343        let snapshot = self.rows.snapshot().await?;
344        {
345            let mut memo = self.memo.lock();
346            if let Some(memo) = memo.as_mut() {
347                if memo.fingerprint == snapshot.fingerprint {
348                    // The rows are unchanged — the index stays exact.
349                    // A write landing between the counter load and the
350                    // fingerprint hit would have moved the fingerprint,
351                    // so the captured epoch is still current here.
352                    memo.build_writes = build_writes;
353                    return Ok(memo.keys.iter().cloned().collect());
354                }
355            }
356        }
357        let (keys, fingerprint) = self.walk(&snapshot).await;
358        self.builds.fetch_add(1, Ordering::Relaxed);
359        *(self.memo.lock()) = Some(Memo {
360            fingerprint,
361            build_writes,
362            keys: keys.iter().cloned().collect(),
363        });
364        Ok(keys)
365    }
366
367    /// The graph walk: tips from the oid rows, BFS over the object
368    /// front's async read legs (the same verified reads the sync Find
369    /// legs bridge to — the walk runs *inside* the
370    /// [`block_in_place`](crate::odb::ObjectFront::block_in_place)
371    /// bridge, where the sync legs' own `block_on` is illegal, the
372    /// POC-O panic shape). Parents included; gitlinks skipped;
373    /// map-free keys. Best-effort on per-node failures (the module
374    /// docs' walk section). The fingerprint travels: it is the
375    /// snapshot's, so the memo it lands in keys on exactly the rows
376    /// the tips came from.
377    async fn walk(&self, snapshot: &RefRowsSnapshot) -> (Vec<Key>, u64) {
378        let fingerprint = snapshot.fingerprint;
379        let mut tips: Vec<ObjectId> = snapshot
380            .rows
381            .iter()
382            .filter_map(|row| match &row.state {
383                RefRowState::Oid(oid) => Some(*oid),
384                RefRowState::Symref(_) => None,
385            })
386            .collect();
387        tips.sort();
388        tips.dedup();
389
390        let mut visited: HashSet<ObjectId> = HashSet::new();
391        let mut keys: Vec<Key> = Vec::new();
392        let mut stack = tips;
393        while let Some(oid) = stack.pop() {
394            if !visited.insert(oid) {
395                continue;
396            }
397            let Ok(key) = oid_key(&oid) else {
398                continue;
399            };
400            let Some((kind, body)) = self.front.get(&oid).await.ok().flatten() else {
401                continue;
402            };
403            keys.push(key);
404            let body = body.as_slice();
405            match kind {
406                Kind::Blob => {}
407                Kind::Commit => {
408                    let Ok(commit) = gix_object::CommitRef::from_bytes(body, gix_hash::Kind::Sha1)
409                    else {
410                        continue;
411                    };
412                    for parent in std::iter::once(&commit.tree).chain(commit.parents.iter()) {
413                        if let Ok(id) = ObjectId::from_hex(parent) {
414                            stack.push(id);
415                        }
416                    }
417                }
418                Kind::Tree => {
419                    for entry in gix_object::TreeRefIter::from_bytes(body, gix_hash::Kind::Sha1) {
420                        let Ok(entry) = entry else { continue };
421                        match entry.mode.kind() {
422                            gix_object::tree::EntryKind::Tree
423                            | gix_object::tree::EntryKind::Blob
424                            | gix_object::tree::EntryKind::BlobExecutable
425                            | gix_object::tree::EntryKind::Link => {
426                                stack.push(entry.oid.to_owned());
427                            }
428                            gix_object::tree::EntryKind::Commit => {}
429                        }
430                    }
431                }
432                Kind::Tag => {
433                    let Ok(tag) = gix_object::TagRef::from_bytes(body, gix_hash::Kind::Sha1) else {
434                        continue;
435                    };
436                    if let Ok(target) = ObjectId::from_hex(tag.target) {
437                        stack.push(target);
438                    }
439                }
440            }
441        }
442        (keys, fingerprint)
443    }
444}
445
446impl alkblobs::LivenessSource for RefClosure {
447    /// The mark-time consult — the whole live set of the refs
448    /// closure, bridged through the front's async-context posture
449    /// (multi-thread runtime floor). Fingerprint hit = no walk; a
450    /// failed snapshot consult answers empty (the next mark corrects
451    /// it). The delete leg is not widened by it: arbitration consults
452    /// this impl's trait-method `contains`, which answers against the
453    /// *same* memo the
454    /// failed mark left untouched — the staged pool-wide candidates
455    /// are guarded by the stale-epoch answer, so the deletion set is
456    /// the residual class documented in the module docs, not the
457    /// snapshot failure's. The alternative postures (blocking or
458    /// panicking the sweep's mark) are worse.
459    fn live_keys(&self) -> Vec<Key> {
460        self.front
461            .block_in_place(self.rebuild_or_cached_keys())
462            .unwrap_or_default()
463    }
464
465    /// The delete-time arbitration consult — under the store's
466    /// arbitration lock, quick, indexed, never blocking (no bridge,
467    /// no reads, no recompute):
468    ///
469    /// - an index hit answers exactly (`true` — the key was live at
470    ///   the memo's build);
471    /// - a cold (never-built) memo answers `true` — the safe default
472    ///   (unknown is live);
473    /// - an index miss after an in-process refs write answers `true`
474    ///   too — the write counter moved past the build, so the
475    ///   candidate's coverage state is unknown: cancel it now, let
476    ///   the next mark's rebuild decide. A ref delete between mark
477    ///   and arbitration lands here the safe way too (kept one more
478    ///   sweep, reclaimed after the rebuilt mark confirms).
479    fn contains(&self, key: &Key) -> bool {
480        let memo = self.memo.lock();
481        let Some(memo) = memo.as_ref() else {
482            return true;
483        };
484        if memo.keys.contains(key) {
485            return true;
486        }
487        let writes_now = self.writes.load(Ordering::Relaxed);
488        writes_now != memo.build_writes
489    }
490}
491
492// The liveness seam's row reads and the wrapper: [`RefRows`] is
493// implemented by the refs tables and forwarded by
494// [`crate::refs::VersionedRefTable`] (the composition hands the
495// liveness wiring one handle for both the writes — the bump — and the
496// rows — the snapshot). The seam type lives here (the walk's durable
497// state); its trait impls live beside their tables ([`crate::refs`]).
498
499#[cfg(feature = "lfs")]
500use crate::lfs::LfsRows;
501
502/// The media family's key formation from a row's oid — the map-free
503/// half the walk's [`oid_key`] is, sha256-swapped
504/// (lfs.md §2; ADR-022 §1: the oid *is* the digest, byte-exact the
505/// `GitObjectSha256` derivation): the key is
506/// `[Algorithm::GitObjectSha256] + oid bytes`, decoded through
507/// `Key::from_bytes` over the physical encoding — family-explicit,
508/// rejects-never-guesses. A non-64-lowercase-hex oid is
509/// [`StorageError::Invalid`] (the rows store validates the same form
510/// on write; the read-side check is the front's OQ-05 posture).
511#[cfg(feature = "lfs")]
512pub fn lfs_media_key(oid_hex: &str) -> Result<Key, StorageError> {
513    let hex_ok = oid_hex.len() == 64
514        && oid_hex
515            .bytes()
516            .all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'));
517    if !hex_ok {
518        return Err(StorageError::Invalid(format!(
519            "the LFS media oid is not a 64-lowercase-hex sha256 form: {oid_hex:?}"
520        )));
521    }
522    let mut bytes = Vec::with_capacity(1 + 32);
523    bytes.push(alkblobs::Algorithm::GitObjectSha256.byte());
524    let pair_value = |pair: &[u8]| {
525        let hi = (pair[0] as char).to_digit(16).ok_or_else(|| {
526            StorageError::Invalid("the media-family digest half is not hex".to_owned())
527        })?;
528        let lo = (pair[1] as char).to_digit(16).ok_or_else(|| {
529            StorageError::Invalid("the media-family digest half is not hex".to_owned())
530        })?;
531        Ok((hi * 16 + lo) as u8)
532    };
533    for pair in oid_hex.as_bytes().chunks(2) {
534        bytes.push(pair_value(pair)?);
535    }
536    Key::from_bytes(&bytes)
537        .map_err(|e| StorageError::Invalid(format!("the media-family key does not decode: {e}")))
538}
539
540/// The memo of the LFS-media liveness source: the live keys built at
541/// the last mark consult plus the rows surface's write counter at that
542/// build (the arbitration epoch). Held under a short lock only;
543/// `contains` reads it under the store's arbitration lock, the
544/// rebuild replaces it back, and the rebuild itself never holds it.
545#[cfg(feature = "lfs")]
546struct MediaMemo {
547    build_writes: u64,
548    keys: HashSet<Key>,
549}
550
551/// The LFS-media [`alkblobs::LivenessSource`] — the second
552/// alkgit-owned liveness source (substrate.md §GC source 2; ADR-022
553/// §4): the pointer-lifecycle rows as the media family's live set, a
554/// mark consult that reads the rows (no graph walk) and an arbitration
555/// consult that answers from the mark's index. See the module docs'
556/// LFS section for the walk-not-walk posture, the invalidation
557/// decision, and the cross-process residual.
558#[cfg(feature = "lfs")]
559pub struct LfsMedia {
560    rows: Arc<dyn LfsRows>,
561    writes: Arc<AtomicU64>,
562    runtime: tokio::runtime::Handle,
563    memo: parking_lot::Mutex<Option<MediaMemo>>,
564    builds: AtomicU64,
565}
566
567#[cfg(feature = "lfs")]
568impl LfsMedia {
569    /// The source over the rows surface and the composition's shared
570    /// assert counter (the [`VersionedLfsRows`](crate::lfs::VersionedLfsRows)
571    /// wrapper bumps the same counter per committed `assert` through
572    /// it — the bump-after-commit half of the epoch discipline).
573    /// Construct within the running runtime (the assembly does): the
574    /// constructor captures `Handle::current()` for the mark consult's
575    /// bridge.
576    pub fn new(rows: Arc<dyn LfsRows>, writes: Arc<AtomicU64>) -> Arc<Self> {
577        Arc::new(LfsMedia {
578            rows,
579            writes,
580            runtime: tokio::runtime::Handle::current(),
581            memo: parking_lot::Mutex::new(None),
582            builds: AtomicU64::new(0),
583        })
584    }
585
586    /// Register the source with the store — the live-shared seam
587    /// ([`Store::register_liveness_source`](`alkblobs::Store::register_liveness_source`);
588    /// the composition entry's second registration leg, the fail-safe
589    /// default ADR-022 §6: an unregistered media source lets sweeps
590    /// eat referenced media on the first mixed-feature deployment).
591    /// Registration once, at construction; the returned token is
592    /// deregister-at-most-once and is the composition's to keep.
593    pub fn register(
594        self: &Arc<Self>,
595        store: &alkblobs::Store,
596    ) -> Result<alkblobs::Registration, alkblobs::Error> {
597        store.register_liveness_source(alkblobs::LivenessRegistration::InProcess(
598            self.clone() as Arc<dyn alkblobs::LivenessSource>
599        ))
600    }
601
602    /// How many mark consults have run — the rebuild-per-mark posture's
603    /// pin (no fingerprint memo: every mark consult re-reads the rows;
604    /// the arbitration consult never triggers one).
605    pub fn keys_builds(&self) -> u64 {
606        self.builds.load(Ordering::Relaxed)
607    }
608
609    /// The front's async-context bridge, shared posture (the POC-O
610    /// panic shape): parks the worker and serves the rows read through
611    /// the blocking pool; the multi-thread runtime is the floor.
612    fn block_in_place<F>(&self, fut: F) -> F::Output
613    where
614        F: std::future::Future + Send,
615        F::Output: Send,
616    {
617        tokio::task::block_in_place(move || self.runtime.block_on(fut))
618    }
619
620    /// The mark consult's build half — the rows read, the keys formed
621    /// map-free and deduped (one key with N rows is one live key), the
622    /// memo replaced with the fresh epoch. The counter loads
623    /// **before** the rows read (the epoch discipline, [`RefClosure`]'s
624    /// build half): every assert's bump lands *after* its committed
625    /// rows ([`crate::lfs::VersionedLfsRows`]), so a memo's captured
626    /// epoch can never include a bump whose rows the row read missed —
627    /// the poisoned memo that would answer exact-dead for
628    /// just-referenced media. An assert landing between the load and
629    /// the row read has its bump land after the load: the memo's
630    /// epoch reads stale at the subsequent arbitration, which answers
631    /// conservatively-live; an assert landing after the read moves the
632    /// counter past the build's epoch the same way.
633    async fn rebuild_keys(&self) -> Result<Vec<Key>, StorageError> {
634        let build_writes = self.writes.load(Ordering::Relaxed);
635        let rows = self.rows.rows().await?;
636        let mut keys: HashSet<Key> = HashSet::new();
637        for row in &rows {
638            if let Ok(key) = lfs_media_key(&row.oid) {
639                keys.insert(key);
640            }
641        }
642        *(self.memo.lock()) = Some(MediaMemo {
643            build_writes,
644            keys: keys.clone(),
645        });
646        self.builds.fetch_add(1, Ordering::Relaxed);
647        let mut out: Vec<Key> = keys.into_iter().collect();
648        out.sort();
649        Ok(out)
650    }
651}
652
653#[cfg(feature = "lfs")]
654impl alkblobs::LivenessSource for LfsMedia {
655    /// The mark-time consult — rebuild-per-mark (the module docs' LFS
656    /// section; the row read is the whole walk). A failed rows read
657    /// answers empty with the memo untouched: the staged candidates
658    /// arbitrate against the *stale* index (the previous mark's) —
659    /// conservative for keys live at that build, and the next mark
660    /// corrects. The alternative postures (blocking or panicking the
661    /// sweep's mark) are worse.
662    fn live_keys(&self) -> Vec<Key> {
663        self.block_in_place(self.rebuild_keys()).unwrap_or_default()
664    }
665
666    /// The delete-time arbitration consult — under the store's
667    /// arbitration lock, quick, indexed, never blocking (no bridge, no
668    /// reads, no recompute):
669    ///
670    /// - an index hit answers exactly (`true` — the key was live at
671    ///   the last mark);
672    /// - a cold (never-marked) source answers `true` — the safe
673    ///   default (unknown is live); direct deletes under it refuse
674    ///   ([`alkblobs::Error::GcRefuse`], the ADR-012 §6.4 flow);
675    /// - an index miss after a moved assert counter answers `true`
676    ///   too — a row may have landed since the build (unknown is
677    ///   live): the candidate cancels and resolves on the next mark's
678    ///   rebuild, one delayed sweep.
679    fn contains(&self, key: &Key) -> bool {
680        let memo = self.memo.lock();
681        let Some(memo) = memo.as_ref() else {
682            return true;
683        };
684        if memo.keys.contains(key) {
685            return true;
686        }
687        self.writes.load(Ordering::Relaxed) != memo.build_writes
688    }
689}
690
691#[cfg(test)]
692mod tests {
693    use alkblobs::Store;
694    use alkgit::{GitRefs, RefUpdate};
695    use gix_hash::ObjectId;
696    use gix_object::Kind;
697    use std::sync::atomic::AtomicU64;
698    use std::sync::Arc;
699
700    use crate::odb::{key_to_oid, ObjectFront};
701    use crate::refs::InMemoryRefTable;
702    use crate::testutil::private_record as repo;
703    #[cfg(feature = "sqlite")]
704    use crate::testutil::scratch_dir;
705
706    use super::*;
707
708    fn writes() -> Arc<AtomicU64> {
709        Arc::new(AtomicU64::new(0))
710    }
711
712    /// A multi_thread runtime is the floor (the block_in_place
713    /// posture); every live-verb-driving test runs under it.
714    fn mem_fixture() -> (ObjectFront, Arc<InMemoryRefTable>, Arc<AtomicU64>) {
715        let front = ObjectFront::new(Arc::new(Store::mem_only()));
716        (front, Arc::new(InMemoryRefTable::new()), writes())
717    }
718
719    async fn put_object(front: &ObjectFront, kind: Kind, content: &[u8]) -> ObjectId {
720        let (key, _pin) = front.put(kind, content).await.unwrap();
721        key_to_oid(&key).expect("the put's key is the family key")
722    }
723
724    async fn create_ref(table: &dyn GitRefs, id: &str, name: &str, oid: ObjectId) {
725        let outcomes = table
726            .apply_updates(
727                &repo(id),
728                vec![RefUpdate {
729                    name: name.to_owned(),
730                    expected: None,
731                    new: Some(oid),
732                }],
733                false,
734            )
735            .await
736            .unwrap();
737        assert!(outcomes.iter().all(|o| o.result.is_ok()));
738    }
739
740    fn blob_content() -> &'static [u8] {
741        b"hello\n"
742    }
743
744    enum TreeEntry<'a> {
745        Blob(&'a str, &'a ObjectId),
746        Link(&'a str, &'a ObjectId),
747        Gitlink(&'a str, &'a ObjectId),
748    }
749
750    fn tree_bytes(entries: &[TreeEntry<'_>]) -> Vec<u8> {
751        let mode_of = |e: &TreeEntry<'_>| match e {
752            TreeEntry::Blob(..) => "100644 ",
753            TreeEntry::Link(..) => "120000 ",
754            TreeEntry::Gitlink(..) => "160000 ",
755        };
756        let mut tree = Vec::new();
757        let mut sorted: Vec<(&str, &str, &ObjectId)> = entries
758            .iter()
759            .map(|e| match e {
760                TreeEntry::Blob(n, o) | TreeEntry::Link(n, o) | TreeEntry::Gitlink(n, o) => {
761                    (mode_of(e), *n, *o)
762                }
763            })
764            .collect();
765        sorted.sort();
766        for (mode, name, oid) in sorted {
767            tree.extend_from_slice(mode.as_bytes());
768            tree.extend_from_slice(name.as_bytes());
769            tree.push(0);
770            tree.extend_from_slice(oid.as_bytes());
771        }
772        tree
773    }
774
775    fn commit_bytes(tree: &ObjectId, parents: &[&ObjectId]) -> Vec<u8> {
776        let mut body = format!("tree {tree}\n");
777        for parent in parents {
778            body.push_str(&format!("parent {parent}\n"));
779        }
780        body.push_str(
781            "author A <a@example.invalid> 1750000000 +0000\n\
782             committer A <a@example.invalid> 1750000000 +0000\n\n",
783        );
784        body.into_bytes()
785    }
786
787    fn tag_bytes(target: &ObjectId, target_kind: Kind) -> Vec<u8> {
788        format!(
789            "object {target}\n\
790             type {target_kind}\n\
791             tag v1\n\
792             tagger A <a@example.invalid> 1750000000 +0000\n\nmessage\n"
793        )
794        .into_bytes()
795    }
796
797    /// A two-commit ancestry with one tree/blob, a tag on the tip,
798    /// and gitlink + a second blob in a separate tree (the gitlink
799    /// mode shape).
800    async fn fixture_graph(front: &ObjectFront) -> GraphFixture {
801        let blob = put_object(front, Kind::Blob, blob_content()).await;
802        let blob2 = put_object(front, Kind::Blob, b"other\n").await;
803        let tree = front
804            .put(Kind::Tree, &tree_bytes(&[TreeEntry::Blob("hello", &blob)]))
805            .await
806            .map(|(key, _)| key_to_oid(&key).unwrap())
807            .unwrap();
808        let gitlink_subtree_oid = front
809            .put(Kind::Tree, &tree_bytes(&[TreeEntry::Blob("g", &blob)]))
810            .await
811            .map(|(key, _)| key_to_oid(&key).unwrap())
812            .unwrap();
813        let gitlink_target = put_object(
814            front,
815            Kind::Commit,
816            &commit_bytes(&gitlink_subtree_oid, &[]),
817        )
818        .await;
819        let gitlink_tree = front
820            .put(
821                Kind::Tree,
822                &tree_bytes(&[
823                    TreeEntry::Blob("other", &blob2),
824                    TreeEntry::Link("sym", &blob2),
825                    TreeEntry::Gitlink("sub", &gitlink_target),
826                ]),
827            )
828            .await
829            .map(|(key, _)| key_to_oid(&key).unwrap())
830            .unwrap();
831        let base = front
832            .put(Kind::Commit, &commit_bytes(&tree, &[]))
833            .await
834            .map(|(key, _)| key_to_oid(&key).unwrap())
835            .unwrap();
836        let tip = front
837            .put(Kind::Commit, &commit_bytes(&tree, &[&base]))
838            .await
839            .map(|(key, _)| key_to_oid(&key).unwrap())
840            .unwrap();
841        let tag = front
842            .put(Kind::Tag, &tag_bytes(&tip, Kind::Commit))
843            .await
844            .map(|(key, _)| key_to_oid(&key).unwrap())
845            .unwrap();
846        GraphFixture {
847            blob,
848            blob2,
849            tree,
850            base,
851            tip,
852            tag,
853            gitlink_target,
854            gitlink_tree,
855        }
856    }
857
858    #[derive(Clone, Copy)]
859    struct GraphFixture {
860        blob: ObjectId,
861        blob2: ObjectId,
862        tree: ObjectId,
863        base: ObjectId,
864        tip: ObjectId,
865        tag: ObjectId,
866        gitlink_target: ObjectId,
867        gitlink_tree: ObjectId,
868    }
869
870    /// The minimal covered closure — blob, tree, base commit, tip
871    /// commit; no tag, no gitlink shapes. The sweep-focused tests'
872    /// fixture (every pooled object accounted for in the assertions).
873    async fn covered_fixture(front: &ObjectFront) -> GraphFixture {
874        let blob = put_object(front, Kind::Blob, blob_content()).await;
875        let tree = front
876            .put(Kind::Tree, &tree_bytes(&[TreeEntry::Blob("hello", &blob)]))
877            .await
878            .map(|(key, _)| key_to_oid(&key).unwrap())
879            .unwrap();
880        let base = front
881            .put(Kind::Commit, &commit_bytes(&tree, &[]))
882            .await
883            .map(|(key, _)| key_to_oid(&key).unwrap())
884            .unwrap();
885        let tip = front
886            .put(Kind::Commit, &commit_bytes(&tree, &[&base]))
887            .await
888            .map(|(key, _)| key_to_oid(&key).unwrap())
889            .unwrap();
890        let oid = ObjectId::from([0u8; 20]);
891        GraphFixture {
892            blob,
893            blob2: oid,
894            tree,
895            base,
896            tip,
897            tag: oid,
898            gitlink_target: oid,
899            gitlink_tree: oid,
900        }
901    }
902
903    async fn live_oids(source: &Arc<RefClosure>) -> Vec<ObjectId> {
904        let mut oids: Vec<ObjectId> = alkblobs::LivenessSource::live_keys(source.as_ref())
905            .iter()
906            .filter_map(key_to_oid)
907            .collect();
908        oids.sort();
909        oids
910    }
911
912    fn sorted(mut oids: Vec<ObjectId>) -> Vec<ObjectId> {
913        oids.sort();
914        oids
915    }
916
917    /// The full closure walk: two repos, commits (parents included),
918    /// trees/blobs, a peeled tag, gitlink entries neither marked nor
919    /// walked, the symref row contributing nothing, and the keys
920    /// map-free (`[0x03] + oid`).
921    #[tokio::test(flavor = "multi_thread")]
922    async fn the_closure_walk_covers_the_full_graph() {
923        let (front, refs, writes) = mem_fixture();
924        let fix = fixture_graph(&front).await;
925        create_ref(refs.as_ref(), "r/one", "refs/heads/main", fix.tip).await;
926        create_ref(refs.as_ref(), "r/one", "refs/tags/v1", fix.tag).await;
927        create_ref(refs.as_ref(), "r/two", "refs/heads/wip", fix.gitlink_tree).await;
928        let orphan = put_object(&front, Kind::Blob, b"orphan\n").await;
929
930        // A symref row in the snapshot (the seeded unborn HEAD shape):
931        // it contributes nothing to the closure — and its target's own
932        // row (r/one's main) covers the object when it exists.
933        let seeded_rows = Arc::new(SymrefSeededRows::new(refs.clone()));
934        seeded_rows.seed_head();
935        let rows: Arc<dyn RefRows> = seeded_rows;
936        let source = RefClosure::new(front.clone(), rows, writes);
937        assert_eq!(source.keys_builds(), 0, "construction walks nothing");
938
939        let oids = live_oids(&source).await;
940        let mut expected = sorted(vec![
941            fix.blob,
942            fix.blob2,
943            fix.tree,
944            fix.base,
945            fix.tip,
946            fix.tag,
947            fix.gitlink_tree,
948        ]);
949        expected.dedup();
950        assert_eq!(
951            oids, expected,
952            "the closure covers the commit ancestry (parents included), the trees and blobs, and the peeled tag"
953        );
954        assert!(
955            !oids.contains(&fix.gitlink_target),
956            "a gitlink entry neither marks nor walks (the other repo's refs cover it)"
957        );
958        assert!(!oids.contains(&orphan), "an unreachable object is not live");
959        assert_eq!(source.keys_builds(), 1);
960
961        // Map-free keys: every live key carries the family byte.
962        for key in alkblobs::LivenessSource::live_keys(source.as_ref()) {
963            assert_eq!(key.as_bytes()[0], 0x03, "the sha1 family byte");
964        }
965    }
966
967    /// The refs-rows seam standing in front of the CAS table to seed
968    /// a HEAD symref row (the unborn shape the tables' CAS cannot
969    /// write): the snapshot surfaces it, and the walk skips it.
970    struct SymrefSeededRows {
971        inner: Arc<InMemoryRefTable>,
972        head: parking_lot::Mutex<Option<RefRow>>,
973    }
974
975    impl SymrefSeededRows {
976        fn new(inner: Arc<InMemoryRefTable>) -> Self {
977            SymrefSeededRows {
978                inner,
979                head: parking_lot::Mutex::new(None),
980            }
981        }
982
983        fn seed_head(self: &Arc<Self>) {
984            *(self.head.lock()) = Some(RefRow {
985                repo_id: "r/one".to_owned(),
986                name: "HEAD".to_owned(),
987                state: RefRowState::Symref("refs/heads/main".to_owned()),
988            });
989        }
990    }
991
992    #[async_trait]
993    impl RefRows for SymrefSeededRows {
994        async fn snapshot(&self) -> Result<RefRowsSnapshot, StorageError> {
995            let mut rows = self.inner.snapshot().await?.rows;
996            if let Some(head) = self.head.lock().clone() {
997                rows.push(head);
998            }
999            rows.sort_by(|a, b| (&a.repo_id, &a.name).cmp(&(&b.repo_id, &b.name)));
1000            Ok(RefRowsSnapshot {
1001                fingerprint: fingerprint_rows(&rows),
1002                rows,
1003            })
1004        }
1005    }
1006
1007    /// Orphan reclamation end-to-end at the source level: objects
1008    /// unreachable from any ref are absent from `live_keys`, the
1009    /// sweep consumes them (no `NoLivenessSources` abort), and the
1010    /// closure's objects survive.
1011    #[tokio::test(flavor = "multi_thread")]
1012    async fn the_sweep_reclaims_orphans_when_the_source_is_registered() {
1013        let (front, refs, writes) = mem_fixture();
1014        let fix = covered_fixture(&front).await;
1015        create_ref(refs.as_ref(), "repo", "refs/heads/main", fix.tip).await;
1016        let rows: Arc<dyn RefRows> = refs.clone();
1017
1018        let orphan_one = put_object(&front, Kind::Blob, b"orphan one\n").await;
1019        let orphan_two = put_object(&front, Kind::Blob, b"orphan two\n").await;
1020
1021        let store = front.store().clone();
1022        let source = RefClosure::new(front.clone(), rows, writes);
1023        source.register(&store).unwrap();
1024
1025        let report = store.sweep().await.unwrap();
1026        assert!(
1027            report.aborted.is_none(),
1028            "a registered source never trips NoLivenessSources"
1029        );
1030        assert_eq!(
1031            report.deleted, 2,
1032            "both orphans reclaimed (the pool: the covered closure + the two orphans)"
1033        );
1034        assert!(front.stat(&orphan_one).await.unwrap().is_none(), "gone");
1035        assert!(front.stat(&orphan_two).await.unwrap().is_none(), "gone");
1036        for live in [fix.tip, fix.base, fix.tree, fix.blob] {
1037            assert!(
1038                front.stat(&live).await.unwrap().is_some(),
1039                "the closure stays"
1040            );
1041        }
1042    }
1043
1044    /// A ref delete uncovers its closure: the formerly-covered set is
1045    /// gone from `live_keys` after the fingerprint moves, and the
1046    /// sweep reclaims it (the repo-deletion-is-GC shape, at the
1047    /// source level — the wave-7 chain's sweep consumes it).
1048    #[tokio::test(flavor = "multi_thread")]
1049    async fn a_ref_delete_uncovers_its_closure_for_the_sweep() {
1050        let (front, refs, writes) = mem_fixture();
1051        let fix = fixture_graph(&front).await;
1052        create_ref(refs.as_ref(), "repo", "refs/heads/main", fix.tip).await;
1053        let rows: Arc<dyn RefRows> = refs.clone();
1054
1055        let store = front.store().clone();
1056        let source = RefClosure::new(front.clone(), rows, writes);
1057        source.register(&store).unwrap();
1058
1059        let first = alkblobs::LivenessSource::live_keys(source.as_ref()).len();
1060        assert_eq!(first, 4, "tip, base, tree, blob — the covered closure");
1061        for live in [fix.tip, fix.base, fix.tree, fix.blob] {
1062            assert!(front.stat(&live).await.unwrap().is_some());
1063        }
1064
1065        let outcomes = refs
1066            .apply_updates(
1067                &repo("repo"),
1068                vec![RefUpdate {
1069                    name: "refs/heads/main".to_owned(),
1070                    expected: Some(fix.tip),
1071                    new: None,
1072                }],
1073                false,
1074            )
1075            .await
1076            .unwrap();
1077        assert!(outcomes.iter().all(|o| o.result.is_ok()));
1078
1079        let after = alkblobs::LivenessSource::live_keys(source.as_ref());
1080        assert_eq!(
1081            after.len(),
1082            0,
1083            "the delete-uncovered closure leaves the live set after the rebuild"
1084        );
1085
1086        let report = store.sweep().await.unwrap();
1087        assert!(report.aborted.is_none());
1088        for gone in [fix.tip, fix.base, fix.tree, fix.blob] {
1089            assert!(front.stat(&gone).await.unwrap().is_none(), "reclaimed");
1090        }
1091    }
1092
1093    /// The memoization: an unchanged rows fingerprint consumes no
1094    /// walk; a changed one rebuilds; a counter bump alone (a denied
1095    /// CAS call) does not.
1096    #[tokio::test(flavor = "multi_thread")]
1097    async fn the_memo_is_keyed_on_the_rows_fingerprint() {
1098        let (front, refs, writes) = mem_fixture();
1099        let fix = fixture_graph(&front).await;
1100        create_ref(refs.as_ref(), "repo", "refs/heads/main", fix.tip).await;
1101        let rows: Arc<dyn RefRows> = refs.clone();
1102        let source = RefClosure::new(front.clone(), rows, writes);
1103
1104        alkblobs::LivenessSource::live_keys(source.as_ref());
1105        assert_eq!(source.keys_builds(), 1, "the first consult walks");
1106
1107        alkblobs::LivenessSource::live_keys(source.as_ref());
1108        assert_eq!(
1109            source.keys_builds(),
1110            1,
1111            "an unchanged fingerprint consumes no walk"
1112        );
1113
1114        // A counter bump without a row change (a denied call's bump)
1115        // does not rebuild — the fingerprint is the rows.
1116        source.writes.fetch_add(1, Ordering::Relaxed);
1117        let memoized = alkblobs::LivenessSource::live_keys(source.as_ref()).len();
1118        assert_eq!(memoized, 4, "the memo answers");
1119        assert_eq!(
1120            source.keys_builds(),
1121            1,
1122            "a counter bump alone does not rebuild"
1123        );
1124
1125        // A row change (the same write, landed) moves the fingerprint:
1126        // rebuild.
1127        create_ref(refs.as_ref(), "repo", "refs/heads/topic", fix.base).await;
1128        alkblobs::LivenessSource::live_keys(source.as_ref());
1129        assert_eq!(source.keys_builds(), 2, "a fingerprint move rebuilds");
1130        let oids = live_oids(&source).await;
1131        assert_eq!(oids, sorted(vec![fix.tip, fix.base, fix.tree, fix.blob]));
1132    }
1133
1134    /// The arbitration consult's discipline: indexed, quick, never
1135    /// deriving — exact on index hits, safe-default on a cold memo,
1136    /// unknown-safe after an in-process write, and the counter-driven
1137    /// answer never triggers a rebuild.
1138    #[tokio::test(flavor = "multi_thread")]
1139    async fn the_contains_consult_never_derives() {
1140        let (front, refs, writes) = mem_fixture();
1141        let fix = fixture_graph(&front).await;
1142        create_ref(refs.as_ref(), "repo", "refs/heads/main", fix.tip).await;
1143        let rows: Arc<dyn RefRows> = refs.clone();
1144        let source = RefClosure::new(front.clone(), rows, writes);
1145
1146        let tip_key = oid_key(&fix.tip).unwrap();
1147        let absent_key = oid_key(&ObjectId::from([1u8; 20])).unwrap();
1148        assert!(
1149            alkblobs::LivenessSource::contains(source.as_ref(), &tip_key),
1150            "a cold memo answers live — the safe default"
1151        );
1152        assert_eq!(source.keys_builds(), 0, "contains never derives");
1153        assert!(alkblobs::LivenessSource::contains(
1154            source.as_ref(),
1155            &absent_key
1156        ));
1157
1158        alkblobs::LivenessSource::live_keys(source.as_ref());
1159        assert_eq!(source.keys_builds(), 1);
1160        assert!(alkblobs::LivenessSource::contains(
1161            source.as_ref(),
1162            &tip_key
1163        ));
1164        assert!(
1165            !alkblobs::LivenessSource::contains(source.as_ref(), &absent_key),
1166            "a built memo answers the index exactly"
1167        );
1168
1169        // An in-process write moves the counter: unknown → live, no rebuild.
1170        source.writes.fetch_add(1, Ordering::Relaxed);
1171        assert!(
1172            alkblobs::LivenessSource::contains(source.as_ref(), &absent_key),
1173            "a moved write counter answers conservatively (unknown is live)"
1174        );
1175        assert_eq!(
1176            source.keys_builds(),
1177            1,
1178            "the arbitration consult never triggers the walk"
1179        );
1180
1181        // The next mark rebuilds and the counter catches up at the
1182        // memo's build: exact answers resume.
1183        alkblobs::LivenessSource::live_keys(source.as_ref());
1184        assert!(!alkblobs::LivenessSource::contains(
1185            source.as_ref(),
1186            &absent_key
1187        ));
1188        assert!(alkblobs::LivenessSource::contains(
1189            source.as_ref(),
1190            &tip_key
1191        ));
1192    }
1193
1194    /// The walk is best-effort: a ref tip with no object and a commit
1195    /// with a missing parent each stop their branch and never fail
1196    /// the consult (the corruption class rides the odb suite's
1197    /// verification tests — the same read legs).
1198    #[tokio::test(flavor = "multi_thread")]
1199    async fn the_walk_degrades_best_effort_on_missing_nodes() {
1200        let (front, refs, writes) = mem_fixture();
1201        let blob = put_object(&front, Kind::Blob, blob_content()).await;
1202        let tree = front
1203            .put(Kind::Tree, &tree_bytes(&[TreeEntry::Blob("hello", &blob)]))
1204            .await
1205            .map(|(key, _)| key_to_oid(&key).unwrap())
1206            .unwrap();
1207        let missing_parent = ObjectId::from([9u8; 20]);
1208        let tip = front
1209            .put(Kind::Commit, &commit_bytes(&tree, &[&missing_parent]))
1210            .await
1211            .map(|(key, _)| key_to_oid(&key).unwrap())
1212            .unwrap();
1213        let dangling = ObjectId::from([5u8; 20]);
1214        create_ref(refs.as_ref(), "repo", "refs/heads/main", tip).await;
1215        create_ref(refs.as_ref(), "repo", "refs/heads/dangling", dangling).await;
1216
1217        let rows: Arc<dyn RefRows> = refs.clone();
1218        let source = RefClosure::new(front.clone(), rows, writes);
1219        let oids = live_oids(&source).await;
1220        assert_eq!(oids, sorted(vec![tip, tree, blob]));
1221    }
1222
1223    /// The postures pinned by odb's bridge tests, re-pinned at the
1224    /// source: the live verb bridges with `block_in_place` — a
1225    /// current-thread runtime panics (the multi-thread runtime is
1226    /// the floor).
1227    #[tokio::test]
1228    #[should_panic]
1229    async fn the_live_verb_panics_on_a_current_thread_runtime() {
1230        let (front, refs, writes) = mem_fixture();
1231        let tip = put_object(&front, Kind::Blob, b"tip\n").await;
1232        create_ref(refs.as_ref(), "repo", "refs/heads/main", tip).await;
1233        let rows: Arc<dyn RefRows> = refs.clone();
1234        let source = RefClosure::new(front, rows, writes);
1235        alkblobs::LivenessSource::live_keys(source.as_ref());
1236    }
1237
1238    /// The FNV fingerprint's determinism and sensitivity: equal rows
1239    /// fingerprint equal across calls; any row change moves it
1240    /// (name, oid, state, count); the canonical fingerprint is
1241    /// order-normalized (the impls pass sorted rows).
1242    #[test]
1243    fn the_fingerprint_is_deterministic_and_sensitive() {
1244        let row = |name: &str, oid: u8| RefRow {
1245            repo_id: "repo".to_owned(),
1246            name: name.to_owned(),
1247            state: RefRowState::Oid(ObjectId::from([oid; 20])),
1248        };
1249        let rows = vec![row("a", 1), row("b", 2)];
1250        let again = vec![row("a", 1), row("b", 2)];
1251        assert_eq!(fingerprint_rows(&rows), fingerprint_rows(&again));
1252
1253        let moved_oid = vec![row("a", 1), row("b", 3)];
1254        let moved_name = vec![row("a", 1), row("c", 2)];
1255        let reordered = vec![row("b", 2), row("a", 1)];
1256        assert_ne!(fingerprint_rows(&rows), fingerprint_rows(&moved_oid));
1257        assert_ne!(fingerprint_rows(&rows), fingerprint_rows(&moved_name));
1258        assert_eq!(
1259            fingerprint_rows(&rows),
1260            fingerprint_rows(&reordered),
1261            "the canonical fingerprint is order-normalized"
1262        );
1263
1264        let symref = RefRow {
1265            repo_id: "repo".to_owned(),
1266            name: "HEAD".to_owned(),
1267            state: RefRowState::Symref("refs/heads/main".to_owned()),
1268        };
1269        let with_symref = vec![row("a", 1), symref];
1270        assert_ne!(fingerprint_rows(&rows), fingerprint_rows(&with_symref));
1271        assert_ne!(fingerprint_rows(&[]), fingerprint_rows(&[row("a", 1)]));
1272    }
1273
1274    /// The sqlite twin's snapshot seam: rows arrive in canonical
1275    /// order with the fingerprint over them; an unchanged table
1276    /// fingerprints equal, a row change moves it.
1277    #[cfg(feature = "sqlite")]
1278    #[tokio::test(flavor = "multi_thread")]
1279    async fn the_sqlite_rows_seam_snapshots_canonically() {
1280        use crate::migrations::CoTenantSqlite;
1281        use crate::refs::SqliteRefTable;
1282
1283        let dir = scratch_dir("liveness", "rows");
1284        let handle = CoTenantSqlite::open(dir.join("rows.sqlite3")).unwrap();
1285        let table = Arc::new(SqliteRefTable::new(handle));
1286        let tip = ObjectId::from([4u8; 20]);
1287        create_ref(table.as_ref(), "repo", "refs/heads/main", tip).await;
1288        create_ref(table.as_ref(), "other", "refs/heads/wip", tip).await;
1289
1290        let rows: Arc<dyn RefRows> = table.clone();
1291        let first = rows.snapshot().await.unwrap();
1292        assert_eq!(first.rows.len(), 2);
1293        assert_eq!(first.rows[0].repo_id, "other", "repo id sorts first");
1294        assert_eq!(first.rows[1].repo_id, "repo");
1295        assert_eq!(first.fingerprint, fingerprint_rows(&first.rows));
1296
1297        let second = rows.snapshot().await.unwrap();
1298        assert_eq!(
1299            first.fingerprint, second.fingerprint,
1300            "an unchanged table fingerprints equal"
1301        );
1302        create_ref(table.as_ref(), "repo", "refs/heads/topic", tip).await;
1303        let third = rows.snapshot().await.unwrap();
1304        assert_ne!(third.fingerprint, second.fingerprint);
1305        std::fs::remove_dir_all(&dir).unwrap_or_default();
1306    }
1307
1308    /// The versioned wrapper forwards the snapshot and bumps per
1309    /// **green** CAS call — the bump follows the committed rows (the
1310    /// epoch discipline's bump-after-commit half; a per-ref denial
1311    /// still returns a green call, so it bumps — over-invalidation,
1312    /// the safe direction — while an `Err` call bumps nothing: no
1313    /// rows landed); the assembly wiring test proves the composition
1314    /// path (sqlite.rs).
1315    #[cfg(feature = "sqlite")]
1316    #[tokio::test(flavor = "multi_thread")]
1317    async fn the_versioned_wrapper_forwards_and_bumps() {
1318        use crate::migrations::CoTenantSqlite;
1319        use crate::refs::{SqliteRefTable, VersionedRefTable};
1320
1321        let dir = scratch_dir("liveness", "wrapper");
1322        let handle = CoTenantSqlite::open(dir.join("wrapper.sqlite3")).unwrap();
1323        let inner = Arc::new(SqliteRefTable::new(handle));
1324        let counter = Arc::new(AtomicU64::new(0));
1325        let wrapper = Arc::new(VersionedRefTable::new(inner.clone(), counter.clone()));
1326        assert_eq!(counter.load(Ordering::Relaxed), 0, "listing does not bump");
1327
1328        let tip = ObjectId::from([4u8; 20]);
1329        wrapper
1330            .apply_updates(
1331                &repo("repo"),
1332                vec![RefUpdate {
1333                    name: "refs/heads/main".to_owned(),
1334                    expected: None,
1335                    new: Some(tip),
1336                }],
1337                false,
1338            )
1339            .await
1340            .unwrap();
1341        assert_eq!(
1342            counter.load(Ordering::Relaxed),
1343            1,
1344            "an applied CAS call bumps the counter"
1345        );
1346        let rows: Arc<dyn RefRows> = wrapper.clone();
1347        assert_eq!(
1348            rows.snapshot().await.unwrap().rows.len(),
1349            1,
1350            "the snapshot forwards through the wrapper"
1351        );
1352
1353        let denied = wrapper
1354            .apply_updates(
1355                &repo("repo"),
1356                vec![RefUpdate {
1357                    name: "refs/heads/main".to_owned(),
1358                    expected: Some(ObjectId::from([9u8; 20])),
1359                    new: Some(tip),
1360                }],
1361                false,
1362            )
1363            .await
1364            .unwrap();
1365        assert!(denied.iter().all(|o| o.result.is_err()));
1366        assert_eq!(
1367            counter.load(Ordering::Relaxed),
1368            2,
1369            "a green call bumps (the bump follows the committed rows; the denials ride the outcomes)"
1370        );
1371        std::fs::remove_dir_all(&dir).unwrap_or_default();
1372    }
1373
1374    /// The end-to-end wiring over the default engine composition:
1375    /// the sqlite assembly's co-tenancy (the alkstore + alkblobs
1376    /// stores, the co-tenant twins — one file), the object front, the
1377    /// versioned refs (the composition's `GitRefs` impl — the pushes'
1378    /// path), the registered `RefClosure`, and the sweep consuming
1379    /// it. Orphans reclaim across the zero-length durable window; a
1380    /// ref create covering a staged candidate cancels it (the
1381    /// arbitration window's counter answer — one delayed sweep); a
1382    /// ref delete uncovers and the closure reclaims.
1383    #[cfg(feature = "sqlite")]
1384    #[tokio::test(flavor = "multi_thread")]
1385    async fn the_composition_wiring_serves_the_sweep_end_to_end() {
1386        use std::time::Duration;
1387
1388        use alkblobs::StoreMode;
1389        use alkgit::GitRegistryStore as _;
1390
1391        use crate::sqlite::SqliteRegistryAssembly;
1392
1393        let dir = scratch_dir("liveness", "wiring");
1394        let db = dir.join("composition.sqlite3");
1395        let assembly = SqliteRegistryAssembly::open(&db).await.unwrap();
1396        let record = repo("owner/wired");
1397
1398        let store = Arc::new(
1399            Store::kv_only_sqlite(&db, 128 * 1024, Duration::ZERO)
1400                .await
1401                .unwrap(),
1402        );
1403        assert_eq!(store.mode(), StoreMode::KvOnly);
1404        let front = ObjectFront::new(store.clone());
1405
1406        // The wiring: rows and the write counter come straight from
1407        // the assembly's versioned refs — one handle for both.
1408        let rows: Arc<dyn RefRows> = assembly.refs().clone();
1409        let source = RefClosure::new(front.clone(), rows, assembly.refs_version().clone());
1410        source.register(&store).unwrap();
1411
1412        // `put_repo` seeds the unborn HEAD symref row — part of the
1413        // snapshot, contributes nothing (the unborn shape).
1414        assembly.backend().put_repo(&record).await.unwrap();
1415
1416        let covered = put_object(&front, Kind::Blob, b"covered\n").await;
1417        let orphan = put_object(&front, Kind::Blob, b"orphan\n").await;
1418        assert!(
1419            front.get(&covered).await.unwrap().is_some()
1420                && front.get(&orphan).await.unwrap().is_some(),
1421            "both land in the pool"
1422        );
1423
1424        // Sweep 1 — mark with both objects uncovered: the durable
1425        // form stages both (the SQLite-hosted window; the zero-length
1426        // delay makes staged candidates overdue at the next sweep).
1427        let first = store.sweep().await.unwrap();
1428        assert!(first.aborted.is_none());
1429        assert_eq!(first.candidates_staged, 2, "both orphans stage");
1430        assert_eq!(first.deleted, 0);
1431        assert!(
1432            front.get(&orphan).await.unwrap().is_some(),
1433            "staged, not deleted"
1434        );
1435        assert!(front.get(&covered).await.unwrap().is_some());
1436
1437        // The push path lands the creation through the composition's
1438        // versioned refs — the counter bumps and the coverage moves.
1439        assembly
1440            .refs()
1441            .apply_updates(
1442                &record,
1443                vec![RefUpdate {
1444                    name: "refs/heads/main".to_owned(),
1445                    expected: None,
1446                    new: Some(covered),
1447                }],
1448                false,
1449            )
1450            .await
1451            .unwrap();
1452
1453        // Sweep 2 — the executor's re-arbitration consults `contains`;
1454        // the counter moved past the memo's build (unknown is live),
1455        // so BOTH staged candidates cancel — the pushed object cannot
1456        // be deleted here, and the over-conservative answer holds the
1457        // still-uncovered orphan for one sweep too. The mark rebuilds
1458        // (the rows moved; the memo's epoch catches up) and re-stages
1459        // the still-uncovered orphan.
1460        let second = store.sweep().await.unwrap();
1461        assert!(second.aborted.is_none());
1462        assert_eq!(second.deleted, 0);
1463        assert_eq!(
1464            second.cancelled_by_arbitration, 2,
1465            "the push-path bump cancels the staged candidates"
1466        );
1467        assert_eq!(second.candidates_staged, 1);
1468        assert!(front.get(&orphan).await.unwrap().is_some());
1469        assert!(front.get(&covered).await.unwrap().is_some());
1470
1471        // Sweep 3 — the memo now keys on the post-create counter and
1472        // rows: the orphan's consult answers exactly, and it is gone.
1473        let third = store.sweep().await.unwrap();
1474        assert!(third.aborted.is_none());
1475        assert_eq!(
1476            third.deleted, 1,
1477            "the orphan reclaims once the rebuild caught up"
1478        );
1479        assert!(front.get(&orphan).await.unwrap().is_none());
1480        assert!(front.get(&covered).await.unwrap().is_some());
1481
1482        // The ref delete uncovers the closure: sweep 4's mark rebuilds
1483        // (the rows moved) and stages it; sweep 5's consult answers
1484        // exactly (the epoch already caught up) and the closure
1485        // reclaims — the repo-deletion-is-GC shape through the full
1486        // chain.
1487        assembly
1488            .refs()
1489            .apply_updates(
1490                &record,
1491                vec![RefUpdate {
1492                    name: "refs/heads/main".to_owned(),
1493                    expected: Some(covered),
1494                    new: None,
1495                }],
1496                false,
1497            )
1498            .await
1499            .unwrap();
1500        let fourth = store.sweep().await.unwrap();
1501        assert!(fourth.aborted.is_none());
1502        assert_eq!(fourth.candidates_staged, 1, "the uncovered closure stages");
1503        assert_eq!(fourth.deleted, 0);
1504        assert!(
1505            front.get(&covered).await.unwrap().is_some(),
1506            "still in the window"
1507        );
1508        let fifth = store.sweep().await.unwrap();
1509        assert_eq!(
1510            fifth.deleted, 1,
1511            "the overdue uncovered closure reclaims (the epoch already caught up at the delete-time mark)"
1512        );
1513        assert!(front.get(&covered).await.unwrap().is_none());
1514
1515        std::fs::remove_dir_all(&dir).unwrap_or_default();
1516    }
1517}
1518
1519/// The LFS media source's pins (the `lfs` feature): map-free keys, the
1520/// rebuild-per-mark row read, the arbitration consult's discipline,
1521/// and the composition wiring's durable-window sweep contract over the
1522/// sqlite rows twin (the mem shape's end-to-end rows ride the
1523/// composition entry's tests in [`crate::compose`]; the pg rows on the
1524/// live-pg harness ride the same entry pattern there).
1525#[cfg(all(test, feature = "lfs"))]
1526mod media_tests {
1527    use std::sync::atomic::AtomicU64;
1528    use std::sync::Arc;
1529
1530    use alkblobs::{Algorithm, Store};
1531    use alkgit::{GitRefs, RefUpdate, RepoRecord};
1532    use gix_hash::ObjectId;
1533    use gix_object::Kind;
1534    use std::io::Cursor;
1535
1536    use crate::lfs::InMemoryLfsRows;
1537    use crate::liveness::{lfs_media_key, oid_key, LfsMedia};
1538    use crate::odb::{key_to_oid, ObjectFront};
1539    use crate::refs::InMemoryRefTable;
1540    use crate::testutil::private_record as repo;
1541    #[cfg(feature = "sqlite")]
1542    use crate::testutil::scratch_dir;
1543
1544    use super::*;
1545
1546    async fn put_object(front: &ObjectFront, kind: Kind, content: &[u8]) -> ObjectId {
1547        let (key, _pin) = front.put(kind, content).await.unwrap();
1548        key_to_oid(&key).expect("the put's key is the family key")
1549    }
1550
1551    fn writes() -> Arc<AtomicU64> {
1552        Arc::new(AtomicU64::new(0))
1553    }
1554
1555    fn media_fixture() -> (Arc<Store>, Arc<dyn crate::lfs::LfsRows>, Arc<AtomicU64>) {
1556        let store = Arc::new(Store::mem_only());
1557        let counter = writes();
1558        let rows: Arc<dyn crate::lfs::LfsRows> = Arc::new(crate::lfs::VersionedLfsRows::new(
1559            Arc::new(InMemoryLfsRows::new()),
1560            Arc::clone(&counter),
1561        ));
1562        (store, rows, counter)
1563    }
1564
1565    /// Put one LFS media entry under its own family: the store derives
1566    /// the key (the facade seam's put), the oid read off it is the
1567    /// digest's hex — the row store's oid shape.
1568    async fn put_media(store: &Store, content: &[u8]) -> (String, Key) {
1569        let (key, _pin) = store
1570            .put_as(
1571                Algorithm::GitObjectSha256,
1572                Some(content.len() as u64),
1573                Cursor::new(content),
1574            )
1575            .await
1576            .unwrap();
1577        let hex: String = key
1578            .digest()
1579            .bytes()
1580            .iter()
1581            .map(|b| format!("{b:02x}"))
1582            .collect();
1583        (hex, key)
1584    }
1585
1586    fn contains_of(source: &Arc<LfsMedia>, key: &Key) -> bool {
1587        alkblobs::LivenessSource::contains(source.as_ref(), key)
1588    }
1589
1590    /// The map-free formation: the put's derived key is re-formable
1591    /// from the digest hex alone — `[0x04] + oid bytes`, byte-exact —
1592    /// and every malformed oid form is a reject.
1593    #[tokio::test(flavor = "multi_thread")]
1594    async fn the_media_keys_form_map_free_and_reject_the_rest() {
1595        let store = Store::mem_only();
1596        let content = b"lfs media bytes\n";
1597        let (hex, put_key) = put_media(&store, content).await;
1598        assert_eq!(hex.len(), 64, "the digest's hex is the row oid");
1599        assert_eq!(put_key.as_bytes()[0], 0x04, "the media family byte");
1600
1601        let key = lfs_media_key(&hex).unwrap();
1602        assert_eq!(
1603            key, put_key,
1604            "the oid IS the digest — the key forms map-free"
1605        );
1606        assert_eq!(key.algorithm(), Algorithm::GitObjectSha256);
1607        assert_eq!(
1608            key.as_bytes(),
1609            put_key.as_bytes(),
1610            "the formed key is the physical encoding byte-exact"
1611        );
1612
1613        let bad_forms: Vec<String> = vec![
1614            "".to_owned(),
1615            "nothex".to_owned(),
1616            "AB".repeat(32),
1617            "1".repeat(63),
1618            "1".repeat(65),
1619            "z".repeat(64),
1620        ];
1621        for bad in bad_forms {
1622            assert!(
1623                matches!(lfs_media_key(&bad), Err(StorageError::Invalid(_))),
1624                "a non-sha256-hex oid rejects: {bad:?}"
1625            );
1626        }
1627    }
1628
1629    /// The mark consult reads the rows (rebuild-per-mark — every
1630    /// consult, no fingerprint memo); the dedup collapses one key with
1631    /// N rows to one live key; `contains` answers the index exactly,
1632    /// cold-live, and unknown-live after the assert counter moved —
1633    /// never deriving.
1634    #[tokio::test(flavor = "multi_thread")]
1635    async fn the_media_source_marks_the_rows_and_answers_the_arbitration_from_the_index() {
1636        let (store, rows, counter) = media_fixture();
1637        let source = LfsMedia::new(rows.clone(), Arc::clone(&counter));
1638
1639        let (_, key_absent) = put_media(&store, b"unasserted\n").await;
1640        assert!(
1641            contains_of(&source, &key_absent),
1642            "a cold source answers live — the safe default"
1643        );
1644        assert_eq!(source.keys_builds(), 0, "contains never derives");
1645
1646        let (oid_a, key_a) = put_media(&store, b"shared\n").await;
1647        let (oid_b, key_b) = put_media(&store, b"other\n").await;
1648        rows.assert("r/one", &oid_a, 10).await.unwrap();
1649        rows.assert("r/two", &oid_a, 10).await.unwrap();
1650        rows.assert("r/one", &oid_b, 6).await.unwrap();
1651
1652        let keys = alkblobs::LivenessSource::live_keys(source.as_ref());
1653        assert_eq!(
1654            keys.len(),
1655            2,
1656            "one key with two rows is one live key (the dedup)"
1657        );
1658        assert!(keys.contains(&key_a) && keys.contains(&key_b));
1659        assert_eq!(source.keys_builds(), 1);
1660
1661        alkblobs::LivenessSource::live_keys(source.as_ref());
1662        assert_eq!(
1663            source.keys_builds(),
1664            2,
1665            "rebuild-per-mark: a consult re-reads the rows even unchanged"
1666        );
1667
1668        assert!(contains_of(&source, &key_a), "the index answer is exact");
1669        assert!(
1670            !contains_of(&source, &key_absent),
1671            "an unasserted oid is exactly absent while the epoch is caught up"
1672        );
1673
1674        // The dangerous window's safe side: a row lands after this
1675        // mark — the moved counter turns the miss conservative until
1676        // the next mark's rebuild resolves it.
1677        let (oid_c, key_c) = put_media(&store, b"landed-late\n").await;
1678        rows.assert("r/one", &oid_c, 12).await.unwrap();
1679        assert!(contains_of(&source, &key_c), "unknown is live");
1680        let keys = alkblobs::LivenessSource::live_keys(source.as_ref());
1681        assert_eq!(keys.len(), 3, "the next mark's rebuild answers exactly");
1682        assert!(contains_of(&source, &key_c), "now in the index");
1683    }
1684
1685    /// The straddle regression (the epoch discipline's poison shape):
1686    /// an `assert` parks **inside its committing call** — bump not yet
1687    /// landed (bump-after-commit) — while the mark consult runs its
1688    /// load-before-read rows read; the mark therefore captures the
1689    /// pre-write rows with the pre-write epoch. The write lands, the
1690    /// bump follows, and the arbitration consult answers LIVE (the
1691    /// moved counter). The bump-before shape this test replaced
1692    /// answered exact-DEAD here: its bump landed ahead of the gated
1693    /// commit, the mark's load caught it while the rows read missed
1694    /// the uncommitted row, and the memo paired the included bump with
1695    /// the missed rows — the sweep would delete just-referenced media.
1696    struct GatedAssertLfsRows {
1697        inner: Arc<crate::lfs::InMemoryLfsRows>,
1698        gate: std::sync::Mutex<Option<tokio::sync::oneshot::Receiver<()>>>,
1699        arrived: tokio::sync::watch::Sender<bool>,
1700    }
1701
1702    #[async_trait::async_trait]
1703    impl crate::lfs::LfsRows for GatedAssertLfsRows {
1704        async fn assert(&self, repo_id: &str, oid: &str, size: u64) -> Result<(), StorageError> {
1705            let rx = self.gate.lock().unwrap_or_else(|e| e.into_inner()).take();
1706            if let Some(rx) = rx {
1707                let _ = self.arrived.send(true);
1708                let _ = rx.await;
1709            }
1710            self.inner.assert(repo_id, oid, size).await
1711        }
1712        async fn remove(&self, repo_id: &str, oid: &str) -> Result<(), StorageError> {
1713            self.inner.remove(repo_id, oid).await
1714        }
1715        async fn remove_repo(&self, repo_id: &str) -> Result<(), StorageError> {
1716            self.inner.remove_repo(repo_id).await
1717        }
1718        async fn rows(&self) -> Result<Vec<crate::lfs::LfsRow>, StorageError> {
1719            self.inner.rows().await
1720        }
1721        async fn repo_rows(&self, repo_id: &str) -> Result<Vec<crate::lfs::LfsRow>, StorageError> {
1722            self.inner.repo_rows(repo_id).await
1723        }
1724    }
1725
1726    #[tokio::test(flavor = "multi_thread")]
1727    async fn an_in_flight_assert_straddling_a_mark_answers_live_not_dead() {
1728        let (gate_tx, gate_rx) = tokio::sync::oneshot::channel();
1729        let (arrived_tx, mut arrived_rx) = tokio::sync::watch::channel(false);
1730        let counter = writes();
1731        let rows: Arc<dyn crate::lfs::LfsRows> = Arc::new(crate::lfs::VersionedLfsRows::new(
1732            Arc::new(GatedAssertLfsRows {
1733                inner: Arc::new(crate::lfs::InMemoryLfsRows::new()),
1734                gate: std::sync::Mutex::new(Some(gate_rx)),
1735                arrived: arrived_tx,
1736            }),
1737            Arc::clone(&counter),
1738        ));
1739        let source = LfsMedia::new(rows.clone(), Arc::clone(&counter));
1740
1741        let oid = "ab".repeat(32);
1742        let key = lfs_media_key(&oid).unwrap();
1743        let task = tokio::spawn({
1744            let rows = rows.clone();
1745            async move { rows.assert("r/one", &oid, 10).await }
1746        });
1747        loop {
1748            if *arrived_rx.borrow_and_update() {
1749                break;
1750            }
1751            arrived_rx
1752                .changed()
1753                .await
1754                .expect("the gated assert arrives");
1755        }
1756
1757        let keys = alkblobs::LivenessSource::live_keys(source.as_ref());
1758        assert!(
1759            keys.is_empty(),
1760            "the mark's rows read misses the in-flight (uncommitted) row"
1761        );
1762        let before = counter.load(Ordering::Relaxed);
1763        assert_eq!(
1764            before, 0,
1765            "bump-after-commit: no bump while the write is in flight"
1766        );
1767
1768        gate_tx.send(()).expect("the gate opens");
1769        task.await
1770            .expect("the assert completes")
1771            .expect("the assert commits");
1772        assert_eq!(
1773            counter.load(Ordering::Relaxed),
1774            1,
1775            "the bump follows the landed row"
1776        );
1777        assert!(
1778            contains_of(&source, &key),
1779            "just-referenced media answers live — never exact-dead"
1780        );
1781    }
1782
1783    /// The same straddle on the refs closure (the wave-7 source the
1784    /// wave-9 discipline fixed beside it): a gated `apply_updates`
1785    /// parks inside the write; the mark consult's snapshot read misses
1786    /// the uncommitted ref while its counter load catches
1787    /// (bump-before's) epoch — the fixed pairing answers live after
1788    /// the commit lands.
1789    struct GatedApplyRefTable {
1790        inner: Arc<InMemoryRefTable>,
1791        gate: std::sync::Mutex<Option<tokio::sync::oneshot::Receiver<()>>>,
1792        arrived: tokio::sync::watch::Sender<bool>,
1793    }
1794
1795    #[async_trait::async_trait]
1796    impl GitRefs for GatedApplyRefTable {
1797        async fn list_refs(&self, repo: &RepoRecord) -> Result<Vec<alkgit::RefLine>, StorageError> {
1798            self.inner.list_refs(repo).await
1799        }
1800        async fn apply_updates(
1801            &self,
1802            repo: &RepoRecord,
1803            updates: Vec<RefUpdate>,
1804            atomic: bool,
1805        ) -> Result<Vec<alkgit::RefOutcome>, StorageError> {
1806            let rx = self.gate.lock().unwrap_or_else(|e| e.into_inner()).take();
1807            if let Some(rx) = rx {
1808                let _ = self.arrived.send(true);
1809                let _ = rx.await;
1810            }
1811            self.inner.apply_updates(repo, updates, atomic).await
1812        }
1813    }
1814
1815    #[async_trait::async_trait]
1816    impl super::RefRows for GatedApplyRefTable {
1817        async fn snapshot(&self) -> Result<super::RefRowsSnapshot, StorageError> {
1818            self.inner.snapshot().await
1819        }
1820    }
1821
1822    #[tokio::test(flavor = "multi_thread")]
1823    async fn an_in_flight_ref_write_straddling_a_mark_answers_live_not_dead() {
1824        let front = ObjectFront::new(Arc::new(Store::mem_only()));
1825        let tip = put_object(&front, Kind::Blob, b"straddled\n").await;
1826        let key = oid_key(&tip).expect("the blob oid's family key");
1827
1828        let (gate_tx, gate_rx) = tokio::sync::oneshot::channel();
1829        let (arrived_tx, mut arrived_rx) = tokio::sync::watch::channel(false);
1830        let counter = writes();
1831        let wrapper = Arc::new(crate::refs::VersionedRefTable::new(
1832            Arc::new(GatedApplyRefTable {
1833                inner: Arc::new(InMemoryRefTable::new()),
1834                gate: std::sync::Mutex::new(Some(gate_rx)),
1835                arrived: arrived_tx,
1836            }),
1837            Arc::clone(&counter),
1838        ));
1839        let rows: Arc<dyn super::RefRows> = wrapper.clone();
1840        let source = RefClosure::new(front.clone(), rows, Arc::clone(&counter));
1841
1842        let task = tokio::spawn({
1843            let wrapper = wrapper.clone();
1844            async move {
1845                wrapper
1846                    .apply_updates(
1847                        &repo("r/one"),
1848                        vec![RefUpdate {
1849                            name: "refs/heads/main".to_owned(),
1850                            expected: None,
1851                            new: Some(tip),
1852                        }],
1853                        false,
1854                    )
1855                    .await
1856            }
1857        });
1858        loop {
1859            if *arrived_rx.borrow_and_update() {
1860                break;
1861            }
1862            arrived_rx.changed().await.expect("the gated write arrives");
1863        }
1864
1865        let keys = alkblobs::LivenessSource::live_keys(source.as_ref());
1866        assert!(
1867            keys.is_empty(),
1868            "the snapshot read misses the in-flight write"
1869        );
1870        assert_eq!(
1871            counter.load(Ordering::Relaxed),
1872            0,
1873            "bump-after-commit: no bump while the write is in flight"
1874        );
1875
1876        gate_tx.send(()).expect("the gate opens");
1877        task.await
1878            .expect("the write completes")
1879            .expect("the write commits");
1880        assert_eq!(
1881            counter.load(Ordering::Relaxed),
1882            1,
1883            "the bump follows the row"
1884        );
1885        assert!(
1886            alkblobs::LivenessSource::contains(source.as_ref(), &key),
1887            "just-pushed refs' objects answer live — never exact-dead"
1888        );
1889    }
1890
1891    /// The registration + sweep integration at the source level over a
1892    /// registered mem pool: the never-asserted media reclaims (POC-L's
1893    /// sweep datum: orphaned deleted, referenced survive), the
1894    /// referenced media survives.
1895    #[tokio::test(flavor = "multi_thread")]
1896    async fn the_registered_media_source_serves_the_sweep_orphan_reclaim() {
1897        let (store, rows, counter) = media_fixture();
1898        let (oid_ref, _) = put_media(&store, b"referenced\n").await;
1899        let (oid_orphan, _) = put_media(&store, b"orphan\n").await;
1900        rows.assert("r/one", &oid_ref, 10).await.unwrap();
1901
1902        let source = LfsMedia::new(rows, Arc::clone(&counter));
1903        source.register(&store).unwrap();
1904
1905        let report = store.sweep().await.unwrap();
1906        assert!(
1907            report.aborted.is_none(),
1908            "a registered source never trips NoLivenessSources"
1909        );
1910        assert_eq!(
1911            report.deleted, 1,
1912            "the never-asserted media reclaims; the referenced survives"
1913        );
1914        let stat = |oid_hex: &str| {
1915            let hex = oid_hex.to_owned();
1916            let store = Arc::clone(&store);
1917            async move {
1918                store
1919                    .stat(&lfs_media_key(&hex).unwrap().as_bytes())
1920                    .await
1921                    .unwrap()
1922                    .is_some()
1923            }
1924        };
1925        assert!(stat(&oid_ref).await, "the referenced media stays");
1926        assert!(!stat(&oid_orphan).await, "the orphan is gone");
1927    }
1928
1929    /// The composition's LFS wiring leg over the durable rows — the
1930    /// sqlite rows twin under the wrapper + the register, exactly the
1931    /// entry's construction shape (the mem shape's own end-to-end rows
1932    /// ride the compose entry's test): re-protect while any row
1933    /// remains (the cross-reference refcount realized across a
1934    /// durable sweep window — stage, then executed-overdue), the
1935    /// assert-across-window cancel (the counter's dangerous-direction
1936    /// coverage), and the repo-deletion reclaim through the twin's
1937    /// record-removal leg.
1938    #[cfg(feature = "sqlite")]
1939    #[tokio::test(flavor = "multi_thread")]
1940    async fn the_composition_wiring_serves_the_lfs_sweep_across_the_durable_window() {
1941        use std::time::Duration;
1942
1943        use alkgit::GitRegistryStore as _;
1944
1945        use crate::sqlite::SqliteRegistryAssembly;
1946
1947        let dir = scratch_dir("liveness", "media-wiring");
1948        let db = dir.join("lfs-sweep.sqlite3");
1949        let assembly = SqliteRegistryAssembly::open(&db).await.unwrap();
1950        let store = Arc::new(
1951            Store::kv_only_sqlite(&db, 128 * 1024, Duration::ZERO)
1952                .await
1953                .unwrap(),
1954        );
1955
1956        let lfs_version = assembly.lfs_version().clone();
1957        let rows: Arc<dyn crate::lfs::LfsRows> = assembly.lfs_rows().clone();
1958        let source = super::LfsMedia::new(rows.clone(), Arc::clone(&lfs_version));
1959        source.register(&store).unwrap();
1960
1961        async fn pool_has(store: &Store, hex: &str) -> bool {
1962            store
1963                .stat(&lfs_media_key(hex).unwrap().as_bytes())
1964                .await
1965                .unwrap()
1966                .is_some()
1967        }
1968        let put = |store: Arc<Store>, content: &'static [u8]| async move {
1969            let (hex, key) = put_media(&store, content).await;
1970            (hex, key)
1971        };
1972
1973        let (shared_oid, _) = put(Arc::clone(&store), b"shared\n").await;
1974        rows.assert("repo-a", &shared_oid, 16).await.unwrap();
1975        rows.assert("repo-b", &shared_oid, 16).await.unwrap();
1976
1977        let first = store.sweep().await.unwrap();
1978        assert!(first.aborted.is_none());
1979        assert_eq!(
1980            first.candidates_staged, 0,
1981            "the cross-referenced media never stages while any row stands"
1982        );
1983        assert_eq!(first.deleted, 0);
1984
1985        rows.remove("repo-a", &shared_oid).await.unwrap();
1986        let second = store.sweep().await.unwrap();
1987        assert!(
1988            second.deleted == 0 && second.candidates_staged == 0,
1989            "one row removed — the survivor re-protects the key across the durable window"
1990        );
1991        assert!(pool_has(&store, &shared_oid).await);
1992
1993        rows.remove("repo-b", &shared_oid).await.unwrap();
1994        let third = store.sweep().await.unwrap();
1995        assert_eq!(
1996            third.candidates_staged, 1,
1997            "the last row's removal makes the media sweep-eligible — staged into the window"
1998        );
1999        assert_eq!(third.deleted, 0);
2000        assert!(pool_has(&store, &shared_oid).await, "staged, not deleted");
2001        let fourth = store.sweep().await.unwrap();
2002        assert_eq!(
2003            fourth.deleted, 1,
2004            "the overdue candidate executes — the arbitration's index answered exact"
2005        );
2006        assert!(!pool_has(&store, &shared_oid).await);
2007
2008        let (late_oid, _) = put(Arc::clone(&store), b"late orphan\n").await;
2009        let fifth = store.sweep().await.unwrap();
2010        assert_eq!(fifth.candidates_staged, 1, "the bare orphan stages");
2011        rows.assert("repo-c", &late_oid, 16).await.unwrap();
2012        let sixth = store.sweep().await.unwrap();
2013        assert_eq!(
2014            sixth.cancelled_by_arbitration, 1,
2015            "the row landed across the window — the moved counter cancels the \
2016             candidate (the dangerous direction the counter exists for)"
2017        );
2018        assert!(pool_has(&store, &late_oid).await, "the media survives");
2019        let seventh = store.sweep().await.unwrap();
2020        assert_eq!(
2021            seventh.deleted, 0,
2022            "the mark's rebuild-per-mark saw the row — protected while it stands"
2023        );
2024
2025        let (bare_oid, _) = put(Arc::clone(&store), b"bare\n").await;
2026        let eighth = store.sweep().await.unwrap();
2027        assert_eq!(
2028            eighth.candidates_staged, 1,
2029            "the never-asserted orphan stages"
2030        );
2031        let ninth = store.sweep().await.unwrap();
2032        assert_eq!(
2033            ninth.deleted, 1,
2034            "orphan reclaim across the durable window (POC-L's datum)"
2035        );
2036        assert!(!pool_has(&store, &bare_oid).await);
2037
2038        let (victim_oid, _) = put(Arc::clone(&store), b"victim media\n").await;
2039        use alkgit::RepoRecord;
2040        assembly
2041            .backend()
2042            .put_repo(&RepoRecord {
2043                repo_id: "victim".to_owned(),
2044                visibility: alkgit::Visibility::Private,
2045                grants: Default::default(),
2046            })
2047            .await
2048            .unwrap();
2049        rows.assert("victim", &victim_oid, 16).await.unwrap();
2050        let tenth = store.sweep().await.unwrap();
2051        assert_eq!(
2052            tenth.candidates_staged, 0,
2053            "the repo-referenced media re-protects"
2054        );
2055        assembly.backend().remove_repo("victim").await.unwrap();
2056        let eleventh = store.sweep().await.unwrap();
2057        assert_eq!(
2058            eleventh.candidates_staged, 1,
2059            "the repo deletion's rows leg leaves the media unreferenced — staged"
2060        );
2061        let twelfth = store.sweep().await.unwrap();
2062        assert_eq!(
2063            twelfth.deleted, 1,
2064            "nothing LFS-specific remains — the sweep reclaims"
2065        );
2066
2067        std::fs::remove_dir_all(&dir).unwrap_or_default();
2068    }
2069}