Skip to main content

ipfrs_storage/
gc.rs

1//! Garbage Collection for block storage.
2//!
3//! Implements mark-and-sweep GC to reclaim space from unreferenced blocks.
4//! Works with the pin management system to ensure pinned blocks are retained.
5//!
6//! # Algorithm
7//!
8//! 1. **Mark Phase**: Starting from pinned root blocks, traverse all links
9//!    to mark reachable blocks.
10//! 2. **Sweep Phase**: Delete all blocks that weren't marked as reachable.
11//!
12//! # Example
13//!
14//! ```rust,ignore
15//! use ipfrs_storage::gc::{GarbageCollector, GcConfig};
16//!
17//! let gc = GarbageCollector::new(store, pin_manager, GcConfig::default());
18//! let result = gc.collect().await?;
19//! println!("Collected {} blocks, freed {} bytes", result.blocks_collected, result.bytes_freed);
20//! ```
21
22use 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/// GC configuration
31#[derive(Debug, Clone)]
32pub struct GcConfig {
33    /// Maximum blocks to collect in a single run (0 = unlimited)
34    pub max_blocks_per_run: usize,
35    /// Time limit for a single GC run (None = unlimited)
36    pub time_limit: Option<Duration>,
37    /// Whether to run incrementally (pause between batches)
38    pub incremental: bool,
39    /// Batch size for incremental GC
40    pub batch_size: usize,
41    /// Delay between incremental batches
42    pub batch_delay: Duration,
43    /// Whether to perform a dry run (don't actually delete)
44    pub dry_run: bool,
45}
46
47impl Default for GcConfig {
48    fn default() -> Self {
49        Self {
50            max_blocks_per_run: 0,                  // Unlimited
51            time_limit: None,                       // No limit
52            incremental: false,                     // Full GC by default
53            batch_size: 1000,                       // Process 1000 blocks per batch
54            batch_delay: Duration::from_millis(10), // 10ms between batches
55            dry_run: false,
56        }
57    }
58}
59
60impl GcConfig {
61    /// Create a configuration for incremental GC
62    pub fn incremental() -> Self {
63        Self {
64            incremental: true,
65            ..Default::default()
66        }
67    }
68
69    /// Create a configuration for dry run (no actual deletion)
70    pub fn dry_run() -> Self {
71        Self {
72            dry_run: true,
73            ..Default::default()
74        }
75    }
76
77    /// Set max blocks per run
78    pub fn with_max_blocks(mut self, max: usize) -> Self {
79        self.max_blocks_per_run = max;
80        self
81    }
82
83    /// Set time limit
84    pub fn with_time_limit(mut self, duration: Duration) -> Self {
85        self.time_limit = Some(duration);
86        self
87    }
88}
89
90/// Result of a GC run
91#[derive(Debug, Clone, Default)]
92pub struct GcResult {
93    /// Number of blocks collected (deleted)
94    pub blocks_collected: u64,
95    /// Bytes freed
96    pub bytes_freed: u64,
97    /// Number of blocks marked as reachable
98    pub blocks_marked: u64,
99    /// Number of blocks scanned
100    pub blocks_scanned: u64,
101    /// Duration of the GC run
102    pub duration: Duration,
103    /// Whether GC was interrupted (time limit, etc.)
104    pub interrupted: bool,
105    /// Errors encountered during GC (non-fatal)
106    pub errors: Vec<String>,
107}
108
109/// GC statistics tracking
110#[derive(Debug, Default)]
111pub struct GcStats {
112    /// Total GC runs
113    pub total_runs: AtomicU64,
114    /// Total blocks collected across all runs
115    pub total_blocks_collected: AtomicU64,
116    /// Total bytes freed across all runs
117    pub total_bytes_freed: AtomicU64,
118    /// Last GC run time (as unix timestamp)
119    pub last_run_timestamp: AtomicU64,
120}
121
122impl GcStats {
123    /// Record a GC run
124    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    /// Get a snapshot of statistics
140    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/// Snapshot of GC statistics
151#[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
159/// Link resolver function type
160pub type LinkResolver = Arc<dyn Fn(&Cid) -> Result<Vec<Cid>> + Send + Sync>;
161
162/// Garbage collector for block storage
163pub struct GarbageCollector<S: BlockStore> {
164    /// The block store to collect from
165    store: Arc<S>,
166    /// Pin manager for determining roots
167    pin_manager: Arc<PinManager>,
168    /// Link resolver for traversing DAG structure
169    link_resolver: LinkResolver,
170    /// Configuration
171    config: GcConfig,
172    /// Statistics
173    stats: GcStats,
174    /// Cancel flag for stopping GC
175    cancel: AtomicBool,
176}
177
178impl<S: BlockStore> GarbageCollector<S> {
179    /// Create a new garbage collector
180    ///
181    /// # Arguments
182    /// * `store` - The block store to collect from
183    /// * `pin_manager` - Pin manager for determining root blocks
184    /// * `link_resolver` - Function to get links from a block
185    /// * `config` - GC configuration
186    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    /// Create with a no-op link resolver (for flat storage without DAG)
203    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    /// Request cancellation of the current GC run
209    pub fn cancel(&self) {
210        self.cancel.store(true, Ordering::SeqCst);
211    }
212
213    /// Reset cancel flag
214    pub fn reset_cancel(&self) {
215        self.cancel.store(false, Ordering::SeqCst);
216    }
217
218    /// Check if GC has been cancelled
219    fn is_cancelled(&self) -> bool {
220        self.cancel.load(Ordering::SeqCst)
221    }
222
223    /// Get GC statistics
224    pub fn stats(&self) -> GcStatsSnapshot {
225        self.stats.snapshot()
226    }
227
228    /// Run garbage collection
229    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        // Phase 1: Mark - find all reachable blocks
235        let marked = self.mark_phase(&mut result).await?;
236
237        // Check for cancellation or time limit
238        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        // Phase 2: Sweep - delete unreachable blocks
246        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    /// Mark phase: traverse from roots to find all reachable blocks
254    #[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        // Get all pinned CIDs as roots
260        let pins = self.pin_manager.list_pins()?;
261        for (cid, info) in pins {
262            // Direct and recursive pins are roots
263            if info.pin_type == PinType::Direct || info.pin_type == PinType::Recursive {
264                to_process.push(cid);
265            }
266            // All pinned blocks (including indirect) should be marked
267            marked.insert(cid.to_bytes());
268        }
269
270        // Traverse from roots
271        while let Some(cid) = to_process.pop() {
272            if self.is_cancelled() {
273                break;
274            }
275
276            // Get links from this block
277            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                            // Newly marked, add to process queue
283                            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    /// Sweep phase: delete unreachable blocks
300    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            // Check max blocks limit
315            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                // Block is not marked - collect it
325                if self.config.dry_run {
326                    // In dry run mode, just count
327                    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                // Incremental GC: process in batches
338                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        // Delete remaining blocks
350        if !self.config.dry_run && !to_delete.is_empty() {
351            self.delete_batch(&to_delete, result).await?;
352        }
353
354        Ok(())
355    }
356
357    /// Delete a batch of blocks
358    async fn delete_batch(&self, cids: &[Cid], result: &mut GcResult) -> Result<()> {
359        for cid in cids {
360            // Get size before deletion
361            if let Ok(Some(block)) = self.store.get(cid).await {
362                result.bytes_freed += block.size();
363            }
364
365            // Delete the block
366            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    /// Check if GC should stop
381    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/// GC policy for automatic garbage collection
397#[derive(Debug, Clone, Default)]
398pub enum GcPolicy {
399    /// Manual GC only
400    #[default]
401    Manual,
402    /// Time-based: run every N seconds
403    TimeBased { interval_secs: u64 },
404    /// Space-based: run when disk usage exceeds threshold
405    SpaceBased { threshold_percent: f64 },
406    /// Combined: run when either condition is met
407    Combined {
408        interval_secs: u64,
409        threshold_percent: f64,
410    },
411}
412
413/// Automatic GC scheduler
414pub 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    /// Create a new GC scheduler
422    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    /// Check if GC should run based on policy
431    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                // Would need disk usage info - not implemented yet
444                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    /// Run GC if policy conditions are met
458    pub async fn maybe_run(&self) -> Option<GcResult> {
459        if !self.should_run() {
460            return None;
461        }
462
463        // Prevent concurrent runs
464        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    /// Get reference to the garbage collector
478    pub fn gc(&self) -> &GarbageCollector<S> {
479        &self.gc
480    }
481}
482
483// ═══════════════════════════════════════════════════════════════════════════
484// Orphan-focused GC (v0.3.0 storage hardening)
485// ═══════════════════════════════════════════════════════════════════════════
486
487/// Configuration for the simplified orphan-focused GC pass.
488#[derive(Debug, Clone)]
489pub struct OrphanGcConfig {
490    /// When `true`, report orphaned blocks without actually deleting them.
491    pub dry_run: bool,
492    /// Only consider blocks older than this many seconds as orphans.
493    /// Because Sled does not store per-block timestamps, this field is
494    /// honoured when the caller supplies `block_ages` metadata; otherwise it
495    /// is ignored and all unpinned blocks are treated as eligible.
496    pub min_age_secs: u64,
497    /// Maximum number of blocks to examine per call.
498    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, // 1 hour
506            batch_size: 100,
507        }
508    }
509}
510
511impl OrphanGcConfig {
512    /// Convenience: dry-run mode with default age filter.
513    pub fn dry_run() -> Self {
514        Self {
515            dry_run: true,
516            ..Default::default()
517        }
518    }
519}
520
521/// Result of a single orphan GC run.
522#[derive(Debug, Clone, Default)]
523pub struct OrphanGcResult {
524    /// Blocks examined in this pass.
525    pub examined: usize,
526    /// Blocks collected (deleted, or would be deleted in dry_run mode).
527    pub collected: usize,
528    /// Estimated bytes freed.
529    pub freed_bytes: usize,
530    /// Non-fatal errors encountered.
531    pub errors: Vec<String>,
532    /// Wall-clock duration of the run in milliseconds.
533    pub duration_ms: u64,
534}
535
536/// Lightweight garbage collector that targets orphaned blocks — blocks that
537/// are present in the store but are not referenced by any pinned CID.
538///
539/// Unlike the full mark-and-sweep [`GarbageCollector`], this collector does
540/// **not** traverse DAG links.  It simply checks every stored CID against the
541/// caller-supplied `pinned_cids` set and deletes the rest.
542pub struct OrphanGarbageCollector {
543    config: OrphanGcConfig,
544}
545
546impl OrphanGarbageCollector {
547    /// Create a new orphan garbage collector with the given configuration.
548    pub fn new(config: OrphanGcConfig) -> Self {
549        Self { config }
550    }
551
552    /// Collect orphaned blocks from `store`.
553    ///
554    /// `pinned_cids` – the set of CID *string* representations that must **not**
555    /// be deleted.  Every block whose string CID is absent from this set and
556    /// is not otherwise protected is treated as an orphan.
557    ///
558    /// When `snapshot_registry` is supplied, CIDs registered there are also
559    /// excluded from collection (they represent HNSW / knowledge-base snapshot
560    /// blocks that must outlive their originating sessions).
561    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    /// Variant of `collect` that additionally consults a
571    /// [`SledSnapshotPinRegistry`] so that HNSW snapshot CIDs are never GC'd.
572    ///
573    /// Only available when the `sled-backend` feature is enabled.
574    #[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        // Materialise snapshot-pinned CIDs once so each per-block check is O(1).
582        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    /// Internal implementation shared by [`collect`] and [`collect_with_snapshot_registry`].
591    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                // Pinned by caller or by snapshot registry – must not delete
608                continue;
609            }
610
611            // Orphan candidate
612            if self.config.dry_run {
613                // In dry-run mode, count but do not delete
614                if let Ok(Some(block)) = store.get(cid).await {
615                    result.freed_bytes += block.data().len();
616                }
617                result.collected += 1;
618            } else {
619                // Get size before deletion for accounting
620                match store.get(cid).await {
621                    Ok(Some(block)) => {
622                        result.freed_bytes += block.data().len();
623                    }
624                    Ok(None) => {} // Already gone
625                    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// ═══════════════════════════════════════════════════════════════════════════
649// Snapshot pin registry (v0.3.0)
650// ═══════════════════════════════════════════════════════════════════════════
651
652// ═══════════════════════════════════════════════════════════════════════════
653// Sled-backed CID snapshot pin registry (v0.3.0)
654// Only compiled when the `sled-backend` feature is enabled.
655// ═══════════════════════════════════════════════════════════════════════════
656
657/// Persistent registry of HNSW snapshot block CIDs that must survive GC.
658///
659/// Backed by a dedicated `"snapshot_pins"` Sled tree inside the blockstore
660/// database.  Entries survive process restarts, which is critical for ensuring
661/// snapshot blocks are never collected by the [`OrphanGarbageCollector`] even
662/// across node restarts.
663///
664/// Key layout: `cid_bytes` → `label_utf8_bytes`
665#[cfg(feature = "sled-backend")]
666pub struct SledSnapshotPinRegistry {
667    tree: sled::Tree,
668}
669
670#[cfg(feature = "sled-backend")]
671impl SledSnapshotPinRegistry {
672    /// Open (or create) the `"snapshot_pins"` tree inside the given Sled `Db`.
673    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    /// Pin `cid` with a human-readable `label`.  Idempotent — pinning the
681    /// same CID again simply overwrites the label.
682    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    /// Remove the pin for `cid`.  No-ops silently if not present.
695    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    /// Return `true` when `cid` is registered in this registry.
707    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    /// Return all pinned CIDs together with their labels.
715    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    /// Number of CIDs currently pinned in this registry.
730    pub fn pin_count(&self) -> Result<usize> {
731        Ok(self.tree.len())
732    }
733
734    /// Build a `HashSet<String>` of pinned CID strings for use with
735    /// [`OrphanGarbageCollector`].
736    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
750/// Compute a deterministic opaque identifier for a snapshot file path.
751///
752/// The returned string is of the form `snapshot:<hex>` and is suitable for use
753/// as a key in a [`SnapshotPinRegistry`] or as a GC-exclusion token.  It does
754/// **not** represent a content hash — it is purely a path-based identifier that
755/// remains stable as long as the path string is unchanged.
756pub 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
763/// Registry of filesystem snapshot paths that must be excluded from GC.
764///
765/// When the HNSW index or the knowledge-base is flushed to disk the resulting
766/// file path should be registered here so the [`OrphanGarbageCollector`] (and
767/// any future full GC pass) can honour the exclusion without needing to
768/// understand the internal format of those files.
769///
770/// The registry holds [`std::path::PathBuf`]s rather than CIDs because
771/// snapshot files are not (yet) tracked as content-addressed blocks in the
772/// primary block store.
773pub struct SnapshotPinRegistry {
774    pinned_paths: std::collections::HashSet<std::path::PathBuf>,
775}
776
777impl SnapshotPinRegistry {
778    /// Create an empty registry.
779    pub fn new() -> Self {
780        Self {
781            pinned_paths: std::collections::HashSet::new(),
782        }
783    }
784
785    /// Mark `path` as pinned so it is not eligible for GC removal.
786    pub fn pin_snapshot(&mut self, path: std::path::PathBuf) {
787        self.pinned_paths.insert(path);
788    }
789
790    /// Remove `path` from the pin registry, making it eligible for GC.
791    ///
792    /// No-ops silently if `path` is not currently pinned.
793    pub fn unpin_snapshot(&mut self, path: &std::path::Path) {
794        self.pinned_paths.remove(path);
795    }
796
797    /// Return `true` if `path` is currently registered as pinned.
798    pub fn is_pinned(&self, path: &std::path::Path) -> bool {
799        self.pinned_paths.contains(path)
800    }
801
802    /// Number of paths currently pinned in this registry.
803    pub fn pinned_count(&self) -> usize {
804        self.pinned_paths.len()
805    }
806
807    /// Iterate over all pinned paths in arbitrary order.
808    pub fn pinned_paths(&self) -> impl Iterator<Item = &std::path::PathBuf> {
809        self.pinned_paths.iter()
810    }
811
812    /// Return `true` when the registry holds no pinned paths.
813    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        // Add some blocks
851        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 only block1
869        pin_manager.pin(block1.cid()).expect("failed to pin block1");
870
871        // Create GC
872        let gc = GarbageCollector::new_flat(store.clone(), pin_manager, GcConfig::default());
873
874        // Run GC
875        let result = gc.collect().await.expect("GC collect failed");
876
877        // Should have collected 2 blocks (block2 and block3)
878        assert_eq!(result.blocks_collected, 2);
879        assert_eq!(result.blocks_marked, 1);
880
881        // Verify block1 still exists
882        assert!(store
883            .has(block1.cid())
884            .await
885            .expect("failed to check block1 existence"));
886        // Verify block2 and block3 are gone
887        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        // Add some blocks
913        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 only block1
920        pin_manager.pin(block1.cid()).expect("test: pin block1");
921
922        // Create GC with dry run
923        let gc = GarbageCollector::new_flat(store.clone(), pin_manager, GcConfig::dry_run());
924
925        // Run GC
926        let result = gc.collect().await.expect("test: gc dry run collect");
927
928        // Should report 1 block to collect
929        assert_eq!(result.blocks_collected, 1);
930
931        // But block2 should still exist (dry run)
932        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    // ── Orphan GC tests (v0.3.0) ──────────────────────────────────────────
972
973    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        // Should report 1 orphan without deleting it
1015        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        // Pin ALL blocks
1098        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    // ── SnapshotPinRegistry tests ─────────────────────────────────────────
1128
1129    #[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        // Idempotent: pinning the same path again does not increase the count
1149        registry.pin_snapshot(path_a.clone());
1150        assert_eq!(registry.pinned_count(), 2);
1151
1152        // Unpin one path
1153        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        // Unpin a path that was never registered — must not panic
1159        let unknown = std::path::PathBuf::from("/nonexistent.snap");
1160        registry.unpin_snapshot(&unknown);
1161        assert_eq!(registry.pinned_count(), 1);
1162
1163        // Iterate pinned paths
1164        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        // Same path → same ID
1176        assert_eq!(id1, id2);
1177
1178        // IDs must start with the expected prefix
1179        assert!(id1.starts_with("snapshot:"), "id was: {}", id1);
1180
1181        // Different paths produce different IDs
1182        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    // ── SledSnapshotPinRegistry tests (v0.3.0) ───────────────────────────
1188
1189    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        // Initially not pinned
1206        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        // Pin with label
1212        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        // List returns the entry
1221        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        // Unpin
1227        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    /// `test_snapshot_pin_survives_gc`:
1237    /// Pin a CID in the Sled registry, run orphan GC with min_age=0,
1238    /// verify the pinned CID is NOT deleted.
1239    #[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        // Register the pinned block in the Sled snapshot registry
1267        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        // Run orphan GC with empty caller-supplied pins (min_age=0 via batch_size=100)
1275        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        // Orphan block must be deleted
1287        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        // Pinned block (from Sled registry) must survive
1297        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    // ── CompactionScheduler trigger test (v0.3.0) ─────────────────────────
1310
1311    /// `test_compaction_scheduler_triggers`:
1312    /// Advance the scheduler past the threshold and verify `should_compact()`.
1313    #[test]
1314    fn test_compaction_scheduler_triggers() {
1315        use crate::compaction::{CompactionConfig, CompactionScheduler};
1316        use std::time::Duration;
1317
1318        // Very low bytes threshold so the first write triggers compaction.
1319        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        // Record a write above the threshold.
1325        sched.record_write(2);
1326        assert!(
1327            sched.should_compact(),
1328            "scheduler must trigger when bytes threshold is exceeded"
1329        );
1330    }
1331}