1use crate::pinning::{PinManager, PinType};
23use crate::traits::BlockStore;
24use ipfrs_core::{Cid, Error, Result};
25use std::collections::HashSet;
26use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
27use std::sync::Arc;
28use std::time::{Duration, Instant};
29
30#[derive(Debug, Clone)]
32pub struct GcConfig {
33 pub max_blocks_per_run: usize,
35 pub time_limit: Option<Duration>,
37 pub incremental: bool,
39 pub batch_size: usize,
41 pub batch_delay: Duration,
43 pub dry_run: bool,
45}
46
47impl Default for GcConfig {
48 fn default() -> Self {
49 Self {
50 max_blocks_per_run: 0, time_limit: None, incremental: false, batch_size: 1000, batch_delay: Duration::from_millis(10), dry_run: false,
56 }
57 }
58}
59
60impl GcConfig {
61 pub fn incremental() -> Self {
63 Self {
64 incremental: true,
65 ..Default::default()
66 }
67 }
68
69 pub fn dry_run() -> Self {
71 Self {
72 dry_run: true,
73 ..Default::default()
74 }
75 }
76
77 pub fn with_max_blocks(mut self, max: usize) -> Self {
79 self.max_blocks_per_run = max;
80 self
81 }
82
83 pub fn with_time_limit(mut self, duration: Duration) -> Self {
85 self.time_limit = Some(duration);
86 self
87 }
88}
89
90#[derive(Debug, Clone, Default)]
92pub struct GcResult {
93 pub blocks_collected: u64,
95 pub bytes_freed: u64,
97 pub blocks_marked: u64,
99 pub blocks_scanned: u64,
101 pub duration: Duration,
103 pub interrupted: bool,
105 pub errors: Vec<String>,
107}
108
109#[derive(Debug, Default)]
111pub struct GcStats {
112 pub total_runs: AtomicU64,
114 pub total_blocks_collected: AtomicU64,
116 pub total_bytes_freed: AtomicU64,
118 pub last_run_timestamp: AtomicU64,
120}
121
122impl GcStats {
123 pub fn record_run(&self, result: &GcResult) {
125 self.total_runs.fetch_add(1, Ordering::Relaxed);
126 self.total_blocks_collected
127 .fetch_add(result.blocks_collected, Ordering::Relaxed);
128 self.total_bytes_freed
129 .fetch_add(result.bytes_freed, Ordering::Relaxed);
130 self.last_run_timestamp.store(
131 std::time::SystemTime::now()
132 .duration_since(std::time::UNIX_EPOCH)
133 .unwrap_or_default()
134 .as_secs(),
135 Ordering::Relaxed,
136 );
137 }
138
139 pub fn snapshot(&self) -> GcStatsSnapshot {
141 GcStatsSnapshot {
142 total_runs: self.total_runs.load(Ordering::Relaxed),
143 total_blocks_collected: self.total_blocks_collected.load(Ordering::Relaxed),
144 total_bytes_freed: self.total_bytes_freed.load(Ordering::Relaxed),
145 last_run_timestamp: self.last_run_timestamp.load(Ordering::Relaxed),
146 }
147 }
148}
149
150#[derive(Debug, Clone)]
152pub struct GcStatsSnapshot {
153 pub total_runs: u64,
154 pub total_blocks_collected: u64,
155 pub total_bytes_freed: u64,
156 pub last_run_timestamp: u64,
157}
158
159pub type LinkResolver = Arc<dyn Fn(&Cid) -> Result<Vec<Cid>> + Send + Sync>;
161
162pub struct GarbageCollector<S: BlockStore> {
164 store: Arc<S>,
166 pin_manager: Arc<PinManager>,
168 link_resolver: LinkResolver,
170 config: GcConfig,
172 stats: GcStats,
174 cancel: AtomicBool,
176}
177
178impl<S: BlockStore> GarbageCollector<S> {
179 pub fn new(
187 store: Arc<S>,
188 pin_manager: Arc<PinManager>,
189 link_resolver: LinkResolver,
190 config: GcConfig,
191 ) -> Self {
192 Self {
193 store,
194 pin_manager,
195 link_resolver,
196 config,
197 stats: GcStats::default(),
198 cancel: AtomicBool::new(false),
199 }
200 }
201
202 pub fn new_flat(store: Arc<S>, pin_manager: Arc<PinManager>, config: GcConfig) -> Self {
204 let link_resolver: LinkResolver = Arc::new(|_| Ok(Vec::new()));
205 Self::new(store, pin_manager, link_resolver, config)
206 }
207
208 pub fn cancel(&self) {
210 self.cancel.store(true, Ordering::SeqCst);
211 }
212
213 pub fn reset_cancel(&self) {
215 self.cancel.store(false, Ordering::SeqCst);
216 }
217
218 fn is_cancelled(&self) -> bool {
220 self.cancel.load(Ordering::SeqCst)
221 }
222
223 pub fn stats(&self) -> GcStatsSnapshot {
225 self.stats.snapshot()
226 }
227
228 pub async fn collect(&self) -> Result<GcResult> {
230 self.reset_cancel();
231 let start_time = Instant::now();
232 let mut result = GcResult::default();
233
234 let marked = self.mark_phase(&mut result).await?;
236
237 if self.should_stop(start_time, &result) {
239 result.interrupted = true;
240 result.duration = start_time.elapsed();
241 self.stats.record_run(&result);
242 return Ok(result);
243 }
244
245 self.sweep_phase(&marked, &mut result).await?;
247
248 result.duration = start_time.elapsed();
249 self.stats.record_run(&result);
250 Ok(result)
251 }
252
253 #[allow(clippy::unused_async)]
255 async fn mark_phase(&self, result: &mut GcResult) -> Result<HashSet<Vec<u8>>> {
256 let mut marked: HashSet<Vec<u8>> = HashSet::new();
257 let mut to_process: Vec<Cid> = Vec::new();
258
259 let pins = self.pin_manager.list_pins()?;
261 for (cid, info) in pins {
262 if info.pin_type == PinType::Direct || info.pin_type == PinType::Recursive {
264 to_process.push(cid);
265 }
266 marked.insert(cid.to_bytes());
268 }
269
270 while let Some(cid) = to_process.pop() {
272 if self.is_cancelled() {
273 break;
274 }
275
276 match (self.link_resolver)(&cid) {
278 Ok(links) => {
279 for link in links {
280 let link_bytes = link.to_bytes();
281 if marked.insert(link_bytes) {
282 to_process.push(link);
284 }
285 }
286 }
287 Err(e) => {
288 result
289 .errors
290 .push(format!("Error resolving links for {cid}: {e}"));
291 }
292 }
293 }
294
295 result.blocks_marked = marked.len() as u64;
296 Ok(marked)
297 }
298
299 async fn sweep_phase(&self, marked: &HashSet<Vec<u8>>, result: &mut GcResult) -> Result<()> {
301 let start_time = Instant::now();
302 let all_cids = self.store.list_cids()?;
303 result.blocks_scanned = all_cids.len() as u64;
304
305 let mut to_delete = Vec::new();
306 let mut batch_count = 0;
307
308 for cid in all_cids {
309 if self.is_cancelled() || self.should_stop(start_time, result) {
310 result.interrupted = true;
311 break;
312 }
313
314 if self.config.max_blocks_per_run > 0
316 && result.blocks_collected >= self.config.max_blocks_per_run as u64
317 {
318 result.interrupted = true;
319 break;
320 }
321
322 let cid_bytes = cid.to_bytes();
323 if !marked.contains(&cid_bytes) {
324 if self.config.dry_run {
326 if let Ok(Some(block)) = self.store.get(&cid).await {
328 result.bytes_freed += block.size();
329 }
330 result.blocks_collected += 1;
331 } else {
332 to_delete.push(cid);
333 }
334
335 batch_count += 1;
336
337 if self.config.incremental && batch_count >= self.config.batch_size {
339 if !self.config.dry_run && !to_delete.is_empty() {
340 self.delete_batch(&to_delete, result).await?;
341 to_delete.clear();
342 }
343 batch_count = 0;
344 tokio::time::sleep(self.config.batch_delay).await;
345 }
346 }
347 }
348
349 if !self.config.dry_run && !to_delete.is_empty() {
351 self.delete_batch(&to_delete, result).await?;
352 }
353
354 Ok(())
355 }
356
357 async fn delete_batch(&self, cids: &[Cid], result: &mut GcResult) -> Result<()> {
359 for cid in cids {
360 if let Ok(Some(block)) = self.store.get(cid).await {
362 result.bytes_freed += block.size();
363 }
364
365 match self.store.delete(cid).await {
367 Ok(()) => {
368 result.blocks_collected += 1;
369 }
370 Err(e) => {
371 result
372 .errors
373 .push(format!("Error deleting block {cid}: {e}"));
374 }
375 }
376 }
377 Ok(())
378 }
379
380 fn should_stop(&self, start_time: Instant, _result: &GcResult) -> bool {
382 if self.is_cancelled() {
383 return true;
384 }
385
386 if let Some(limit) = self.config.time_limit {
387 if start_time.elapsed() > limit {
388 return true;
389 }
390 }
391
392 false
393 }
394}
395
396#[derive(Debug, Clone, Default)]
398pub enum GcPolicy {
399 #[default]
401 Manual,
402 TimeBased { interval_secs: u64 },
404 SpaceBased { threshold_percent: f64 },
406 Combined {
408 interval_secs: u64,
409 threshold_percent: f64,
410 },
411}
412
413pub struct GcScheduler<S: BlockStore + 'static> {
415 gc: Arc<GarbageCollector<S>>,
416 policy: GcPolicy,
417 running: AtomicBool,
418}
419
420impl<S: BlockStore + 'static> GcScheduler<S> {
421 pub fn new(gc: Arc<GarbageCollector<S>>, policy: GcPolicy) -> Self {
423 Self {
424 gc,
425 policy,
426 running: AtomicBool::new(false),
427 }
428 }
429
430 pub fn should_run(&self) -> bool {
432 match &self.policy {
433 GcPolicy::Manual => false,
434 GcPolicy::TimeBased { interval_secs } => {
435 let stats = self.gc.stats();
436 let now = std::time::SystemTime::now()
437 .duration_since(std::time::UNIX_EPOCH)
438 .unwrap_or_default()
439 .as_secs();
440 now.saturating_sub(stats.last_run_timestamp) >= *interval_secs
441 }
442 GcPolicy::SpaceBased { .. } => {
443 false
445 }
446 GcPolicy::Combined { interval_secs, .. } => {
447 let stats = self.gc.stats();
448 let now = std::time::SystemTime::now()
449 .duration_since(std::time::UNIX_EPOCH)
450 .unwrap_or_default()
451 .as_secs();
452 now.saturating_sub(stats.last_run_timestamp) >= *interval_secs
453 }
454 }
455 }
456
457 pub async fn maybe_run(&self) -> Option<GcResult> {
459 if !self.should_run() {
460 return None;
461 }
462
463 if self
465 .running
466 .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
467 .is_err()
468 {
469 return None;
470 }
471
472 let result = self.gc.collect().await.ok();
473 self.running.store(false, Ordering::SeqCst);
474 result
475 }
476
477 pub fn gc(&self) -> &GarbageCollector<S> {
479 &self.gc
480 }
481}
482
483#[derive(Debug, Clone)]
489pub struct OrphanGcConfig {
490 pub dry_run: bool,
492 pub min_age_secs: u64,
497 pub batch_size: usize,
499}
500
501impl Default for OrphanGcConfig {
502 fn default() -> Self {
503 Self {
504 dry_run: false,
505 min_age_secs: 3600, batch_size: 100,
507 }
508 }
509}
510
511impl OrphanGcConfig {
512 pub fn dry_run() -> Self {
514 Self {
515 dry_run: true,
516 ..Default::default()
517 }
518 }
519}
520
521#[derive(Debug, Clone, Default)]
523pub struct OrphanGcResult {
524 pub examined: usize,
526 pub collected: usize,
528 pub freed_bytes: usize,
530 pub errors: Vec<String>,
532 pub duration_ms: u64,
534}
535
536pub struct OrphanGarbageCollector {
543 config: OrphanGcConfig,
544}
545
546impl OrphanGarbageCollector {
547 pub fn new(config: OrphanGcConfig) -> Self {
549 Self { config }
550 }
551
552 pub async fn collect<S: BlockStore>(
562 &self,
563 store: &S,
564 pinned_cids: &std::collections::HashSet<String>,
565 ) -> ipfrs_core::Result<OrphanGcResult> {
566 self.collect_inner(store, pinned_cids, &HashSet::new())
567 .await
568 }
569
570 #[cfg(feature = "sled-backend")]
575 pub async fn collect_with_snapshot_registry<S: BlockStore>(
576 &self,
577 store: &S,
578 pinned_cids: &std::collections::HashSet<String>,
579 snapshot_registry: Option<&SledSnapshotPinRegistry>,
580 ) -> ipfrs_core::Result<OrphanGcResult> {
581 let snapshot_pinned: HashSet<String> = match snapshot_registry {
583 Some(reg) => reg.pinned_cid_strings()?,
584 None => HashSet::new(),
585 };
586 self.collect_inner(store, pinned_cids, &snapshot_pinned)
587 .await
588 }
589
590 async fn collect_inner<S: BlockStore>(
592 &self,
593 store: &S,
594 pinned_cids: &std::collections::HashSet<String>,
595 extra_pinned: &HashSet<String>,
596 ) -> ipfrs_core::Result<OrphanGcResult> {
597 let start = std::time::Instant::now();
598 let mut result = OrphanGcResult::default();
599
600 let all_cids = store.list_cids()?;
601
602 for cid in all_cids.iter().take(self.config.batch_size) {
603 result.examined += 1;
604 let cid_str = cid.to_string();
605
606 if pinned_cids.contains(&cid_str) || extra_pinned.contains(&cid_str) {
607 continue;
609 }
610
611 if self.config.dry_run {
613 if let Ok(Some(block)) = store.get(cid).await {
615 result.freed_bytes += block.data().len();
616 }
617 result.collected += 1;
618 } else {
619 match store.get(cid).await {
621 Ok(Some(block)) => {
622 result.freed_bytes += block.data().len();
623 }
624 Ok(None) => {} Err(e) => {
626 result.errors.push(format!("get error for {cid_str}: {e}"));
627 }
628 }
629
630 match store.delete(cid).await {
631 Ok(()) => {
632 result.collected += 1;
633 }
634 Err(e) => {
635 result
636 .errors
637 .push(format!("delete error for {cid_str}: {e}"));
638 }
639 }
640 }
641 }
642
643 result.duration_ms = start.elapsed().as_millis() as u64;
644 Ok(result)
645 }
646}
647
648#[cfg(feature = "sled-backend")]
666pub struct SledSnapshotPinRegistry {
667 tree: sled::Tree,
668}
669
670#[cfg(feature = "sled-backend")]
671impl SledSnapshotPinRegistry {
672 pub fn open(db: &sled::Db) -> Result<Self> {
674 let tree = db
675 .open_tree("snapshot_pins")
676 .map_err(|e| Error::Storage(format!("Failed to open snapshot_pins tree: {e}")))?;
677 Ok(Self { tree })
678 }
679
680 pub fn pin(&self, cid: &Cid, label: &str) -> Result<()> {
683 let key = cid.to_bytes();
684 let value = label.as_bytes().to_vec();
685 self.tree
686 .insert(key, value)
687 .map_err(|e| Error::Storage(format!("Failed to pin CID {cid}: {e}")))?;
688 self.tree
689 .flush()
690 .map_err(|e| Error::Storage(format!("Failed to flush snapshot_pins: {e}")))?;
691 Ok(())
692 }
693
694 pub fn unpin(&self, cid: &Cid) -> Result<()> {
696 let key = cid.to_bytes();
697 self.tree
698 .remove(key)
699 .map_err(|e| Error::Storage(format!("Failed to unpin CID {cid}: {e}")))?;
700 self.tree
701 .flush()
702 .map_err(|e| Error::Storage(format!("Failed to flush snapshot_pins: {e}")))?;
703 Ok(())
704 }
705
706 pub fn is_pinned(&self, cid: &Cid) -> Result<bool> {
708 let key = cid.to_bytes();
709 self.tree
710 .contains_key(&key)
711 .map_err(|e| Error::Storage(format!("Failed to check pin for CID {cid}: {e}")))
712 }
713
714 pub fn list_pinned(&self) -> Result<Vec<(Cid, String)>> {
716 let mut result = Vec::new();
717 for item in self.tree.iter() {
718 let (key, value) =
719 item.map_err(|e| Error::Storage(format!("Iteration error in snapshot_pins: {e}")))?;
720 let cid = Cid::try_from(key.to_vec()).map_err(|e| {
721 ipfrs_core::Error::Cid(format!("Invalid CID in snapshot_pins: {e}"))
722 })?;
723 let label = String::from_utf8_lossy(&value).into_owned();
724 result.push((cid, label));
725 }
726 Ok(result)
727 }
728
729 pub fn pin_count(&self) -> Result<usize> {
731 Ok(self.tree.len())
732 }
733
734 pub fn pinned_cid_strings(&self) -> Result<HashSet<String>> {
737 let mut set = HashSet::new();
738 for item in self.tree.iter() {
739 let (key, _) =
740 item.map_err(|e| Error::Storage(format!("Iteration error in snapshot_pins: {e}")))?;
741 let cid = Cid::try_from(key.to_vec()).map_err(|e| {
742 ipfrs_core::Error::Cid(format!("Invalid CID in snapshot_pins: {e}"))
743 })?;
744 set.insert(cid.to_string());
745 }
746 Ok(set)
747 }
748}
749
750pub fn snapshot_pin_id(snapshot_path: &std::path::Path) -> String {
757 use std::hash::{Hash, Hasher};
758 let mut hasher = std::collections::hash_map::DefaultHasher::new();
759 snapshot_path.hash(&mut hasher);
760 format!("snapshot:{:x}", hasher.finish())
761}
762
763pub struct SnapshotPinRegistry {
774 pinned_paths: std::collections::HashSet<std::path::PathBuf>,
775}
776
777impl SnapshotPinRegistry {
778 pub fn new() -> Self {
780 Self {
781 pinned_paths: std::collections::HashSet::new(),
782 }
783 }
784
785 pub fn pin_snapshot(&mut self, path: std::path::PathBuf) {
787 self.pinned_paths.insert(path);
788 }
789
790 pub fn unpin_snapshot(&mut self, path: &std::path::Path) {
794 self.pinned_paths.remove(path);
795 }
796
797 pub fn is_pinned(&self, path: &std::path::Path) -> bool {
799 self.pinned_paths.contains(path)
800 }
801
802 pub fn pinned_count(&self) -> usize {
804 self.pinned_paths.len()
805 }
806
807 pub fn pinned_paths(&self) -> impl Iterator<Item = &std::path::PathBuf> {
809 self.pinned_paths.iter()
810 }
811
812 pub fn is_empty(&self) -> bool {
814 self.pinned_paths.is_empty()
815 }
816}
817
818impl Default for SnapshotPinRegistry {
819 fn default() -> Self {
820 Self::new()
821 }
822}
823
824#[cfg(all(test, feature = "sled-backend"))]
825mod tests {
826 use super::*;
827 use crate::blockstore::{BlockStoreConfig, SledBlockStore};
828 use bytes::Bytes;
829 use ipfrs_core::Block;
830 use std::path::PathBuf;
831
832 fn make_test_block(data: &[u8]) -> Block {
833 Block::new(Bytes::copy_from_slice(data)).expect("test data is valid for block construction")
834 }
835
836 #[tokio::test]
837 async fn test_gc_collect_unreachable() {
838 let gc_path = std::env::temp_dir().join(format!("ipfrs-test-gc-{}", std::process::id()));
839 let config = BlockStoreConfig {
840 path: gc_path.clone(),
841 cache_size: 1024 * 1024,
842 };
843 let _ = std::fs::remove_dir_all(&config.path);
844
845 let store = Arc::new(
846 SledBlockStore::new(config).expect("failed to create SledBlockStore for GC test"),
847 );
848 let pin_manager = Arc::new(PinManager::new());
849
850 let block1 = make_test_block(b"block1");
852 let block2 = make_test_block(b"block2");
853 let block3 = make_test_block(b"block3");
854
855 store
856 .put(&block1)
857 .await
858 .expect("failed to put block1 into store");
859 store
860 .put(&block2)
861 .await
862 .expect("failed to put block2 into store");
863 store
864 .put(&block3)
865 .await
866 .expect("failed to put block3 into store");
867
868 pin_manager.pin(block1.cid()).expect("failed to pin block1");
870
871 let gc = GarbageCollector::new_flat(store.clone(), pin_manager, GcConfig::default());
873
874 let result = gc.collect().await.expect("GC collect failed");
876
877 assert_eq!(result.blocks_collected, 2);
879 assert_eq!(result.blocks_marked, 1);
880
881 assert!(store
883 .has(block1.cid())
884 .await
885 .expect("failed to check block1 existence"));
886 assert!(!store
888 .has(block2.cid())
889 .await
890 .expect("failed to check block2 existence"));
891 assert!(!store
892 .has(block3.cid())
893 .await
894 .expect("failed to check block3 existence"));
895
896 let _ = std::fs::remove_dir_all(&gc_path);
897 }
898
899 #[tokio::test]
900 async fn test_gc_dry_run() {
901 let path = std::env::temp_dir().join("ipfrs-test-gc-dry");
902 let config = BlockStoreConfig {
903 path: path.clone(),
904 cache_size: 1024 * 1024,
905 };
906 let _ = std::fs::remove_dir_all(&config.path);
907
908 let store =
909 Arc::new(SledBlockStore::new(config).expect("test: open sled block store for dry run"));
910 let pin_manager = Arc::new(PinManager::new());
911
912 let block1 = make_test_block(b"block1");
914 let block2 = make_test_block(b"block2");
915
916 store.put(&block1).await.expect("test: put block1");
917 store.put(&block2).await.expect("test: put block2");
918
919 pin_manager.pin(block1.cid()).expect("test: pin block1");
921
922 let gc = GarbageCollector::new_flat(store.clone(), pin_manager, GcConfig::dry_run());
924
925 let result = gc.collect().await.expect("test: gc dry run collect");
927
928 assert_eq!(result.blocks_collected, 1);
930
931 assert!(store
933 .has(block2.cid())
934 .await
935 .expect("test: check block2 still exists in dry run"));
936
937 let _ = std::fs::remove_dir_all(&path);
938 }
939
940 #[test]
941 fn test_gc_config() {
942 let config = GcConfig::default();
943 assert!(!config.dry_run);
944 assert!(!config.incremental);
945
946 let config = GcConfig::incremental();
947 assert!(config.incremental);
948
949 let config = GcConfig::dry_run().with_max_blocks(100);
950 assert!(config.dry_run);
951 assert_eq!(config.max_blocks_per_run, 100);
952 }
953
954 #[test]
955 fn test_gc_stats() {
956 let stats = GcStats::default();
957 let result = GcResult {
958 blocks_collected: 10,
959 bytes_freed: 1024,
960 ..Default::default()
961 };
962
963 stats.record_run(&result);
964
965 let snapshot = stats.snapshot();
966 assert_eq!(snapshot.total_runs, 1);
967 assert_eq!(snapshot.total_blocks_collected, 10);
968 assert_eq!(snapshot.total_bytes_freed, 1024);
969 }
970
971 fn unique_gc_dir(tag: &str) -> PathBuf {
974 std::env::temp_dir().join(format!("ipfrs-gc-{}-{}", tag, std::process::id()))
975 }
976
977 #[tokio::test]
978 async fn test_gc_dry_run_reports_orphans() {
979 let path = unique_gc_dir("orphan-dry");
980 let _ = std::fs::remove_dir_all(&path);
981
982 let store = Arc::new(
983 SledBlockStore::new(BlockStoreConfig {
984 path: path.clone(),
985 cache_size: 1024 * 1024,
986 })
987 .expect("test: open sled block store for orphan dry run"),
988 );
989
990 let block_pinned = make_test_block(b"pinned block");
991 let block_orphan = make_test_block(b"orphan block");
992 store
993 .put(&block_pinned)
994 .await
995 .expect("test: put pinned block");
996 store
997 .put(&block_orphan)
998 .await
999 .expect("test: put orphan block");
1000
1001 let pinned: std::collections::HashSet<String> =
1002 std::iter::once(block_pinned.cid().to_string()).collect();
1003
1004 let gc = OrphanGarbageCollector::new(OrphanGcConfig {
1005 dry_run: true,
1006 batch_size: 100,
1007 ..Default::default()
1008 });
1009 let result = gc
1010 .collect(store.as_ref(), &pinned)
1011 .await
1012 .expect("test: orphan gc dry run collect");
1013
1014 assert_eq!(result.collected, 1);
1016 assert!(store
1017 .has(block_orphan.cid())
1018 .await
1019 .expect("test: orphan block still exists in dry run"));
1020 assert!(result.freed_bytes > 0);
1021 assert!(result.errors.is_empty());
1022
1023 let _ = std::fs::remove_dir_all(&path);
1024 }
1025
1026 #[tokio::test]
1027 async fn test_gc_collects_unpinned_blocks() {
1028 let path = unique_gc_dir("orphan-collect");
1029 let _ = std::fs::remove_dir_all(&path);
1030
1031 let store = Arc::new(
1032 SledBlockStore::new(BlockStoreConfig {
1033 path: path.clone(),
1034 cache_size: 1024 * 1024,
1035 })
1036 .expect("test: open sled block store for unpinned collection"),
1037 );
1038
1039 let block_a = make_test_block(b"keep me");
1040 let block_b = make_test_block(b"delete me");
1041 store.put(&block_a).await.expect("test: put block_a");
1042 store.put(&block_b).await.expect("test: put block_b");
1043
1044 let pinned: std::collections::HashSet<String> =
1045 std::iter::once(block_a.cid().to_string()).collect();
1046
1047 let gc = OrphanGarbageCollector::new(OrphanGcConfig {
1048 dry_run: false,
1049 batch_size: 100,
1050 ..Default::default()
1051 });
1052 let result = gc
1053 .collect(store.as_ref(), &pinned)
1054 .await
1055 .expect("test: orphan gc collect unpinned");
1056
1057 assert_eq!(result.collected, 1);
1058 assert!(
1059 store
1060 .has(block_a.cid())
1061 .await
1062 .expect("test: check pinned block survives"),
1063 "pinned block must survive"
1064 );
1065 assert!(
1066 !store
1067 .has(block_b.cid())
1068 .await
1069 .expect("test: check orphan block deleted"),
1070 "orphan must be deleted"
1071 );
1072 assert!(result.errors.is_empty());
1073
1074 let _ = std::fs::remove_dir_all(&path);
1075 }
1076
1077 #[tokio::test]
1078 async fn test_gc_preserves_pinned_blocks() {
1079 let path = unique_gc_dir("orphan-pin");
1080 let _ = std::fs::remove_dir_all(&path);
1081
1082 let store = Arc::new(
1083 SledBlockStore::new(BlockStoreConfig {
1084 path: path.clone(),
1085 cache_size: 1024 * 1024,
1086 })
1087 .expect("test: open sled block store for preserve-pinned test"),
1088 );
1089
1090 let b1 = make_test_block(b"pin1");
1091 let b2 = make_test_block(b"pin2");
1092 let b3 = make_test_block(b"pin3");
1093 store.put(&b1).await.expect("test: put b1");
1094 store.put(&b2).await.expect("test: put b2");
1095 store.put(&b3).await.expect("test: put b3");
1096
1097 let pinned: std::collections::HashSet<String> = [
1099 b1.cid().to_string(),
1100 b2.cid().to_string(),
1101 b3.cid().to_string(),
1102 ]
1103 .into_iter()
1104 .collect();
1105
1106 let gc = OrphanGarbageCollector::new(OrphanGcConfig {
1107 dry_run: false,
1108 batch_size: 100,
1109 ..Default::default()
1110 });
1111 let result = gc
1112 .collect(store.as_ref(), &pinned)
1113 .await
1114 .expect("test: orphan gc collect with all blocks pinned");
1115
1116 assert_eq!(
1117 result.collected, 0,
1118 "no blocks should be collected when all are pinned"
1119 );
1120 assert!(store.has(b1.cid()).await.expect("test: b1 still exists"));
1121 assert!(store.has(b2.cid()).await.expect("test: b2 still exists"));
1122 assert!(store.has(b3.cid()).await.expect("test: b3 still exists"));
1123
1124 let _ = std::fs::remove_dir_all(&path);
1125 }
1126
1127 #[test]
1130 fn test_snapshot_pin_registry() {
1131 let mut registry = SnapshotPinRegistry::new();
1132 assert!(registry.is_empty());
1133 assert_eq!(registry.pinned_count(), 0);
1134
1135 let path_a = std::path::PathBuf::from("/var/ipfrs/hnsw_index.snap");
1136 let path_b = std::path::PathBuf::from("/var/ipfrs/kb.snap");
1137
1138 registry.pin_snapshot(path_a.clone());
1139 assert_eq!(registry.pinned_count(), 1);
1140 assert!(registry.is_pinned(&path_a));
1141 assert!(!registry.is_pinned(&path_b));
1142 assert!(!registry.is_empty());
1143
1144 registry.pin_snapshot(path_b.clone());
1145 assert_eq!(registry.pinned_count(), 2);
1146 assert!(registry.is_pinned(&path_b));
1147
1148 registry.pin_snapshot(path_a.clone());
1150 assert_eq!(registry.pinned_count(), 2);
1151
1152 registry.unpin_snapshot(&path_a);
1154 assert_eq!(registry.pinned_count(), 1);
1155 assert!(!registry.is_pinned(&path_a));
1156 assert!(registry.is_pinned(&path_b));
1157
1158 let unknown = std::path::PathBuf::from("/nonexistent.snap");
1160 registry.unpin_snapshot(&unknown);
1161 assert_eq!(registry.pinned_count(), 1);
1162
1163 let paths: Vec<_> = registry.pinned_paths().collect();
1165 assert_eq!(paths.len(), 1);
1166 assert_eq!(paths[0], &path_b);
1167 }
1168
1169 #[test]
1170 fn test_snapshot_pin_id_deterministic() {
1171 let path = std::path::Path::new("/var/ipfrs/snapshots/hnsw_index.snap");
1172 let id1 = snapshot_pin_id(path);
1173 let id2 = snapshot_pin_id(path);
1174
1175 assert_eq!(id1, id2);
1177
1178 assert!(id1.starts_with("snapshot:"), "id was: {}", id1);
1180
1181 let path2 = std::path::Path::new("/var/ipfrs/snapshots/kb.snap");
1183 let id3 = snapshot_pin_id(path2);
1184 assert_ne!(id1, id3);
1185 }
1186
1187 fn unique_sled_dir(tag: &str) -> std::path::PathBuf {
1190 std::env::temp_dir().join(format!("ipfrs-sled-pin-{}-{}", tag, std::process::id()))
1191 }
1192
1193 #[test]
1194 fn test_sled_snapshot_pin_registry_basic() {
1195 let path = unique_sled_dir("basic");
1196 let _ = std::fs::remove_dir_all(&path);
1197
1198 let db = sled::open(&path).expect("test: open sled db");
1199 let registry =
1200 SledSnapshotPinRegistry::open(&db).expect("test: open snapshot pin registry");
1201
1202 let block = make_test_block(b"snapshot block");
1203 let cid = *block.cid();
1204
1205 assert!(!registry
1207 .is_pinned(&cid)
1208 .expect("test: check not pinned initially"));
1209 assert_eq!(registry.pin_count().expect("test: count before pin"), 0);
1210
1211 registry
1213 .pin(&cid, "hnsw-v1")
1214 .expect("test: pin cid with label");
1215 assert!(registry
1216 .is_pinned(&cid)
1217 .expect("test: check pinned after pin"));
1218 assert_eq!(registry.pin_count().expect("test: count after pin"), 1);
1219
1220 let list = registry.list_pinned().expect("test: list pinned entries");
1222 assert_eq!(list.len(), 1);
1223 assert_eq!(list[0].0, cid);
1224 assert_eq!(list[0].1, "hnsw-v1");
1225
1226 registry.unpin(&cid).expect("test: unpin cid");
1228 assert!(!registry
1229 .is_pinned(&cid)
1230 .expect("test: check not pinned after unpin"));
1231 assert_eq!(registry.pin_count().expect("test: count after unpin"), 0);
1232
1233 let _ = std::fs::remove_dir_all(&path);
1234 }
1235
1236 #[tokio::test]
1240 async fn test_snapshot_pin_survives_gc() {
1241 let store_path = unique_gc_dir("snap-pin-gc");
1242 let sled_pin_path = unique_sled_dir("snap-pin-gc-reg");
1243 let _ = std::fs::remove_dir_all(&store_path);
1244 let _ = std::fs::remove_dir_all(&sled_pin_path);
1245
1246 let store = Arc::new(
1247 SledBlockStore::new(BlockStoreConfig {
1248 path: store_path.clone(),
1249 cache_size: 1024 * 1024,
1250 })
1251 .expect("test: open sled block store for snapshot-pin-gc test"),
1252 );
1253
1254 let pinned_block = make_test_block(b"hnsw snapshot block");
1255 let orphan_block = make_test_block(b"truly orphaned block");
1256
1257 store
1258 .put(&pinned_block)
1259 .await
1260 .expect("test: put pinned block");
1261 store
1262 .put(&orphan_block)
1263 .await
1264 .expect("test: put orphan block");
1265
1266 let db = sled::open(&sled_pin_path).expect("test: open sled db for pin registry");
1268 let snap_reg =
1269 SledSnapshotPinRegistry::open(&db).expect("test: open snapshot pin registry");
1270 snap_reg
1271 .pin(pinned_block.cid(), "hnsw-snapshot")
1272 .expect("test: pin block in snapshot registry");
1273
1274 let gc = OrphanGarbageCollector::new(OrphanGcConfig {
1276 dry_run: false,
1277 min_age_secs: 0,
1278 batch_size: 100,
1279 });
1280 let pinned_set = std::collections::HashSet::new();
1281 let result = gc
1282 .collect_with_snapshot_registry(store.as_ref(), &pinned_set, Some(&snap_reg))
1283 .await
1284 .expect("test: collect with snapshot registry");
1285
1286 assert_eq!(result.collected, 1, "orphan block must be collected");
1288 assert!(
1289 !store
1290 .has(orphan_block.cid())
1291 .await
1292 .expect("test: check orphan block gone after gc"),
1293 "orphan must be gone"
1294 );
1295
1296 assert!(
1298 store
1299 .has(pinned_block.cid())
1300 .await
1301 .expect("test: check snapshot-pinned block survives gc"),
1302 "snapshot-pinned block must survive GC"
1303 );
1304
1305 let _ = std::fs::remove_dir_all(&store_path);
1306 let _ = std::fs::remove_dir_all(&sled_pin_path);
1307 }
1308
1309 #[test]
1314 fn test_compaction_scheduler_triggers() {
1315 use crate::compaction::{CompactionConfig, CompactionScheduler};
1316 use std::time::Duration;
1317
1318 let sched = CompactionScheduler::new(CompactionConfig {
1320 idle_threshold: Duration::from_secs(0),
1321 min_interval: Duration::from_secs(0),
1322 max_bytes_since_compact: 1,
1323 });
1324 sched.record_write(2);
1326 assert!(
1327 sched.should_compact(),
1328 "scheduler must trigger when bytes threshold is exceeded"
1329 );
1330 }
1331}