1use 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#[derive(Clone, Debug, PartialEq, Eq)]
184pub struct RefRow {
185 pub repo_id: String,
186 pub name: String,
187 pub state: RefRowState,
188}
189
190#[derive(Clone, Debug, PartialEq, Eq)]
193pub enum RefRowState {
194 Oid(ObjectId),
196 Symref(String),
199}
200
201#[derive(Clone, Debug, PartialEq, Eq)]
205pub struct RefRowsSnapshot {
206 pub rows: Vec<RefRow>,
207 pub fingerprint: u64,
208}
209
210#[async_trait]
218pub trait RefRows: Send + Sync {
219 async fn snapshot(&self) -> Result<RefRowsSnapshot, StorageError>;
222}
223
224pub(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 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
265struct Memo {
271 fingerprint: u64,
272 build_writes: u64,
273 keys: HashSet<Key>,
274}
275
276pub 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 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 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 pub fn keys_builds(&self) -> u64 {
329 self.builds.load(Ordering::Relaxed)
330 }
331
332 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 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 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 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 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#[cfg(feature = "lfs")]
500use crate::lfs::LfsRows;
501
502#[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#[cfg(feature = "lfs")]
546struct MediaMemo {
547 build_writes: u64,
548 keys: HashSet<Key>,
549}
550
551#[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 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 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 pub fn keys_builds(&self) -> u64 {
606 self.builds.load(Ordering::Relaxed)
607 }
608
609 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 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 fn live_keys(&self) -> Vec<Key> {
663 self.block_in_place(self.rebuild_keys()).unwrap_or_default()
664 }
665
666 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 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 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 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 #[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 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 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 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 #[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 #[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 #[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 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 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 #[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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 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 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 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 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 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#[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 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 #[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 #[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 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 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 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 #[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 #[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}