1#[cfg(feature = "sqlite")]
125use std::path::Path;
126use std::path::PathBuf;
127use std::sync::atomic::{AtomicU64, Ordering};
128use std::sync::Arc;
129
130use alkstore::Store;
131use async_trait::async_trait;
132
133use crate::error::OpenError;
134use crate::ingest::PackIngest;
135#[cfg(feature = "lfs")]
136use crate::lfs::LfsRows as _;
137use crate::liveness::RefClosure;
138use crate::maintenance::{MaintenanceChain, MaintenanceOpts};
139use crate::odb::ObjectFront;
140use crate::packgen::{PackCache, PackGen};
141use crate::pins::{CommittedRefs, MemMutationLegs, PendingPinRegistry, RegistryMutations};
142use crate::reactor::{AppliedWatermark, Reactor};
143use crate::refs::InMemoryRefTable;
144use crate::registry::{
145 CachedRegistryProvider, InMemoryRegistry, RegistryBackend, RegistryWrite, HEAD_SEED_TARGET,
146};
147use alkgit::{RegistryError, RepoRecord};
148
149use alkblobs::Store as BlobStore;
150
151#[cfg(feature = "lfs")]
152use crate::liveness::LfsMedia;
153
154use alkgit::{GitPackGen, GitPackIngest, GitRefs, GitRegistry, GitRegistryStore};
155
156#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
159pub enum PackServing {
160 #[default]
163 Direct,
164 ThroughStore,
171}
172
173#[derive(Clone, Debug)]
179pub struct AssemblyOptions {
180 pub pack_serving: PackServing,
183 pub work_root: PathBuf,
187 pub large_root: PathBuf,
192 pub maintenance: MaintenanceOpts,
197}
198
199impl AssemblyOptions {
200 pub fn new(work_root: impl Into<PathBuf>, large_root: impl Into<PathBuf>) -> Self {
203 AssemblyOptions {
204 pack_serving: PackServing::default(),
205 work_root: work_root.into(),
206 large_root: large_root.into(),
207 maintenance: MaintenanceOpts::default(),
208 }
209 }
210}
211
212struct RegistryLegs {
217 store: Arc<dyn Store>,
220 provider: Arc<CachedRegistryProvider>,
222 registry: Arc<dyn GitRegistry>,
226 registry_store: Arc<dyn GitRegistryStore>,
227 reactor: Arc<Reactor>,
229 versioned: Arc<dyn GitRefs>,
232 rows: Arc<dyn crate::liveness::RefRows>,
234 refs_version: Arc<AtomicU64>,
236 mutations: Arc<dyn RegistryMutations>,
238 #[cfg(feature = "lfs")]
244 lfs: Arc<dyn crate::lfs::LfsRows>,
245 #[cfg(feature = "lfs")]
249 lfs_version: Arc<AtomicU64>,
250}
251
252struct MemSeededBackend {
261 inner: Arc<RegistryBackend>,
262 rows: Arc<InMemoryRefTable>,
263 writes: Arc<AtomicU64>,
264 #[cfg(feature = "lfs")]
265 lfs: Arc<crate::lfs::VersionedLfsRows<crate::lfs::InMemoryLfsRows>>,
266}
267
268impl RegistryLegs {
269 fn mem(store: Arc<dyn Store>) -> Self {
273 let durable = Arc::new(InMemoryRegistry::new());
274 let provider = Arc::new(CachedRegistryProvider::new());
275 let reactor = Arc::new(Reactor::new(
276 Arc::clone(&store),
277 durable.clone(),
278 provider.clone(),
279 ));
280 let announced: Arc<dyn RegistryWrite> = Arc::new(crate::reactor::AnnouncingRegistry::new(
281 durable.clone(),
282 reactor.clone(),
283 ));
284 let refs_version = Arc::new(AtomicU64::new(0));
285 let table = Arc::new(InMemoryRefTable::new());
286 #[cfg(feature = "lfs")]
287 let lfs_version = Arc::new(AtomicU64::new(0));
288 #[cfg(feature = "lfs")]
289 let lfs = Arc::new(crate::lfs::VersionedLfsRows::new(
290 Arc::new(crate::lfs::InMemoryLfsRows::new()),
291 Arc::clone(&lfs_version),
292 ));
293 let inner = Arc::new(RegistryBackend::with_cached_provider(
294 announced,
295 provider.clone(),
296 ));
297 let seeded = Arc::new(MemSeededBackend {
298 inner,
299 rows: Arc::clone(&table),
300 writes: Arc::clone(&refs_version),
301 #[cfg(feature = "lfs")]
302 lfs: Arc::clone(&lfs),
303 });
304 let versioned = Arc::new(crate::refs::VersionedRefTable::new(
305 Arc::clone(&table),
306 Arc::clone(&refs_version),
307 ));
308 let mutations = Arc::new(MemMutationLegs::new(
309 Arc::clone(&durable),
310 Arc::clone(&reactor),
311 ));
312 RegistryLegs {
313 store,
314 provider,
315 registry: Arc::clone(&seeded) as Arc<dyn GitRegistry>,
316 registry_store: seeded as Arc<dyn GitRegistryStore>,
317 reactor,
318 versioned: Arc::clone(&versioned) as Arc<dyn GitRefs>,
319 rows: versioned,
320 refs_version,
321 mutations,
322 #[cfg(feature = "lfs")]
323 lfs: lfs as Arc<dyn crate::lfs::LfsRows>,
324 #[cfg(feature = "lfs")]
325 lfs_version,
326 }
327 }
328}
329
330#[async_trait]
331impl GitRegistryStore for MemSeededBackend {
332 async fn put_repo(&self, record: &RepoRecord) -> Result<(), RegistryError> {
333 self.inner.put_repo(record).await?;
334 self.rows.seed_head(&record.repo_id, HEAD_SEED_TARGET).await;
335 self.writes.fetch_add(1, Ordering::Relaxed);
336 Ok(())
337 }
338
339 async fn update_repo(&self, record: &RepoRecord) -> Result<(), RegistryError> {
340 self.inner.update_repo(record).await
341 }
342
343 async fn remove_repo(&self, repo_id: &str) -> Result<(), RegistryError> {
344 self.inner.remove_repo(repo_id).await?;
345 self.rows.drop_repo_rows(repo_id).await;
346 #[cfg(feature = "lfs")]
347 self.lfs
348 .remove_repo(repo_id)
349 .await
350 .map_err(|e| RegistryError::Io(e.to_string()))?;
351 self.writes.fetch_add(1, Ordering::Relaxed);
352 Ok(())
353 }
354}
355
356#[async_trait]
357impl GitRegistry for MemSeededBackend {
358 async fn resolve(&self, repo_id: &str) -> Result<RepoRecord, RegistryError> {
359 self.inner.resolve(repo_id).await
360 }
361}
362
363async fn wire(
367 legs: RegistryLegs,
368 blobs: Arc<BlobStore>,
369 options: AssemblyOptions,
370) -> Result<SubstrateAssembly, OpenError> {
371 let front = ObjectFront::new(Arc::clone(&blobs));
372 let pins = Arc::new(PendingPinRegistry::new());
373
374 let (packgen, packs) = match options.pack_serving {
375 PackServing::Direct => (PackGen::direct(front.clone()), None),
376 PackServing::ThroughStore => {
377 let cache = Arc::new(PackCache::new(crate::packgen::DEFAULT_CACHE_ENTRIES));
378 (
379 PackGen::through_store_with(front.clone(), Arc::clone(&cache)),
380 Some(cache),
381 )
382 }
383 };
384
385 let committed = CommittedRefs::new(
386 Arc::clone(&legs.versioned),
387 Arc::clone(&pins),
388 Arc::clone(&legs.mutations),
389 );
390 let refs: Arc<dyn GitRefs> = Arc::new(committed);
391
392 let ingest: Arc<dyn GitPackIngest> = Arc::new(PackIngest::new(
393 front.clone(),
394 Arc::clone(&pins),
395 options.work_root.clone(),
396 ));
397
398 let closure = RefClosure::new(front.clone(), legs.rows.clone(), legs.refs_version.clone());
399 let liveness_registration = closure
400 .register(&blobs)
401 .map_err(|e| OpenError::Engine(format!("the liveness source registration: {e}")))?;
402
403 #[cfg(feature = "lfs")]
404 let lfs_registration = {
405 let media = LfsMedia::new(legs.lfs.clone(), legs.lfs_version.clone());
406 media
407 .register(&blobs)
408 .map_err(|e| OpenError::Engine(format!("the LFS liveness source registration: {e}")))?
409 };
410
411 let applied = AppliedWatermark::new();
412 let chain = MaintenanceChain::new(
413 Arc::clone(&legs.store),
414 Arc::clone(&blobs),
415 legs.reactor.clone(),
416 applied.clone(),
417 packs,
418 options.maintenance.clone(),
419 );
420
421 Ok(SubstrateAssembly {
422 registry: legs.registry,
423 registry_store: legs.registry_store,
424 refs,
425 packgen: Arc::new(packgen),
426 ingest,
427 front,
428 reactor: legs.reactor,
429 chain,
430 applied,
431 liveness_registration,
432 #[cfg(feature = "lfs")]
433 lfs_registration,
434 blobs,
435 provider: legs.provider,
436 refs_version: legs.refs_version,
437 #[cfg(feature = "lfs")]
438 lfs_rows: legs.lfs,
439 })
440}
441
442pub struct SubstrateAssembly {
459 registry: Arc<dyn GitRegistry>,
460 registry_store: Arc<dyn GitRegistryStore>,
461 refs: Arc<dyn GitRefs>,
462 packgen: Arc<dyn GitPackGen>,
463 ingest: Arc<dyn GitPackIngest>,
464 front: ObjectFront,
465 reactor: Arc<Reactor>,
466 chain: MaintenanceChain,
467 applied: Arc<AppliedWatermark>,
468 liveness_registration: alkblobs::Registration,
469 #[cfg(feature = "lfs")]
470 lfs_registration: alkblobs::Registration,
471 blobs: Arc<BlobStore>,
472 provider: Arc<CachedRegistryProvider>,
473 refs_version: Arc<AtomicU64>,
474 #[cfg(feature = "lfs")]
475 lfs_rows: Arc<dyn crate::lfs::LfsRows>,
476}
477
478impl SubstrateAssembly {
479 pub async fn mem(
489 store: Arc<dyn Store>,
490 blobs: Arc<BlobStore>,
491 options: AssemblyOptions,
492 ) -> Result<Self, OpenError> {
493 wire(RegistryLegs::mem(store), blobs, options).await
494 }
495
496 #[cfg(feature = "sqlite")]
505 pub async fn sqlite(
506 path: impl AsRef<Path>,
507 options: AssemblyOptions,
508 ) -> Result<Self, OpenError> {
509 let path = path.as_ref();
510 let assembly = crate::sqlite::SqliteRegistryAssembly::open(path).await?;
511 let blobs = Arc::new(
512 BlobStore::dual_tier_sqlite_local(
513 path,
514 options.large_root.clone(),
515 alkblobs::DEFAULT_DISPATCH_THRESHOLD,
516 alkblobs::DEFAULT_DELETE_WINDOW_DELAY,
517 )
518 .await
519 .map_err(|e| OpenError::Engine(format!("alkblobs sqlite object store: {e}")))?,
520 );
521
522 let versioned: Arc<dyn GitRefs> = assembly.refs().clone();
523 let rows: Arc<dyn crate::liveness::RefRows> = assembly.refs().clone();
524 #[cfg(feature = "lfs")]
525 let lfs_version = assembly.lfs_version().clone();
526 #[cfg(feature = "lfs")]
527 let lfs_rows: Arc<dyn crate::lfs::LfsRows> = assembly.lfs_rows().clone();
528 let legs = RegistryLegs {
529 store: assembly.store().clone(),
530 provider: assembly.provider().clone(),
531 registry: assembly.backend().clone() as Arc<dyn GitRegistry>,
532 registry_store: assembly.backend().clone() as Arc<dyn GitRegistryStore>,
533 reactor: assembly.reactor().clone(),
534 versioned,
535 rows,
536 refs_version: assembly.refs_version().clone(),
537 mutations: Arc::new(assembly),
538 #[cfg(feature = "lfs")]
539 lfs: lfs_rows.clone(),
540 #[cfg(feature = "lfs")]
541 lfs_version,
542 };
543 wire(legs, blobs, options).await
544 }
545
546 #[cfg(feature = "postgres")]
555 pub async fn postgres(
556 config: &str,
557 registry: crate::postgres::PgRegistryOptions,
558 options: AssemblyOptions,
559 ) -> Result<Self, OpenError> {
560 let assembly = crate::postgres::PgRegistryAssembly::open(config, registry.clone()).await?;
561 let blobs = Arc::new(
562 BlobStore::dual_tier_postgres_local(
563 config,
564 registry.blobs,
565 options.large_root.clone(),
566 alkblobs::DEFAULT_DISPATCH_THRESHOLD,
567 alkblobs::DEFAULT_DELETE_WINDOW_DELAY,
568 )
569 .await
570 .map_err(|e| OpenError::Engine(format!("alkblobs postgres object store: {e}")))?,
571 );
572
573 let versioned: Arc<dyn GitRefs> = assembly.refs().clone();
574 let rows: Arc<dyn crate::liveness::RefRows> = assembly.refs().clone();
575 #[cfg(feature = "lfs")]
576 let lfs_version = assembly.lfs_version().clone();
577 #[cfg(feature = "lfs")]
578 let lfs_rows: Arc<dyn crate::lfs::LfsRows> = assembly.lfs_rows().clone();
579 let legs = RegistryLegs {
580 store: assembly.store().clone(),
581 provider: assembly.provider().clone(),
582 registry: assembly.backend().clone() as Arc<dyn GitRegistry>,
583 registry_store: assembly.backend().clone() as Arc<dyn GitRegistryStore>,
584 reactor: assembly.reactor().clone(),
585 versioned,
586 rows,
587 refs_version: assembly.refs_version().clone(),
588 mutations: Arc::new(assembly),
589 #[cfg(feature = "lfs")]
590 lfs: lfs_rows.clone(),
591 #[cfg(feature = "lfs")]
592 lfs_version,
593 };
594 wire(legs, blobs, options).await
595 }
596}
597
598impl SubstrateAssembly {
601 pub fn registry(&self) -> &Arc<dyn GitRegistry> {
604 &self.registry
605 }
606
607 pub fn registry_store(&self) -> &Arc<dyn GitRegistryStore> {
610 &self.registry_store
611 }
612
613 pub fn refs(&self) -> &Arc<dyn GitRefs> {
617 &self.refs
618 }
619
620 pub fn packgen(&self) -> &Arc<dyn GitPackGen> {
623 &self.packgen
624 }
625
626 pub fn ingest(&self) -> &Arc<dyn GitPackIngest> {
629 &self.ingest
630 }
631
632 pub fn front(&self) -> &ObjectFront {
635 &self.front
636 }
637
638 pub fn reactor(&self) -> &Arc<Reactor> {
641 &self.reactor
642 }
643
644 pub fn chain(&self) -> &MaintenanceChain {
647 &self.chain
648 }
649
650 pub fn applied_watermark(&self) -> &Arc<AppliedWatermark> {
654 &self.applied
655 }
656
657 pub fn provider(&self) -> &Arc<CachedRegistryProvider> {
660 &self.provider
661 }
662
663 pub fn blobs(&self) -> &Arc<BlobStore> {
666 &self.blobs
667 }
668
669 pub fn liveness_registration(&self) -> &alkblobs::Registration {
673 &self.liveness_registration
674 }
675
676 #[cfg(feature = "lfs")]
682 pub fn lfs_registration(&self) -> &alkblobs::Registration {
683 &self.lfs_registration
684 }
685
686 #[cfg(feature = "lfs")]
697 pub fn lfs_rows(&self) -> &Arc<dyn crate::lfs::LfsRows> {
698 &self.lfs_rows
699 }
700}
701
702impl SubstrateAssembly {
706 pub fn refs_version(&self) -> u64 {
708 self.refs_version.load(Ordering::Relaxed)
709 }
710}
711#[cfg(test)]
712mod tests {
713 use std::collections::BTreeMap;
714 use std::sync::atomic::AtomicU64;
715 use std::sync::Arc;
716 use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
717
718 use alkcall::core::Identity;
719 use alkstore::Store as AlkStore;
720 use gix_hash::{hasher, Kind as HashKind, ObjectId};
721 use gix_object::Kind;
722 use gix_pack::data::entry::Header as PackHeader;
723 use gix_pack::data::input::Entry as PackEntry;
724 use tokio::io::{duplex, AsyncReadExt, AsyncWriteExt};
725 use tokio::sync::Semaphore;
726
727 use alkgit::{
728 serve_duplex_resolved, GitPackGen, GitPackIngest, GitRefs, GitRegistry, Limits,
729 RegistryError, Service, SubstrateError, Visibility,
730 };
731 use serde_json::Value;
732
733 use alkgit::RepoRecord;
734
735 use super::*;
736 use crate::loose_form;
737 use crate::registry::RegistryRead as _;
738
739 fn unique_root(tag: &str) -> std::path::PathBuf {
740 static COUNTER: AtomicU64 = AtomicU64::new(0);
741 let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
742 let nanos = SystemTime::now()
743 .duration_since(UNIX_EPOCH)
744 .map(|d| d.as_nanos())
745 .unwrap_or_default();
746 std::env::temp_dir().join(format!(
747 "alkgit-substrate-compose-{tag}-{}-{n}-{nanos}",
748 std::process::id()
749 ))
750 }
751
752 fn identity() -> Identity {
753 Identity {
754 id: "u-writer".to_owned(),
755 scopes: Vec::new(),
756 resources: Default::default(),
757 }
758 }
759
760 fn granted_record(id: &str, identity: &Identity) -> RepoRecord {
761 RepoRecord {
762 repo_id: id.to_owned(),
763 visibility: Visibility::Private,
764 grants: BTreeMap::from([(
765 identity.id.clone(),
766 vec![alkgit::AccessAction::Read, alkgit::AccessAction::Write],
767 )]),
768 }
769 }
770
771 fn ungranted_record(id: &str) -> RepoRecord {
772 RepoRecord {
773 repo_id: id.to_owned(),
774 visibility: Visibility::Private,
775 grants: BTreeMap::new(),
776 }
777 }
778
779 fn mem_object_pool() -> Arc<alkblobs::Store> {
780 Arc::new(alkblobs::Store::mem_only())
781 }
782
783 fn pkt(payload: &[u8]) -> Vec<u8> {
786 let total = payload.len() + 4;
787 let mut out = Vec::with_capacity(total);
788 out.extend_from_slice(format!("{total:04x}").as_bytes());
789 out.extend_from_slice(payload);
790 out
791 }
792
793 async fn read_pkts<R: tokio::io::AsyncRead + Unpin>(reader: &mut R) -> Vec<Vec<u8>> {
794 let mut packets = Vec::new();
795 loop {
796 let mut header = [0u8; 4];
797 reader.read_exact(&mut header).await.expect("pkt header");
798 let len = usize::from_str_radix(std::str::from_utf8(&header).expect("hex header"), 16)
799 .expect("hex length");
800 if len < 4 {
801 packets.push(Vec::new());
802 break;
803 }
804 let mut payload = vec![0u8; len - 4];
805 reader.read_exact(&mut payload).await.expect("pkt payload");
806 packets.push(payload);
807 }
808 packets
809 }
810
811 fn text(payload: &[u8]) -> String {
812 String::from_utf8_lossy(payload)
813 .trim_end_matches('\n')
814 .to_owned()
815 }
816
817 fn create_command(name: &str, new: ObjectId) -> String {
818 format!("{} {new} {name}", "0".repeat(40))
819 }
820
821 async fn read_band1_body<R: tokio::io::AsyncRead + Unpin>(reader: &mut R) -> Vec<u8> {
824 let mut payload = Vec::new();
825 loop {
826 let mut header = [0u8; 4];
827 reader.read_exact(&mut header).await.expect("pkt header");
828 let len = usize::from_str_radix(std::str::from_utf8(&header).expect("hex header"), 16)
829 .expect("hex length");
830 if len < 4 {
831 break;
832 }
833 let mut packet = vec![0u8; len - 4];
834 reader.read_exact(&mut packet).await.expect("pkt payload");
835 assert_eq!(packet[0], 1, "band-1 framing");
836 payload.extend_from_slice(&packet[1..]);
837 }
838 payload
839 }
840
841 fn report_lines(body: &[u8]) -> Vec<String> {
842 let inner = {
843 let mut lines = Vec::new();
844 let mut cursor = body;
845 while !cursor.is_empty() {
846 let len = usize::from_str_radix(
847 std::str::from_utf8(&cursor[..4]).expect("hex header"),
848 16,
849 )
850 .expect("hex length");
851 cursor = &cursor[4..];
852 if len < 4 {
853 lines.push(Vec::new());
854 continue;
855 }
856 lines.push(cursor[..len - 4].to_vec());
857 cursor = &cursor[len - 4..];
858 }
859 let last = lines.pop().expect("inner framing non-empty");
860 assert!(last.is_empty(), "the inner flush ends the report");
861 lines
862 };
863 inner.iter().map(|p| text(p)).collect()
864 }
865
866 fn tree_bytes(entries: &[(&str, &ObjectId)]) -> Vec<u8> {
869 let mut tree = Vec::new();
870 for (name, oid) in entries {
871 tree.extend_from_slice(format!("100644 {name}\0").as_bytes());
872 tree.extend_from_slice(oid.as_bytes());
873 }
874 tree
875 }
876
877 fn commit_bytes(tree: &ObjectId, parents: &[&ObjectId]) -> Vec<u8> {
878 let mut body = format!("tree {tree}\n");
879 for parent in parents {
880 body.push_str(&format!("parent {parent}\n"));
881 }
882 body.push_str(
883 "author A <a@example.invalid> 1750000000 +0000\n\
884 committer A <a@example.invalid> 1750000000 +0000\n\nthe message\n",
885 );
886 body.into_bytes()
887 }
888
889 fn oid_of(kind: Kind, content: &[u8]) -> ObjectId {
890 let form = loose_form(kind, content);
891 let mut h = hasher(HashKind::Sha1);
892 h.update(&form);
893 h.try_finalize().expect("fixture oid hashes")
894 }
895
896 fn deflate(bytes: &[u8]) -> Vec<u8> {
897 let mut deflate =
898 gix_zlib::stream::deflate::Write::new(Vec::new(), gix_zlib::Compression::DEFAULT);
899 std::io::Write::write_all(&mut deflate, bytes).expect("fixture bytes deflate");
900 std::io::Write::flush(&mut deflate).expect("the fixture deflate stream finishes");
901 deflate.into_inner()
902 }
903
904 fn pack_entry(kind: Kind, body: &[u8], pack_offset: u64) -> PackEntry {
905 let header = match kind {
906 Kind::Blob => PackHeader::Blob,
907 Kind::Tree => PackHeader::Tree,
908 Kind::Commit => PackHeader::Commit,
909 Kind::Tag => PackHeader::Tag,
910 };
911 let compressed = deflate(body);
912 PackEntry {
913 header,
914 header_size: header.size(body.len() as u64) as u16,
915 pack_offset,
916 compressed: Some(compressed.clone()),
917 compressed_size: compressed.len() as u64,
918 crc32: None,
919 decompressed_size: body.len() as u64,
920 trailer: None,
921 }
922 }
923
924 fn pack_bytes(entries: &[PackEntry]) -> Vec<u8> {
925 let mut out = Vec::new();
926 out.extend_from_slice(&gix_pack::data::header::encode(
927 gix_pack::data::Version::V2,
928 entries.len() as u32,
929 ));
930 for entry in entries {
931 entry
932 .header
933 .write_to(entry.decompressed_size, &mut out)
934 .expect("fixture entry headers write");
935 out.extend_from_slice(
936 entry
937 .compressed
938 .as_ref()
939 .expect("fixture entries keep bytes"),
940 );
941 }
942 let mut h = hasher(HashKind::Sha1);
943 h.update(&out);
944 out.extend_from_slice(
945 h.try_finalize()
946 .expect("fixture pack trailer hashes")
947 .as_slice(),
948 );
949 out
950 }
951
952 fn chain_pack() -> (Vec<u8>, ObjectId, ObjectId, ObjectId, ObjectId) {
954 let blob_body = b"the composed chain's blob\n".to_vec();
955 let blob = oid_of(Kind::Blob, &blob_body);
956 let tree_base_body = tree_bytes(&[("a.txt", &blob)]);
957 let tree_base = oid_of(Kind::Tree, &tree_base_body);
958 let base_body = commit_bytes(&tree_base, &[]);
959 let base = oid_of(Kind::Commit, &base_body);
960 let tree_tip_body = tree_bytes(&[("a.txt", &blob), ("b.txt", &blob)]);
961 let tree_tip = oid_of(Kind::Tree, &tree_tip_body);
962 let tip_body = commit_bytes(&tree_tip, &[&base]);
963 let tip = oid_of(Kind::Commit, &tip_body);
964
965 let mut offset = 0u64;
966 let mut entries = Vec::new();
967 for (kind, body) in [
968 (Kind::Blob, blob_body.as_slice()),
969 (Kind::Tree, tree_base_body.as_slice()),
970 (Kind::Commit, base_body.as_slice()),
971 (Kind::Tree, tree_tip_body.as_slice()),
972 (Kind::Commit, tip_body.as_slice()),
973 ] {
974 let entry = pack_entry(kind, body, offset);
975 offset += entry.header_size as u64 + entry.compressed_size;
976 entries.push(entry);
977 }
978 (pack_bytes(&entries), tip, base, tree_tip, blob)
979 }
980
981 #[allow(clippy::too_many_arguments)]
984 async fn run_push(
985 repo_id: &str,
986 identity: &Identity,
987 registry: Arc<dyn GitRegistry>,
988 refs: Arc<dyn GitRefs>,
989 packgen: Arc<dyn GitPackGen>,
990 ingest: Arc<dyn GitPackIngest>,
991 commands: &[String],
992 pack: &[u8],
993 ) -> Vec<String> {
994 let limits = Limits::default();
995 let (mut peer, stream) = duplex(1 << 16);
996 let joined = tokio::spawn({
997 let registry = registry.clone();
998 let refs = refs.clone();
999 let packgen = packgen.clone();
1000 let ingest = ingest.clone();
1001 let identity = identity.clone();
1002 let repo_id = repo_id.to_owned();
1003 let pipeline = Arc::new(Semaphore::new(limits.max_concurrent_blocking_tasks));
1004 async move {
1005 serve_duplex_resolved(
1006 &repo_id,
1007 Some(&identity),
1008 Service::ReceivePack,
1009 stream,
1010 limits,
1011 registry,
1012 refs,
1013 packgen,
1014 ingest,
1015 false,
1016 pipeline,
1017 )
1018 .await
1019 }
1020 });
1021
1022 read_pkts(&mut peer).await;
1023 let mut chunks = Vec::new();
1024 for (i, command) in commands.iter().enumerate() {
1025 if i == 0 {
1026 chunks.push(pkt(
1027 format!("{command}\0report-status side-band-64k").as_bytes()
1028 ));
1029 } else {
1030 chunks.push(pkt(command.as_bytes()));
1031 }
1032 }
1033 chunks.push(b"0000".to_vec());
1034 chunks.push(pack.to_vec());
1035 peer.write_all(&chunks.concat()).await.unwrap();
1036
1037 let body = read_band1_body(&mut peer).await;
1038 drop(peer);
1039 joined
1040 .await
1041 .expect("the session task joins")
1042 .expect("the push session serves cleanly");
1043 report_lines(&body)
1044 }
1045
1046 async fn feed_events(
1047 assembly: &SubstrateAssembly,
1048 since: i64,
1049 ) -> Vec<crate::reactor::RegistryEvent> {
1050 let stream = assembly
1051 .reactor()
1052 .store()
1053 .stream(crate::reactor::EVENTS_STREAM)
1054 .await
1055 .expect("the feed opens");
1056 stream
1057 .read_since(since, 100)
1058 .await
1059 .expect("the feed reads")
1060 .iter()
1061 .filter_map(crate::reactor::RegistryEvent::decode_event)
1062 .collect()
1063 }
1064
1065 async fn mem_assembly(tag: &str) -> SubstrateAssembly {
1068 let root = unique_root(tag);
1069 let options = AssemblyOptions::new(root.join("work"), root.join("large"));
1070 SubstrateAssembly::mem(
1071 Arc::new(alkstore_mem::MemStore::new()),
1072 mem_object_pool(),
1073 options,
1074 )
1075 .await
1076 .expect("the mem assembly composes")
1077 }
1078
1079 #[tokio::test(flavor = "multi_thread")]
1084 async fn the_mem_assembly_wires_the_complete_backend() {
1085 let assembly = mem_assembly("wired").await;
1086 let identity = identity();
1087 let record = granted_record("owner/composed", &identity);
1088
1089 assembly.registry_store().put_repo(&record).await.unwrap();
1090 assert_eq!(
1091 assembly.registry().resolve("owner/composed").await.unwrap(),
1092 record,
1093 "the provider was written through by the store"
1094 );
1095 assert!(assembly.provider().resolve("owner/composed").is_some());
1096 assert_eq!(
1097 assembly.refs_version(),
1098 1,
1099 "the record-create seeding leg bumped the counter"
1100 );
1101
1102 let lines = assembly
1103 .refs()
1104 .list_refs(&record)
1105 .await
1106 .expect("the refs side lists");
1107 assert_eq!(
1108 lines,
1109 vec![alkgit::RefLine {
1110 name: "HEAD".to_owned(),
1111 oid: None,
1112 peeled: None,
1113 symref_target: Some(crate::registry::HEAD_SEED_TARGET.to_owned()),
1114 }],
1115 "the unborn HEAD shape rides the composed refs — the record's seed row"
1116 );
1117
1118 let unknown = assembly.registry().resolve("owner/ghost").await;
1119 assert_eq!(unknown.unwrap_err(), RegistryError::NotFound);
1120 }
1121
1122 #[tokio::test(flavor = "multi_thread")]
1126 async fn the_mem_assemblys_reactive_legs_fire_post_commit() {
1127 let assembly = mem_assembly("legs").await;
1128 let identity = identity();
1129 let mut listener = assembly
1130 .reactor()
1131 .store()
1132 .listen(crate::reactor::WAKE_CHANNEL)
1133 .await
1134 .expect("the wake source opens");
1135 let record = granted_record("owner/announced", &identity);
1136
1137 assembly.registry_store().put_repo(&record).await.unwrap();
1138
1139 let events = feed_events(&assembly, 0).await;
1140 assert_eq!(
1141 events,
1142 vec![crate::reactor::RegistryEvent::Put {
1143 seq: 1,
1144 record: record.clone()
1145 }],
1146 "the announced put published post-commit"
1147 );
1148 let wake = listener
1149 .recv_timeout(Duration::from_secs(2))
1150 .await
1151 .expect("the wake delivers")
1152 .expect("wake source open");
1153 assert_eq!(wake.channel, crate::reactor::WAKE_CHANNEL);
1154 drop(listener);
1155
1156 let queue = assembly
1157 .reactor()
1158 .store()
1159 .queue(
1160 crate::reactor::MAINTENANCE_QUEUE,
1161 alkstore::QueueOpts::default(),
1162 )
1163 .await
1164 .expect("the queue opens");
1165 let claimed = queue
1166 .claim_one("probe")
1167 .await
1168 .expect("claim checks")
1169 .expect("the enqueue leg landed the housekeeping job");
1170 assert_eq!(
1171 claimed
1172 .job()
1173 .payload_as::<crate::reactor::MaintenanceJob>()
1174 .unwrap(),
1175 crate::reactor::MaintenanceJob::PackHousekeeping {
1176 repo_id: "owner/announced".to_owned(),
1177 }
1178 );
1179 }
1180
1181 #[tokio::test(flavor = "multi_thread")]
1189 async fn the_receive_path_composes_through_the_entry_end_to_end() {
1190 let assembly = mem_assembly("receipt").await;
1191 let identity = identity();
1192 let record = granted_record("owner/pushed", &identity);
1193 assembly.registry_store().put_repo(&record).await.unwrap();
1194
1195 let (pack, tip, _base, _tree, _blob) = chain_pack();
1196 let report = run_push(
1197 "owner/pushed",
1198 &identity,
1199 assembly.registry().clone(),
1200 assembly.refs().clone(),
1201 assembly.packgen().clone(),
1202 assembly.ingest().clone(),
1203 &[create_command("refs/heads/main", tip)],
1204 &pack,
1205 )
1206 .await;
1207 assert_eq!(
1208 report,
1209 vec!["unpack ok".to_owned(), "ok refs/heads/main".to_owned(),],
1210 "the band-1 report equals the capture's shape"
1211 );
1212
1213 let lines = assembly.refs().list_refs(&record).await.unwrap();
1214 assert_eq!(
1215 lines
1216 .iter()
1217 .find(|line| line.name == "refs/heads/main")
1218 .map(|line| line.oid),
1219 Some(Some(tip)),
1220 "the committed ref landed through the entry's refs side"
1221 );
1222 assert!(assembly.refs_version() >= 1, "the CAS bumped the counter");
1223
1224 let events = feed_events(&assembly, 1).await;
1225 assert_eq!(
1226 events,
1227 vec![crate::reactor::RegistryEvent::Mutation {
1228 seq: 2,
1229 repo_id: "owner/pushed".to_owned(),
1230 }],
1231 "the marker write announced post-CAS — the watermark leg"
1232 );
1233 let watermark = assembly.reactor().registry().watermark().await.unwrap();
1234 assert_eq!(watermark, 2, "the put (1) + the push's marker (2)");
1235
1236 let report = assembly.blobs().sweep().await.expect("the sweep runs");
1237 assert!(
1238 report.aborted.is_none() && report.deleted == 0,
1239 "the pushed chain is liveness-covered — the registered closure \
1240 and the committed custody hold it across the sweep"
1241 );
1242 }
1243
1244 #[tokio::test(flavor = "multi_thread")]
1249 async fn the_receive_path_enforces_the_visible_surface() {
1250 let assembly = mem_assembly("gate").await;
1251 let identity = identity();
1252 let record = ungranted_record("owner/gated");
1253 assembly.registry_store().put_repo(&record).await.unwrap();
1254
1255 let (_pack, tip, _a, _b, _c) = chain_pack();
1256 let limits = Limits::default();
1257 let (peer, stream) = duplex(1 << 16);
1258 let joined = tokio::spawn({
1259 let registry = assembly.registry().clone();
1260 let refs = assembly.refs().clone();
1261 let packgen = assembly.packgen().clone();
1262 let ingest = assembly.ingest().clone();
1263 let identity = identity.clone();
1264 let pipeline = Arc::new(Semaphore::new(limits.max_concurrent_blocking_tasks));
1265 async move {
1266 serve_duplex_resolved(
1267 "owner/gated",
1268 Some(&identity),
1269 Service::ReceivePack,
1270 stream,
1271 limits,
1272 registry,
1273 refs,
1274 packgen,
1275 ingest,
1276 false,
1277 pipeline,
1278 )
1279 .await
1280 }
1281 });
1282
1283 let chunks = [
1294 pkt(format!("{} {tip} refs/heads/main\0report-status", "0".repeat(40)).as_bytes()),
1295 b"0000".to_vec(),
1296 ];
1297 let (mut peer_read, mut peer_write) = tokio::io::split(peer);
1298 let write_outcome =
1299 tokio::spawn(async move { peer_write.write_all(&chunks.concat()).await })
1300 .await
1301 .expect("the client write task joins");
1302 assert!(
1303 write_outcome.is_ok()
1304 || matches!(
1305 &write_outcome,
1306 Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe
1307 ),
1308 "the push bytes drain against a live session, or the \
1309 deny-then-close beat them to the close — never another \
1310 shape: {write_outcome:?}"
1311 );
1312
1313 let denial = {
1314 let mut buf = Vec::new();
1315 peer_read
1316 .read_to_end(&mut buf)
1317 .await
1318 .expect("the denial body reads to the close");
1319 buf
1320 };
1321 assert_eq!(
1322 &denial[4..],
1323 format!("ERR access denied: {}", "owner/gated").as_bytes(),
1324 "the denial is the one wire shape — its message the \
1325 client-requested id and nothing else"
1326 );
1327
1328 drop(peer_read);
1329 let result = joined
1330 .await
1331 .expect("the session task joins")
1332 .expect_err("the denial ends the session denied");
1333 assert!(
1334 matches!(result, SubstrateError::AccessDenied),
1335 "the collapse is the one generic denial: {result:?}"
1336 );
1337 assert!(
1338 assembly.refs().list_refs(&record).await.unwrap().len() <= 1,
1339 "only the seeded HEAD row exists — no ungranted write landed"
1340 );
1341 }
1342
1343 #[tokio::test(flavor = "multi_thread")]
1347 async fn the_chain_rides_the_entry_and_drains_the_housekeeping_job() {
1348 let root = unique_root("chain");
1349 let options = AssemblyOptions {
1350 maintenance: crate::maintenance::MaintenanceOpts {
1351 worker_poll: Duration::from_millis(20),
1352 reconcile_tick: Duration::from_secs(60),
1353 sweep_spec: "@every 1h".to_owned(),
1354 ..Default::default()
1355 },
1356 ..AssemblyOptions::new(root.join("work"), root.join("large"))
1357 };
1358 let assembly = SubstrateAssembly::mem(
1359 Arc::new(alkstore_mem::MemStore::new()),
1360 mem_object_pool(),
1361 options,
1362 )
1363 .await
1364 .expect("the mem assembly composes");
1365
1366 let identity = identity();
1367 let record = granted_record("owner/hk", &identity);
1368 assembly.registry_store().put_repo(&record).await.unwrap();
1369
1370 let seen = Arc::new(parking_lot::Mutex::new(Vec::new()));
1371 let stop = alkstore::StopToken::new();
1372 let worker = {
1373 let seen = seen.clone();
1374 tokio::spawn({
1375 let stop = stop.clone();
1376 let seen_probe = seen.clone();
1377 async move {
1378 let result = assembly
1379 .chain()
1380 .run("entry-worker", stop, move |event| {
1381 seen_probe.lock().push(event);
1382 })
1383 .await;
1384 result.expect("the chain ran clean");
1385 }
1386 })
1387 };
1388
1389 let deadline = Instant::now() + Duration::from_secs(5);
1390 while Instant::now() < deadline {
1391 if seen
1392 .lock()
1393 .iter()
1394 .any(|event| matches!(event, crate::maintenance::WorkerEvent::PackHousekeeping(_)))
1395 {
1396 break;
1397 }
1398 tokio::time::sleep(Duration::from_millis(20)).await;
1399 }
1400 stop.cancel();
1401
1402 tokio::time::timeout(Duration::from_secs(5), worker)
1403 .await
1404 .expect("the chain wound down inside the deadline")
1405 .expect("the chain task joins clean");
1406
1407 assert!(
1408 seen.lock()
1409 .iter()
1410 .any(|event| matches!(event, crate::maintenance::WorkerEvent::PackHousekeeping(_))),
1411 "the chain drained the announced mutation's housekeeping job"
1412 );
1413 }
1414
1415 struct SchedulerDeadStore {
1420 inner: Box<dyn AlkStore>,
1421 }
1422
1423 impl AlkStore for SchedulerDeadStore {
1424 fn begin_tx(
1425 &self,
1426 ) -> alkstore::BoxedFuture<'_, alkstore::Result<Box<dyn alkstore::TxHandle + Send>>>
1427 {
1428 self.inner.begin_tx()
1429 }
1430
1431 fn notify<'a>(
1432 &'a self,
1433 channel: &str,
1434 payload: Value,
1435 ) -> alkstore::BoxedFuture<'a, alkstore::Result<()>> {
1436 self.inner.notify(channel, payload)
1437 }
1438
1439 fn listen<'a>(
1440 &'a self,
1441 channel: &str,
1442 ) -> alkstore::BoxedFuture<'a, alkstore::Result<Box<dyn alkstore::WakeReceiver>>> {
1443 self.inner.listen(channel)
1444 }
1445
1446 fn stream<'a>(
1447 &'a self,
1448 name: &str,
1449 ) -> alkstore::BoxedFuture<'a, alkstore::Result<Box<dyn alkstore::StreamHandle>>> {
1450 self.inner.stream(name)
1451 }
1452
1453 fn queue<'a>(
1454 &'a self,
1455 name: &str,
1456 opts: alkstore::QueueOpts,
1457 ) -> alkstore::BoxedFuture<'a, alkstore::Result<Box<dyn alkstore::Queue>>> {
1458 self.inner.queue(name, opts)
1459 }
1460
1461 fn outbox<'a>(
1462 &'a self,
1463 name: &str,
1464 ) -> alkstore::BoxedFuture<'a, alkstore::Result<Box<dyn alkstore::Outbox>>> {
1465 self.inner.outbox(name)
1466 }
1467
1468 fn try_lock<'a>(
1469 &'a self,
1470 name: &str,
1471 owner: &str,
1472 ttl: i64,
1473 ) -> alkstore::BoxedFuture<'a, alkstore::Result<Option<Box<dyn alkstore::Lock>>>> {
1474 self.inner.try_lock(name, owner, ttl)
1475 }
1476
1477 fn schedule<'a>(
1478 &'a self,
1479 name: &str,
1480 spec: &str,
1481 queue: &str,
1482 payload: Value,
1483 opts: alkstore::ScheduleOpts,
1484 ) -> alkstore::BoxedFuture<'a, alkstore::Result<alkstore::Schedule>> {
1485 self.inner.schedule(name, spec, queue, payload, opts)
1486 }
1487
1488 fn unschedule<'a>(
1489 &'a self,
1490 name: &str,
1491 ) -> alkstore::BoxedFuture<'a, alkstore::Result<bool>> {
1492 self.inner.unschedule(name)
1493 }
1494
1495 fn run_schedules<'a>(
1496 &'a self,
1497 _stop: alkstore::StopToken,
1498 ) -> alkstore::BoxedFuture<'a, alkstore::Result<()>> {
1499 Box::pin(async { Err(alkstore::Error::database("the scheduler leg died (probe)")) })
1500 }
1501 }
1502
1503 #[tokio::test(flavor = "multi_thread")]
1508 async fn a_scheduler_leg_failure_ends_the_chain_with_the_legs_wound_down() {
1509 let root = unique_root("ob");
1510 let options = AssemblyOptions::new(root.join("work"), root.join("large"));
1511 let assembly = SubstrateAssembly::mem(
1512 Arc::new(SchedulerDeadStore {
1513 inner: Box::new(alkstore_mem::MemStore::new()),
1514 }) as Arc<dyn AlkStore>,
1515 mem_object_pool(),
1516 options,
1517 )
1518 .await
1519 .expect("the mem assembly composes");
1520
1521 let stop = alkstore::StopToken::new();
1522 let run =
1523 tokio::spawn(async move { assembly.chain().run("ob-worker", stop, |_| {}).await });
1524 let outcome = tokio::time::timeout(Duration::from_millis(750), run)
1525 .await
1526 .expect(
1527 "the chain returned inside the bound — the legs were wound \
1528 down by the wired token, not outwaited into the join's \
1529 abort window",
1530 )
1531 .expect("the chain task joins");
1532 let _error = outcome.expect_err("the scheduler leg's failure is the chain's return");
1533 }
1534
1535 #[tokio::test(flavor = "multi_thread")]
1541 async fn the_through_store_serving_shape_composes_at_the_entry() {
1542 let root = unique_root("through-store");
1543 let options = AssemblyOptions {
1544 pack_serving: PackServing::ThroughStore,
1545 ..AssemblyOptions::new(root.join("work"), root.join("large"))
1546 };
1547 let blobs = Arc::new(
1548 alkblobs::Store::dual_tier_mem_local(
1549 root.join("large"),
1550 alkblobs::DEFAULT_DISPATCH_THRESHOLD,
1551 )
1552 .await
1553 .expect("the dual-tier mem object pool constructs"),
1554 );
1555 let assembly =
1556 SubstrateAssembly::mem(Arc::new(alkstore_mem::MemStore::new()), blobs, options)
1557 .await
1558 .expect("the mem assembly composes");
1559
1560 let identity = identity();
1561 let record = granted_record("owner/served", &identity);
1562 assembly.registry_store().put_repo(&record).await.unwrap();
1563 let (pack, tip, base, tree_tip, blob) = chain_pack();
1564 run_push(
1565 "owner/served",
1566 &identity,
1567 assembly.registry().clone(),
1568 assembly.refs().clone(),
1569 assembly.packgen().clone(),
1570 assembly.ingest().clone(),
1571 &[create_command("refs/heads/main", tip)],
1572 &pack,
1573 )
1574 .await;
1575
1576 #[derive(Clone)]
1577 struct SinkBytes(Arc<parking_lot::Mutex<Vec<u8>>>);
1578 impl SinkBytes {
1579 fn bytes(&self) -> Vec<u8> {
1580 self.0.lock().clone()
1581 }
1582 }
1583 impl std::io::Write for SinkBytes {
1584 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
1585 self.0.lock().extend_from_slice(buf);
1586 Ok(buf.len())
1587 }
1588 fn flush(&mut self) -> std::io::Result<()> {
1589 Ok(())
1590 }
1591 }
1592 let sink = SinkBytes(Arc::new(parking_lot::Mutex::new(Vec::new())));
1593 assembly
1594 .packgen()
1595 .generate(
1596 &record,
1597 vec![tip],
1598 Vec::new(),
1599 Limits::default(),
1600 Box::new(sink.clone()),
1601 )
1602 .await
1603 .expect("the fetch generates over the through-store shape");
1604
1605 let pack = sink.bytes();
1606 let mut entries = gix_pack::data::input::BytesToEntriesIter::new_from_header(
1607 std::io::Cursor::new(&pack),
1608 gix_pack::data::input::Mode::Verify,
1609 gix_pack::data::input::EntryDataMode::Keep,
1610 HashKind::Sha1,
1611 )
1612 .expect("the pack stream starts with a decodable header");
1613 let mut ids = Vec::new();
1614 let mut decompressor = gix_zlib::Decompress::new();
1615 for entry in &mut entries {
1616 let entry = entry.expect("every pack entry decodes");
1617 let compressed = entry.compressed.expect("keep mode retains entry bytes");
1618 let mut object = vec![0; entry.decompressed_size as usize];
1619 gix_zlib::stream::inflate::read(
1620 &mut std::io::Cursor::new(compressed),
1621 &mut decompressor,
1622 &mut object,
1623 )
1624 .expect("the entry inflates");
1625 decompressor.reset();
1626 let kind = match entry.header {
1627 PackHeader::Blob => Kind::Blob,
1628 PackHeader::Tree => Kind::Tree,
1629 PackHeader::Commit => Kind::Commit,
1630 PackHeader::Tag => Kind::Tag,
1631 other => panic!("the loose closure packs no deltas: {other:?}"),
1632 };
1633 ids.push(oid_of(kind, &object));
1634 }
1635 let tree_base = oid_of(Kind::Tree, &tree_bytes(&[("a.txt", &blob)]));
1636 let expected = std::collections::BTreeSet::from([tip, base, tree_base, tree_tip, blob]);
1637 assert_eq!(
1638 ids.iter()
1639 .copied()
1640 .collect::<std::collections::BTreeSet<ObjectId>>(),
1641 expected,
1642 "the through-store generation packs exactly the pushed chain's closure"
1643 );
1644
1645 let _ = std::fs::remove_dir_all(&root);
1646 }
1647
1648 #[cfg(feature = "sqlite")]
1657 #[tokio::test(flavor = "multi_thread")]
1658 async fn the_sqlite_assembly_composes_default_and_carries_the_receive_path() {
1659 let root = unique_root("sqlite");
1660 std::fs::create_dir_all(&root).unwrap();
1661 let options = AssemblyOptions::new(root.join("work"), root.join("large"));
1662 let assembly = SubstrateAssembly::sqlite(root.join("co-tenant.db"), options)
1663 .await
1664 .expect("the default assembly composes");
1665 let identity = identity();
1666 let record = granted_record("owner/composed-sqlite", &identity);
1667 assembly.registry_store().put_repo(&record).await.unwrap();
1668
1669 let events = feed_events(&assembly, 0).await;
1670 assert_eq!(
1671 events.len(),
1672 1,
1673 "the announced put fired through the twin assembly the entry unified"
1674 );
1675
1676 let (pack, tip, _base, _tree, _blob) = chain_pack();
1677 let report = run_push(
1678 "owner/composed-sqlite",
1679 &identity,
1680 assembly.registry().clone(),
1681 assembly.refs().clone(),
1682 assembly.packgen().clone(),
1683 assembly.ingest().clone(),
1684 &[create_command("refs/heads/main", tip)],
1685 &pack,
1686 )
1687 .await;
1688 assert_eq!(
1689 report,
1690 vec!["unpack ok".to_owned(), "ok refs/heads/main".to_owned()]
1691 );
1692
1693 let lines = assembly.refs().list_refs(&record).await.unwrap();
1694 assert_eq!(
1695 lines
1696 .iter()
1697 .find(|line| line.name == "refs/heads/main")
1698 .map(|line| line.oid),
1699 Some(Some(tip))
1700 );
1701
1702 let events = feed_events(&assembly, 1).await;
1703 assert_eq!(
1704 events,
1705 vec![crate::reactor::RegistryEvent::Mutation {
1706 seq: 2,
1707 repo_id: "owner/composed-sqlite".to_owned(),
1708 }],
1709 "the marker + announce legs rode the committed CAS"
1710 );
1711
1712 let report = assembly.blobs().sweep().await.expect("the sweep runs");
1713 assert!(
1714 report.aborted.is_none() && report.deleted == 0,
1715 "the pushed chain is covered (the seeded closure over the co-tenant rows)"
1716 );
1717
1718 let _ = std::fs::remove_dir_all(&root);
1719 }
1720
1721 #[cfg(feature = "sqlite")]
1725 #[tokio::test]
1726 async fn the_sqlite_entry_refuses_memory_mode() {
1727 let root = unique_root("dq7");
1728 let options = AssemblyOptions::new(root.join("work"), root.join("large"));
1729 let outcome = SubstrateAssembly::sqlite(":memory:", options).await;
1730 assert!(matches!(outcome, Err(OpenError::Invalid(_))));
1731 }
1732
1733 #[cfg(all(feature = "lfs", feature = "sqlite"))]
1742 #[tokio::test(flavor = "multi_thread")]
1743 async fn the_sqlite_row_store_asserts_and_the_rows_leave_with_the_repo() {
1744 let root = unique_root("lfs-rows");
1745 std::fs::create_dir_all(&root).unwrap();
1746 let options = AssemblyOptions::new(root.join("work"), root.join("large"));
1747 let assembly = SubstrateAssembly::sqlite(root.join("co-tenant.db"), options)
1748 .await
1749 .expect("the default assembly composes");
1750 run_lfs_rows_removal_leg(&assembly).await;
1751 let _ = std::fs::remove_dir_all(&root);
1752 }
1753
1754 #[cfg(all(feature = "lfs", not(feature = "sqlite")))]
1755 #[tokio::test(flavor = "multi_thread")]
1756 async fn the_mem_row_store_asserts_and_the_rows_leave_with_the_repo() {
1757 let assembly = mem_assembly("lfs-rows").await;
1758 run_lfs_rows_removal_leg(&assembly).await;
1759 }
1760
1761 #[cfg(all(feature = "lfs", feature = "sqlite"))]
1762 #[tokio::test(flavor = "multi_thread")]
1763 async fn the_mem_row_store_asserts_and_the_rows_leave_with_the_repo() {
1764 let assembly = mem_assembly("lfs-rows").await;
1765 run_lfs_rows_removal_leg(&assembly).await;
1766 }
1767
1768 #[cfg(feature = "lfs")]
1773 async fn run_lfs_rows_removal_leg(assembly: &SubstrateAssembly) {
1774 let identity = identity();
1775 assembly
1776 .registry_store()
1777 .put_repo(&granted_record("owner/victim-lfs", &identity))
1778 .await
1779 .unwrap();
1780 assembly
1781 .registry_store()
1782 .put_repo(&granted_record("owner/survivor-lfs", &identity))
1783 .await
1784 .unwrap();
1785
1786 let shared_oid = "01".repeat(32);
1787 assembly
1788 .lfs_rows()
1789 .assert("owner/victim-lfs", &shared_oid, 16)
1790 .await
1791 .unwrap();
1792 assembly
1793 .lfs_rows()
1794 .assert("owner/survivor-lfs", &shared_oid, 16)
1795 .await
1796 .unwrap();
1797 let rows = assembly.lfs_rows().rows().await.unwrap();
1798 assert_eq!(rows.len(), 2);
1799 for row in &rows {
1800 assert_eq!(row.refcount, 2);
1801 }
1802
1803 assembly
1804 .registry_store()
1805 .remove_repo("owner/victim-lfs")
1806 .await
1807 .unwrap();
1808 let rows = assembly.lfs_rows().rows().await.unwrap();
1809 assert_eq!(
1810 rows,
1811 vec![crate::lfs::LfsRow {
1812 repo_id: "owner/survivor-lfs".to_owned(),
1813 oid: shared_oid,
1814 size: 16,
1815 refcount: 1,
1816 }],
1817 "the victim's rows went with the repo; the survivor carries the recomputed count"
1818 );
1819 }
1820
1821 #[cfg(feature = "lfs")]
1827 async fn put_media(
1828 assembly: &SubstrateAssembly,
1829 content: &'static [u8],
1830 ) -> (String, alkblobs::Key) {
1831 let (key, _pin) = assembly
1832 .blobs()
1833 .put_as(
1834 alkblobs::Algorithm::GitObjectSha256,
1835 Some(content.len() as u64),
1836 std::io::Cursor::new(content),
1837 )
1838 .await
1839 .expect("the media put lands");
1840 let hex: String = key
1841 .digest()
1842 .bytes()
1843 .iter()
1844 .map(|b| format!("{b:02x}"))
1845 .collect();
1846 (hex, key)
1847 }
1848
1849 #[cfg(feature = "lfs")]
1850 async fn pool_has(assembly: &SubstrateAssembly, key: &alkblobs::Key) -> bool {
1851 assembly
1852 .blobs()
1853 .stat(&key.as_bytes())
1854 .await
1855 .expect("the pool probe reads")
1856 .is_some()
1857 }
1858
1859 #[cfg(feature = "lfs")]
1868 #[tokio::test(flavor = "multi_thread")]
1869 async fn the_mem_entrys_lfs_source_reprotects_and_reclaims_media() {
1870 let assembly = mem_assembly("lfs-gc").await;
1871 let _token = assembly.lfs_registration();
1872
1873 let (shared_oid, shared_key) = put_media(&assembly, b"lfs media shared\n").await;
1874 let (_, orphan_key) = put_media(&assembly, b"lfs media orphan\n").await;
1875 let identity = identity();
1876 assembly
1877 .registry_store()
1878 .put_repo(&granted_record("owner/repro-1", &identity))
1879 .await
1880 .unwrap();
1881 assembly
1882 .registry_store()
1883 .put_repo(&granted_record("owner/repro-2", &identity))
1884 .await
1885 .unwrap();
1886 assembly
1887 .lfs_rows()
1888 .assert("owner/repro-1", &shared_oid, 16)
1889 .await
1890 .unwrap();
1891 assembly
1892 .lfs_rows()
1893 .assert("owner/repro-2", &shared_oid, 16)
1894 .await
1895 .unwrap();
1896
1897 let first = assembly.blobs().sweep().await.expect("the sweep runs");
1898 assert!(first.aborted.is_none());
1899 assert_eq!(
1900 first.candidates_staged, 1,
1901 "only the never-asserted orphan marks uncovered"
1902 );
1903 assert_eq!(first.deleted, 1, "the orphan reclaims (POC-L's datum)");
1904 assert!(pool_has(&assembly, &shared_key).await, "referenced survive");
1905 assert!(
1906 !pool_has(&assembly, &orphan_key).await,
1907 "the orphan is gone from the pool"
1908 );
1909
1910 assembly
1911 .lfs_rows()
1912 .remove("owner/repro-1", &shared_oid)
1913 .await
1914 .unwrap();
1915 let second = assembly.blobs().sweep().await.expect("the sweep runs");
1916 assert!(
1917 second.deleted == 0 && second.candidates_staged == 0,
1918 "one row removed — the key stays protected while any row remains"
1919 );
1920 assert!(pool_has(&assembly, &shared_key).await);
1921
1922 assembly
1923 .lfs_rows()
1924 .remove("owner/repro-2", &shared_oid)
1925 .await
1926 .unwrap();
1927 let third = assembly.blobs().sweep().await.expect("the sweep runs");
1928 assert_eq!(
1929 third.deleted, 1,
1930 "the last row's removal makes the media sweep-eligible"
1931 );
1932 assert!(!pool_has(&assembly, &shared_key).await);
1933
1934 let (victim_oid, victim_key) = put_media(&assembly, b"lfs victim media\n").await;
1935 assembly
1936 .registry_store()
1937 .put_repo(&granted_record("owner/victim-lfs", &identity))
1938 .await
1939 .unwrap();
1940 assembly
1941 .lfs_rows()
1942 .assert("owner/victim-lfs", &victim_oid, 16)
1943 .await
1944 .unwrap();
1945 let fourth = assembly.blobs().sweep().await.expect("the sweep runs");
1946 assert!(
1947 fourth.deleted == 0 && fourth.candidates_staged == 0,
1948 "the row stands — the media re-protects"
1949 );
1950 assembly
1951 .registry_store()
1952 .remove_repo("owner/victim-lfs")
1953 .await
1954 .unwrap();
1955 assert!(
1956 assembly
1957 .lfs_rows()
1958 .repo_rows("owner/victim-lfs")
1959 .await
1960 .unwrap()
1961 .is_empty(),
1962 "the rows went with the repo (the record-removal leg)"
1963 );
1964 let fifth = assembly.blobs().sweep().await.expect("the sweep runs");
1965 assert_eq!(
1966 fifth.deleted, 1,
1967 "the repo-deletion reclaim — nothing LFS-specific (lfs.md §3)"
1968 );
1969 assert!(!pool_has(&assembly, &victim_key).await);
1970 }
1971
1972 #[cfg(all(feature = "lfs", feature = "sqlite"))]
1980 #[tokio::test(flavor = "multi_thread")]
1981 async fn the_sqlite_entrys_lfs_source_reprotects_media_across_the_sweep() {
1982 let root = unique_root("lfs-gc");
1983 std::fs::create_dir_all(&root).unwrap();
1984 let options = AssemblyOptions::new(root.join("work"), root.join("large"));
1985 let assembly = SubstrateAssembly::sqlite(root.join("co-tenant.db"), options)
1986 .await
1987 .expect("the default assembly composes");
1988
1989 let (shared_oid, shared_key) = put_media(&assembly, b"lfs media shared\n").await;
1990 let identity = identity();
1991 assembly
1992 .registry_store()
1993 .put_repo(&granted_record("owner/repro-1", &identity))
1994 .await
1995 .unwrap();
1996 assembly
1997 .registry_store()
1998 .put_repo(&granted_record("owner/repro-2", &identity))
1999 .await
2000 .unwrap();
2001 assembly
2002 .lfs_rows()
2003 .assert("owner/repro-1", &shared_oid, 16)
2004 .await
2005 .unwrap();
2006 assembly
2007 .lfs_rows()
2008 .assert("owner/repro-2", &shared_oid, 16)
2009 .await
2010 .unwrap();
2011
2012 let first = assembly.blobs().sweep().await.expect("the sweep runs");
2013 assert!(first.aborted.is_none());
2014 assert_eq!(
2015 first.candidates_staged, 0,
2016 "the rows re-protect the media — the registered source's mark consult"
2017 );
2018 assert_eq!(first.deleted, 0);
2019
2020 assembly
2021 .lfs_rows()
2022 .remove("owner/repro-1", &shared_oid)
2023 .await
2024 .unwrap();
2025 let second = assembly.blobs().sweep().await.expect("the sweep runs");
2026 assert!(
2027 second.deleted == 0 && second.candidates_staged == 0,
2028 "one row removed — the survivor re-protects across the durable window"
2029 );
2030 assert!(pool_has(&assembly, &shared_key).await);
2031
2032 assembly
2033 .lfs_rows()
2034 .remove("owner/repro-2", &shared_oid)
2035 .await
2036 .unwrap();
2037 let third = assembly.blobs().sweep().await.expect("the sweep runs");
2038 assert_eq!(
2039 third.candidates_staged, 1,
2040 "the last row's removal marks the media — staged into the window"
2041 );
2042 assert_eq!(third.deleted, 0, "the window defers the reclaim");
2043 assert!(pool_has(&assembly, &shared_key).await);
2044
2045 let _ = std::fs::remove_dir_all(&root);
2046 }
2047
2048 #[cfg(feature = "postgres")]
2051 mod live {
2052 use super::*;
2053 use crate::postgres::live_harness::{drop_schema, harness_dsn, unique_schema, LIVE_GATE};
2054
2055 #[tokio::test(flavor = "multi_thread")]
2065 async fn the_postgres_assembly_composes_and_carries_the_receive_path() {
2066 let Some(dsn) = harness_dsn() else {
2067 eprintln!(
2068 "skipping (no postgres harness server): set \
2069 ALKGIT_SUBSTRATE_PG_HOST (or the ALKBLOBS_PG_* harness env)"
2070 );
2071 return;
2072 };
2073 let _gate = LIVE_GATE.lock().await;
2074 let schema = unique_schema("compose");
2075 let registry = crate::postgres::PgRegistryOptions {
2076 schema: schema.clone(),
2077 engine: alkstore_postgres::PgOpts {
2078 schema: format!("{schema}_engine"),
2079 ..alkstore_postgres::PgOpts::default()
2080 },
2081 blobs: alkblobs::PgOpts {
2082 schema: format!("{schema}_blobs"),
2083 ..alkblobs::PgOpts::default()
2084 },
2085 };
2086 let root = unique_root("pg-compose");
2087 let options = AssemblyOptions::new(root.join("work"), root.join("large"));
2088 let assembly = match SubstrateAssembly::postgres(&dsn, registry.clone(), options).await
2089 {
2090 Ok(assembly) => assembly,
2091 Err(error) => {
2092 drop_schema(&dsn, &schema).await;
2093 drop_schema(&dsn, &format!("{}_engine", schema)).await;
2094 drop_schema(&dsn, &format!("{}_blobs", schema)).await;
2095 panic!("the pg assembly composes on the harness: {error}");
2096 }
2097 };
2098
2099 let identity = identity();
2100 let record = granted_record("owner/composed-pg", &identity);
2101 assembly.registry_store().put_repo(&record).await.unwrap();
2102 assert_eq!(
2103 assembly
2104 .registry()
2105 .resolve("owner/composed-pg")
2106 .await
2107 .unwrap(),
2108 record,
2109 "the provider carried the fleet record"
2110 );
2111
2112 let (pack, tip, _base, _tree, _blob) = chain_pack();
2113 let report = run_push(
2114 "owner/composed-pg",
2115 &identity,
2116 assembly.registry().clone(),
2117 assembly.refs().clone(),
2118 assembly.packgen().clone(),
2119 assembly.ingest().clone(),
2120 &[create_command("refs/heads/main", tip)],
2121 &pack,
2122 )
2123 .await;
2124 assert_eq!(
2125 report,
2126 vec!["unpack ok".to_owned(), "ok refs/heads/main".to_owned()]
2127 );
2128
2129 let lines = assembly.refs().list_refs(&record).await.unwrap();
2130 assert_eq!(
2131 lines
2132 .iter()
2133 .find(|line| line.name == "refs/heads/main")
2134 .map(|line| line.oid),
2135 Some(Some(tip))
2136 );
2137
2138 let events = feed_events(&assembly, 1).await;
2139 assert_eq!(
2140 events,
2141 vec![crate::reactor::RegistryEvent::Mutation {
2142 seq: 2,
2143 repo_id: "owner/composed-pg".to_owned(),
2144 }],
2145 "the marker + announce legs rode the committed CAS through the \
2146 fleet assembly"
2147 );
2148
2149 let report = assembly.blobs().sweep().await.expect("the sweep runs");
2150 assert!(
2151 report.aborted.is_none() && report.deleted == 0,
2152 "the pushed chain is covered (the in-process closure source over \
2153 the co-tenant pg rows)"
2154 );
2155
2156 drop(assembly);
2157 drop_schema(&dsn, &schema).await;
2158 drop_schema(&dsn, &format!("{}_engine", schema)).await;
2159 drop_schema(&dsn, &format!("{}_blobs", schema)).await;
2160 let _ = std::fs::remove_dir_all(&root);
2161 }
2162
2163 #[tokio::test(flavor = "multi_thread")]
2183 async fn the_fleet_table_materialization_serves_the_shared_arbitration() {
2184 use std::time::Duration;
2185
2186 use alkblobs::LivenessRegistration;
2187
2188 use crate::odb::oid_key;
2189 use crate::postgres::live_harness::connect_direct;
2190
2191 let Some(dsn) = harness_dsn() else {
2192 eprintln!(
2193 "skipping (no postgres harness server): set \
2194 ALKGIT_SUBSTRATE_PG_HOST (or the ALKBLOBS_PG_* harness env)"
2195 );
2196 return;
2197 };
2198 let _gate = LIVE_GATE.lock().await;
2199 let schema = unique_schema("compose-fleet-table");
2200 let registry = crate::postgres::PgRegistryOptions {
2201 schema: schema.clone(),
2202 engine: alkstore_postgres::PgOpts {
2203 schema: format!("{schema}_engine"),
2204 ..alkstore_postgres::PgOpts::default()
2205 },
2206 blobs: alkblobs::PgOpts {
2207 schema: format!("{schema}_blobs"),
2208 ..alkblobs::PgOpts::default()
2209 },
2210 };
2211 let root = unique_root("pg-fleet-table");
2212 let options = AssemblyOptions::new(root.join("work"), root.join("large"));
2213 let assembly = match SubstrateAssembly::postgres(&dsn, registry.clone(), options).await
2214 {
2215 Ok(assembly) => assembly,
2216 Err(error) => {
2217 drop_schema(&dsn, &schema).await;
2218 drop_schema(&dsn, &format!("{}_engine", schema)).await;
2219 drop_schema(&dsn, &format!("{}_blobs", schema)).await;
2220 panic!("the pg assembly composes on the harness: {error}");
2221 }
2222 };
2223
2224 let identity = identity();
2227 let record = granted_record("owner/fleet-liveness", &identity);
2228 assembly.registry_store().put_repo(&record).await.unwrap();
2229 let (pack, tip, base, tree, blob) = chain_pack();
2230 let report = run_push(
2231 "owner/fleet-liveness",
2232 &identity,
2233 assembly.registry().clone(),
2234 assembly.refs().clone(),
2235 assembly.packgen().clone(),
2236 assembly.ingest().clone(),
2237 &[create_command("refs/heads/main", tip)],
2238 &pack,
2239 )
2240 .await;
2241 assert_eq!(
2242 report,
2243 vec!["unpack ok".to_owned(), "ok refs/heads/main".to_owned()]
2244 );
2245
2246 let chain_keys: Vec<alkblobs::Key> = [&tip, &base, &tree, &blob]
2247 .iter()
2248 .map(|oid| oid_key(oid).expect("the git-object key forms"))
2249 .collect();
2250 for key in &chain_keys {
2251 assert!(
2252 assembly
2253 .blobs()
2254 .stat(key.key_bytes().as_slice())
2255 .await
2256 .expect("the pool stats")
2257 .is_some(),
2258 "the pushed chain's object sits in the shared pool under its oid key"
2259 );
2260 }
2261 let pool = assembly.blobs().list().await.expect("the pool lists");
2262 let pool_len = pool.len() as u64;
2263 assert_eq!(
2264 pool_len, 5,
2265 "the pushed chain is the pool's whole inventory: {pool:?}"
2266 );
2267
2268 let probe = connect_direct(&dsn).await;
2274 probe
2275 .batch_execute(&format!(
2276 "CREATE TABLE \"{schema}\".git_live_keys (key bytea PRIMARY KEY)"
2277 ))
2278 .await
2279 .expect("the embedder table creates");
2280 let tip_key = oid_key(&tip).expect("the tip's key forms");
2281 probe
2282 .execute(
2283 &format!("INSERT INTO \"{schema}\".git_live_keys (key) VALUES ($1)"),
2284 &[&tip_key.key_bytes().as_slice()],
2285 )
2286 .await
2287 .expect("the tip's key materializes");
2288
2289 let fleet = alkblobs::Store::kv_only_postgres_fleet(
2294 &dsn,
2295 alkblobs::PgOpts {
2296 schema: format!("{schema}_blobs"),
2297 ..alkblobs::PgOpts::default()
2298 },
2299 1024 * 1024,
2300 Duration::ZERO,
2301 Duration::from_secs(60),
2302 )
2303 .await
2304 .expect("the fleet store constructs");
2305 let registration = fleet
2306 .register_liveness_source(LivenessRegistration::FleetTable(format!(
2307 "{schema}.git_live_keys"
2308 )))
2309 .expect("the embedder table reference registers");
2310 assert!(
2311 assembly
2312 .blobs()
2313 .register_liveness_source(LivenessRegistration::FleetTable(
2314 "some.where".to_owned()
2315 ))
2316 .is_err(),
2317 "the fleet form errors on the non-fleet entry store (the form boundary)"
2318 );
2319
2320 let first = fleet.sweep().await.expect("the sweep runs");
2323 assert_eq!(
2324 first.aborted, None,
2325 "a fleet-table-only registration admits the sweep"
2326 );
2327 assert_eq!(
2328 first.candidates_staged, pool_len,
2329 "the mark stages the chain — the table is never enumerated"
2330 );
2331 assert_eq!(first.deleted, 0, "nothing overdue at the staging mark");
2332
2333 let second = fleet.sweep().await.expect("the sweep executes");
2338 assert_eq!(second.aborted, None);
2339 assert_eq!(
2340 second.cancelled_by_arbitration, 1,
2341 "the materialized key cancelled at the SQL re-arbitration"
2342 );
2343 assert_eq!(
2344 second.deleted,
2345 pool_len - 1,
2346 "the unmaterialized keys reclaimed at the same execution"
2347 );
2348 assert!(
2349 assembly
2350 .blobs()
2351 .stat(tip_key.key_bytes().as_slice())
2352 .await
2353 .expect("the pool stats")
2354 .is_some(),
2355 "the protected key rides the table's protection"
2356 );
2357
2358 probe
2360 .batch_execute(&format!("DELETE FROM \"{schema}\".git_live_keys"))
2361 .await
2362 .expect("the materializer's removal");
2363 let third = fleet.sweep().await.expect("the sweep executes");
2364 assert_eq!(third.aborted, None);
2365 assert_eq!(
2366 (third.deleted, third.cancelled_by_arbitration),
2367 (1, 0),
2368 "the unprotected key reclaims once its table row left"
2369 );
2370 for key in &chain_keys {
2371 assert!(
2372 assembly
2373 .blobs()
2374 .stat(key.key_bytes().as_slice())
2375 .await
2376 .expect("the pool stats")
2377 .is_none(),
2378 "the pool emptied once the materialization left"
2379 );
2380 }
2381 let _ = fleet.unregister_liveness_source(registration);
2382
2383 drop(assembly);
2384 drop(probe);
2385 drop_schema(&dsn, &schema).await;
2386 drop_schema(&dsn, &format!("{}_engine", schema)).await;
2387 drop_schema(&dsn, &format!("{}_blobs", schema)).await;
2388 let _ = std::fs::remove_dir_all(&root);
2389 }
2390
2391 #[cfg(feature = "lfs")]
2396 #[tokio::test(flavor = "multi_thread")]
2397 async fn the_postgres_row_store_asserts_and_the_rows_leave_with_the_repo() {
2398 let Some(dsn) = harness_dsn() else {
2399 eprintln!(
2400 "skipping (no postgres harness server): set \
2401 ALKGIT_SUBSTRATE_PG_HOST (or the ALKBLOBS_PG_* harness env)"
2402 );
2403 return;
2404 };
2405 let _gate = LIVE_GATE.lock().await;
2406 let schema = unique_schema("compose-lfs-rows");
2407 let registry = crate::postgres::PgRegistryOptions {
2408 schema: schema.clone(),
2409 engine: alkstore_postgres::PgOpts {
2410 schema: format!("{schema}_engine"),
2411 ..alkstore_postgres::PgOpts::default()
2412 },
2413 blobs: alkblobs::PgOpts {
2414 schema: format!("{schema}_blobs"),
2415 ..alkblobs::PgOpts::default()
2416 },
2417 };
2418 let root = unique_root("pg-compose-lfs");
2419 let options = AssemblyOptions::new(root.join("work"), root.join("large"));
2420 let assembly = match SubstrateAssembly::postgres(&dsn, registry.clone(), options).await
2421 {
2422 Ok(assembly) => assembly,
2423 Err(error) => {
2424 drop_schema(&dsn, &schema).await;
2425 drop_schema(&dsn, &format!("{}_engine", schema)).await;
2426 drop_schema(&dsn, &format!("{}_blobs", schema)).await;
2427 panic!("the pg assembly composes on the harness: {error}");
2428 }
2429 };
2430
2431 let identity = identity();
2432 assembly
2433 .registry_store()
2434 .put_repo(&granted_record("owner/victim-lfs", &identity))
2435 .await
2436 .unwrap();
2437 assembly
2438 .registry_store()
2439 .put_repo(&granted_record("owner/survivor-lfs", &identity))
2440 .await
2441 .unwrap();
2442
2443 let shared_oid = "01".repeat(32);
2444 assembly
2445 .lfs_rows()
2446 .assert("owner/victim-lfs", &shared_oid, 16)
2447 .await
2448 .unwrap();
2449 assembly
2450 .lfs_rows()
2451 .assert("owner/survivor-lfs", &shared_oid, 16)
2452 .await
2453 .unwrap();
2454
2455 assembly
2456 .registry_store()
2457 .remove_repo("owner/victim-lfs")
2458 .await
2459 .unwrap();
2460 assert_eq!(
2461 assembly.lfs_rows().rows().await.unwrap(),
2462 vec![crate::lfs::LfsRow {
2463 repo_id: "owner/survivor-lfs".to_owned(),
2464 oid: shared_oid,
2465 size: 16,
2466 refcount: 1,
2467 }],
2468 "the victim's rows went with the repo; the survivor carries the recomputed count"
2469 );
2470
2471 drop(assembly);
2472 drop_schema(&dsn, &schema).await;
2473 drop_schema(&dsn, &format!("{}_engine", schema)).await;
2474 drop_schema(&dsn, &format!("{}_blobs", schema)).await;
2475 let _ = std::fs::remove_dir_all(&root);
2476 }
2477
2478 #[cfg(feature = "lfs")]
2487 #[tokio::test(flavor = "multi_thread")]
2488 async fn the_postgres_entrys_lfs_source_reprotects_media_across_the_sweep() {
2489 let Some(dsn) = harness_dsn() else {
2490 eprintln!(
2491 "skipping (no postgres harness server): set \
2492 ALKGIT_SUBSTRATE_PG_HOST (or the ALKBLOBS_PG_* harness env)"
2493 );
2494 return;
2495 };
2496 let _gate = LIVE_GATE.lock().await;
2497 let schema = unique_schema("compose-lfs-gc");
2498 let registry = crate::postgres::PgRegistryOptions {
2499 schema: schema.clone(),
2500 engine: alkstore_postgres::PgOpts {
2501 schema: format!("{schema}_engine"),
2502 ..alkstore_postgres::PgOpts::default()
2503 },
2504 blobs: alkblobs::PgOpts {
2505 schema: format!("{schema}_blobs"),
2506 ..alkblobs::PgOpts::default()
2507 },
2508 };
2509 let root = unique_root("pg-compose-lfs-gc");
2510 let options = AssemblyOptions::new(root.join("work"), root.join("large"));
2511 let assembly = match SubstrateAssembly::postgres(&dsn, registry.clone(), options).await
2512 {
2513 Ok(assembly) => assembly,
2514 Err(error) => {
2515 drop_schema(&dsn, &schema).await;
2516 drop_schema(&dsn, &format!("{}_engine", schema)).await;
2517 drop_schema(&dsn, &format!("{}_blobs", schema)).await;
2518 panic!("the pg assembly composes on the harness: {error}");
2519 }
2520 };
2521
2522 let (shared_oid, shared_key) = put_media(&assembly, b"lfs media shared\n").await;
2523 let identity = identity();
2524 assembly
2525 .registry_store()
2526 .put_repo(&granted_record("owner/lfs-repro-1", &identity))
2527 .await
2528 .unwrap();
2529 assembly
2530 .registry_store()
2531 .put_repo(&granted_record("owner/lfs-repro-2", &identity))
2532 .await
2533 .unwrap();
2534 assembly
2535 .lfs_rows()
2536 .assert("owner/lfs-repro-1", &shared_oid, 16)
2537 .await
2538 .unwrap();
2539 assembly
2540 .lfs_rows()
2541 .assert("owner/lfs-repro-2", &shared_oid, 16)
2542 .await
2543 .unwrap();
2544
2545 let first = assembly.blobs().sweep().await.expect("the sweep runs");
2546 assert!(
2547 first.aborted.is_none() && first.candidates_staged == 0 && first.deleted == 0,
2548 "the rows re-protect the media — the registered source consulted the \
2549 co-tenant pg rows"
2550 );
2551 assert!(pool_has(&assembly, &shared_key).await);
2552
2553 assembly
2554 .lfs_rows()
2555 .remove("owner/lfs-repro-1", &shared_oid)
2556 .await
2557 .unwrap();
2558 let second = assembly.blobs().sweep().await.expect("the sweep runs");
2559 assert!(
2560 second.deleted == 0 && second.candidates_staged == 0,
2561 "one row removed — the survivor re-protects across the durable window"
2562 );
2563
2564 assembly
2565 .registry_store()
2566 .remove_repo("owner/lfs-repro-2")
2567 .await
2568 .unwrap();
2569 let third = assembly.blobs().sweep().await.expect("the sweep runs");
2570 assert_eq!(
2571 third.candidates_staged, 1,
2572 "the rows all left with the repo — the mark uncovered the media"
2573 );
2574 assert_eq!(third.deleted, 0, "the durable window defers the reclaim");
2575 assert!(pool_has(&assembly, &shared_key).await);
2576
2577 drop(assembly);
2578 drop_schema(&dsn, &schema).await;
2579 drop_schema(&dsn, &format!("{}_engine", schema)).await;
2580 drop_schema(&dsn, &format!("{}_blobs", schema)).await;
2581 let _ = std::fs::remove_dir_all(&root);
2582 }
2583 }
2584}