Skip to main content

ipfrs_storage/
garbage_collector.rs

1//! Mark-and-sweep garbage collector for content-addressed storage.
2//!
3//! Implements full mark-and-sweep GC with reference counting, cycle detection
4//! via BFS traversal, pinning support, and incremental batch processing.
5//!
6//! # Algorithm
7//!
8//! 1. **Mark phase**: BFS from all root objects and pinned objects, following
9//!    DAG links. Every reachable object is stamped with `last_marked = Some(now)`.
10//! 2. **Sweep phase**: All objects where `last_marked != Some(now)` AND
11//!    `pinned == false` AND `ref_count == 0` are removed and their sizes summed.
12//! 3. Statistics are returned in a [`StorageGcRun`] record.
13//!
14//! # Example
15//!
16//! ```rust
17//! use ipfrs_storage::garbage_collector::{
18//!     StorageGarbageCollector, GcObjectId, GcObject, StorageGcConfig,
19//! };
20//!
21//! let config = StorageGcConfig::default();
22//! let mut gc = StorageGarbageCollector::new(config);
23//!
24//! let root_id = GcObjectId("bafyroot".to_string());
25//! let root = GcObject {
26//!     id: root_id.clone(),
27//!     size_bytes: 512,
28//!     ref_count: 1,
29//!     pinned: false,
30//!     created_at: 0,
31//!     last_marked: None,
32//!     links: vec![],
33//! };
34//! gc.add_object(root).expect("add object");
35//! gc.add_root(&root_id);
36//!
37//! let run = gc.run_gc(1);
38//! assert_eq!(run.objects_swept, 0);
39//! ```
40
41use std::collections::{HashMap, HashSet, VecDeque};
42
43use thiserror::Error;
44
45// ─── Error type ──────────────────────────────────────────────────────────────
46
47/// Errors that can occur during garbage-collection operations.
48#[derive(Debug, Error, Clone, PartialEq, Eq)]
49pub enum GcError {
50    /// An object with this id already exists in the store.
51    #[error("object already exists: {0}")]
52    ObjectAlreadyExists(String),
53
54    /// No object with this id exists in the store.
55    #[error("object not found: {0}")]
56    ObjectNotFound(String),
57
58    /// The object is pinned and cannot be removed.
59    #[error("object is pinned: {0}")]
60    ObjectPinned(String),
61
62    /// The object still has active references and cannot be removed.
63    #[error("object {id} is still referenced (ref_count={ref_count})")]
64    ObjectReferenced {
65        /// Object identifier.
66        id: String,
67        /// Current reference count.
68        ref_count: u32,
69    },
70}
71
72// ─── Core types ──────────────────────────────────────────────────────────────
73
74/// Newtype wrapper for object identifiers (CIDs).
75#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
76pub struct GcObjectId(pub String);
77
78impl GcObjectId {
79    /// Create a new `GcObjectId` from any `Into<String>`.
80    pub fn new(id: impl Into<String>) -> Self {
81        Self(id.into())
82    }
83
84    /// Return the inner string slice.
85    pub fn as_str(&self) -> &str {
86        &self.0
87    }
88}
89
90impl std::fmt::Display for GcObjectId {
91    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92        f.write_str(&self.0)
93    }
94}
95
96/// A node in the content-addressed DAG.
97#[derive(Debug, Clone)]
98pub struct GcObject {
99    /// Unique identifier (CID).
100    pub id: GcObjectId,
101    /// Size of this object in bytes.
102    pub size_bytes: u64,
103    /// External reference count (from the application layer).
104    pub ref_count: u32,
105    /// If `true`, this object is never collected regardless of reachability.
106    pub pinned: bool,
107    /// Unix timestamp (seconds) when this object was created.
108    pub created_at: u64,
109    /// Unix timestamp of the last GC mark pass that reached this object.
110    pub last_marked: Option<u64>,
111    /// CIDs of objects this object references (outgoing DAG edges).
112    pub links: Vec<GcObjectId>,
113}
114
115impl GcObject {
116    /// Returns `true` if this object is safe to sweep:
117    /// not pinned, no external references, and not freshly marked.
118    fn is_sweepable(&self, mark_epoch: u64) -> bool {
119        !self.pinned && self.ref_count == 0 && self.last_marked != Some(mark_epoch)
120    }
121}
122
123/// Current phase of the garbage collector.
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub enum GcPhase {
126    /// No GC in progress.
127    Idle,
128    /// Mark phase is active.
129    Marking,
130    /// Sweep phase is active.
131    Sweeping,
132    /// Post-sweep compaction (currently a no-op pass).
133    Compacting,
134}
135
136// ─── Configuration ────────────────────────────────────────────────────────────
137
138/// Configuration for [`StorageGarbageCollector`].
139#[derive(Debug, Clone)]
140pub struct StorageGcConfig {
141    /// Total byte budget; triggers GC when exceeded by `sweep_threshold_fraction`.
142    pub max_live_bytes: u64,
143    /// Fraction in `[0.0, 1.0]`: trigger GC when
144    /// `live_bytes / max_live_bytes >= sweep_threshold_fraction`.
145    pub sweep_threshold_fraction: f64,
146    /// When `true`, pinned objects are never swept even if unreachable.
147    pub pin_preserve: bool,
148    /// Maximum number of objects to process per GC step.
149    pub batch_size: usize,
150}
151
152impl Default for StorageGcConfig {
153    fn default() -> Self {
154        Self {
155            max_live_bytes: 1024 * 1024 * 1024, // 1 GiB
156            sweep_threshold_fraction: 0.8,
157            pin_preserve: true,
158            batch_size: 1000,
159        }
160    }
161}
162
163// ─── GC run record ────────────────────────────────────────────────────────────
164
165/// Record of a single garbage-collection run.
166#[derive(Debug, Clone)]
167pub struct StorageGcRun {
168    /// Monotonically increasing run identifier.
169    pub id: u64,
170    /// Unix timestamp when this run started.
171    pub started_at: u64,
172    /// Unix timestamp when this run completed (None if still in progress).
173    pub completed_at: Option<u64>,
174    /// Number of objects marked as reachable.
175    pub objects_marked: usize,
176    /// Number of objects swept (removed).
177    pub objects_swept: usize,
178    /// Total bytes freed by this run.
179    pub bytes_freed: u64,
180    /// Phase the collector was in when this record was taken.
181    pub phase: GcPhase,
182}
183
184// ─── Statistics ───────────────────────────────────────────────────────────────
185
186/// Aggregate statistics for a [`StorageGarbageCollector`].
187#[derive(Debug, Clone)]
188pub struct StorageGcStats {
189    /// Total objects currently tracked.
190    pub total_objects: usize,
191    /// Number of GC-root objects.
192    pub root_count: usize,
193    /// Total bytes across all tracked objects.
194    pub live_bytes: u64,
195    /// Number of pinned objects.
196    pub pinned_count: usize,
197    /// Total number of completed GC runs.
198    pub total_runs: usize,
199    /// Bytes freed in the most recent completed run (0 if none).
200    pub last_run_freed_bytes: u64,
201}
202
203// ─── Garbage collector ────────────────────────────────────────────────────────
204
205/// Production-grade mark-and-sweep garbage collector for content-addressed
206/// storage.
207///
208/// Objects form a directed acyclic graph (DAG) via [`GcObject::links`].
209/// GC roots and pinned objects anchor the live set; everything else that has
210/// no external references (`ref_count == 0`) is eligible for collection.
211pub struct StorageGarbageCollector {
212    /// Runtime configuration.
213    pub config: StorageGcConfig,
214    /// All tracked objects keyed by id.
215    pub objects: HashMap<GcObjectId, GcObject>,
216    /// Set of explicit GC roots.
217    pub roots: HashSet<GcObjectId>,
218    /// History of completed GC runs (capped at 256 entries).
219    pub gc_runs: VecDeque<StorageGcRun>,
220    /// Current collector phase.
221    pub phase: GcPhase,
222    /// Counter used to assign monotonically increasing run ids.
223    pub next_run_id: u64,
224}
225
226impl StorageGarbageCollector {
227    // ── Construction ─────────────────────────────────────────────────────────
228
229    /// Create a new collector with the given configuration.
230    pub fn new(config: StorageGcConfig) -> Self {
231        Self {
232            config,
233            objects: HashMap::new(),
234            roots: HashSet::new(),
235            gc_runs: VecDeque::new(),
236            phase: GcPhase::Idle,
237            next_run_id: 1,
238        }
239    }
240
241    // ── Object management ────────────────────────────────────────────────────
242
243    /// Add a new object.  Returns [`GcError::ObjectAlreadyExists`] if an
244    /// object with the same id is already tracked.
245    pub fn add_object(&mut self, object: GcObject) -> Result<(), GcError> {
246        if self.objects.contains_key(&object.id) {
247            return Err(GcError::ObjectAlreadyExists(object.id.0.clone()));
248        }
249        self.objects.insert(object.id.clone(), object);
250        Ok(())
251    }
252
253    /// Remove and return an object.
254    ///
255    /// Returns errors when:
256    /// - The object does not exist.
257    /// - The object is pinned.
258    /// - The object has `ref_count > 0`.
259    pub fn remove_object(&mut self, id: &GcObjectId) -> Result<GcObject, GcError> {
260        let obj = self
261            .objects
262            .get(id)
263            .ok_or_else(|| GcError::ObjectNotFound(id.0.clone()))?;
264
265        if obj.pinned {
266            return Err(GcError::ObjectPinned(id.0.clone()));
267        }
268        if obj.ref_count > 0 {
269            return Err(GcError::ObjectReferenced {
270                id: id.0.clone(),
271                ref_count: obj.ref_count,
272            });
273        }
274
275        Ok(self.objects.remove(id).unwrap_or_else(|| {
276            // Unreachable: we checked existence above.
277            panic!("object disappeared between check and remove")
278        }))
279    }
280
281    // ── Root management ──────────────────────────────────────────────────────
282
283    /// Mark an object as a GC root.
284    ///
285    /// Returns `true` if the object exists (and was added as root), `false`
286    /// otherwise.
287    pub fn add_root(&mut self, id: &GcObjectId) -> bool {
288        if self.objects.contains_key(id) {
289            self.roots.insert(id.clone());
290            true
291        } else {
292            false
293        }
294    }
295
296    /// Remove an object from the GC root set.
297    ///
298    /// Returns `true` if the object was a root (and has been removed).
299    pub fn remove_root(&mut self, id: &GcObjectId) -> bool {
300        self.roots.remove(id)
301    }
302
303    // ── Reference counting ───────────────────────────────────────────────────
304
305    /// Increment the external reference count for an object.
306    ///
307    /// Returns `true` if the object was found, `false` otherwise.
308    pub fn increment_ref(&mut self, id: &GcObjectId) -> bool {
309        if let Some(obj) = self.objects.get_mut(id) {
310            obj.ref_count = obj.ref_count.saturating_add(1);
311            true
312        } else {
313            false
314        }
315    }
316
317    /// Decrement the external reference count (floor 0).
318    ///
319    /// Returns `true` if the object was found.  When `ref_count` reaches 0 and
320    /// the object is neither a root nor pinned, it becomes a collection
321    /// candidate on the next GC run.
322    pub fn decrement_ref(&mut self, id: &GcObjectId) -> bool {
323        if let Some(obj) = self.objects.get_mut(id) {
324            obj.ref_count = obj.ref_count.saturating_sub(1);
325            true
326        } else {
327            false
328        }
329    }
330
331    // ── Pinning ───────────────────────────────────────────────────────────────
332
333    /// Pin an object so it is never swept.
334    ///
335    /// Returns `true` if the object was found.
336    pub fn pin(&mut self, id: &GcObjectId) -> bool {
337        if let Some(obj) = self.objects.get_mut(id) {
338            obj.pinned = true;
339            true
340        } else {
341            false
342        }
343    }
344
345    /// Unpin an object so it can be swept when unreachable.
346    ///
347    /// Returns `true` if the object was found.
348    pub fn unpin(&mut self, id: &GcObjectId) -> bool {
349        if let Some(obj) = self.objects.get_mut(id) {
350            obj.pinned = false;
351            true
352        } else {
353            false
354        }
355    }
356
357    // ── Threshold check ───────────────────────────────────────────────────────
358
359    /// Returns `true` when live_bytes / max_live_bytes ≥ sweep_threshold_fraction.
360    pub fn should_run(&self) -> bool {
361        let live = self.live_bytes();
362        let max = self.config.max_live_bytes;
363        if max == 0 {
364            return false;
365        }
366        let ratio = live as f64 / max as f64;
367        ratio >= self.config.sweep_threshold_fraction
368    }
369
370    // ── Mark phase ────────────────────────────────────────────────────────────
371
372    /// BFS traversal from all roots and pinned objects.
373    ///
374    /// Every reachable object gets `last_marked = Some(now)`.
375    /// Returns the set of reachable ids.
376    pub fn mark_reachable(&mut self, now: u64) -> HashSet<GcObjectId> {
377        // Seeds: explicit roots + pinned objects.
378        let mut queue: VecDeque<GcObjectId> = VecDeque::new();
379        let mut visited: HashSet<GcObjectId> = HashSet::new();
380
381        for id in &self.roots {
382            if !visited.contains(id) {
383                visited.insert(id.clone());
384                queue.push_back(id.clone());
385            }
386        }
387
388        // Also seed pinned objects even if not roots.
389        for (id, obj) in &self.objects {
390            if obj.pinned && !visited.contains(id) {
391                visited.insert(id.clone());
392                queue.push_back(id.clone());
393            }
394        }
395
396        // BFS: walk links in batches (respecting batch_size for incremental
397        // friendliness, but this is a full mark so we exhaust the queue).
398        while let Some(current_id) = queue.pop_front() {
399            // Stamp the object.
400            if let Some(obj) = self.objects.get_mut(&current_id) {
401                obj.last_marked = Some(now);
402
403                // Collect links to avoid borrow-checker issues.
404                let links: Vec<GcObjectId> = obj.links.clone();
405                for link in links {
406                    if !visited.contains(&link) {
407                        visited.insert(link.clone());
408                        queue.push_back(link);
409                    }
410                }
411            }
412        }
413
414        visited
415    }
416
417    // ── Full GC run ───────────────────────────────────────────────────────────
418
419    /// Perform a full mark-and-sweep GC run.
420    ///
421    /// Steps:
422    /// 1. Mark all objects reachable from roots / pinned objects.
423    /// 2. Sweep unreachable, unpinned, ref_count==0 objects.
424    /// 3. Record and return a [`StorageGcRun`].
425    pub fn run_gc(&mut self, now: u64) -> StorageGcRun {
426        let run_id = self.next_run_id;
427        self.next_run_id += 1;
428
429        // ── Mark ────────────────────────────────────────────────────────────
430        self.phase = GcPhase::Marking;
431        let reachable = self.mark_reachable(now);
432        let objects_marked = reachable.len();
433
434        // ── Sweep ────────────────────────────────────────────────────────────
435        self.phase = GcPhase::Sweeping;
436        let sweep_ids: Vec<GcObjectId> = self
437            .objects
438            .iter()
439            .filter_map(|(id, obj)| {
440                if obj.is_sweepable(now) {
441                    Some(id.clone())
442                } else {
443                    None
444                }
445            })
446            .collect();
447
448        let mut objects_swept = 0usize;
449        let mut bytes_freed = 0u64;
450        for id in &sweep_ids {
451            if let Some(obj) = self.objects.remove(id) {
452                objects_swept += 1;
453                bytes_freed += obj.size_bytes;
454                // Also clean up any root entry that referenced a swept object.
455                self.roots.remove(id);
456            }
457        }
458
459        // ── Compact (bookkeeping only) ────────────────────────────────────────
460        self.phase = GcPhase::Compacting;
461
462        // ── Finalise ─────────────────────────────────────────────────────────
463        self.phase = GcPhase::Idle;
464
465        let run = StorageGcRun {
466            id: run_id,
467            started_at: now,
468            completed_at: Some(now),
469            objects_marked,
470            objects_swept,
471            bytes_freed,
472            phase: GcPhase::Idle,
473        };
474
475        // Keep at most 256 historical run records.
476        if self.gc_runs.len() >= 256 {
477            self.gc_runs.pop_front();
478        }
479        self.gc_runs.push_back(run.clone());
480
481        run
482    }
483
484    // ── Metrics ───────────────────────────────────────────────────────────────
485
486    /// Total bytes across all currently tracked objects.
487    pub fn live_bytes(&self) -> u64 {
488        self.objects.values().map(|o| o.size_bytes).sum()
489    }
490
491    /// Total bytes across the supplied reachable set.
492    pub fn reachable_bytes(&self, reachable: &HashSet<GcObjectId>) -> u64 {
493        reachable
494            .iter()
495            .filter_map(|id| self.objects.get(id))
496            .map(|o| o.size_bytes)
497            .sum()
498    }
499
500    /// Number of tracked objects.
501    pub fn object_count(&self) -> usize {
502        self.objects.len()
503    }
504
505    /// Number of GC-root objects.
506    pub fn root_count(&self) -> usize {
507        self.roots.len()
508    }
509
510    /// Return aggregate statistics.
511    pub fn stats(&self) -> StorageGcStats {
512        let pinned_count = self.objects.values().filter(|o| o.pinned).count();
513        let last_run_freed_bytes = self.gc_runs.back().map(|r| r.bytes_freed).unwrap_or(0);
514
515        StorageGcStats {
516            total_objects: self.objects.len(),
517            root_count: self.roots.len(),
518            live_bytes: self.live_bytes(),
519            pinned_count,
520            total_runs: self.gc_runs.len(),
521            last_run_freed_bytes,
522        }
523    }
524}
525
526// ─── Helpers (builder-style) ──────────────────────────────────────────────────
527
528impl GcObject {
529    /// Convenience constructor.
530    pub fn new(id: GcObjectId, size_bytes: u64, created_at: u64, links: Vec<GcObjectId>) -> Self {
531        Self {
532            id,
533            size_bytes,
534            ref_count: 0,
535            pinned: false,
536            created_at,
537            last_marked: None,
538            links,
539        }
540    }
541
542    /// Set the initial reference count and return `self`.
543    pub fn with_ref_count(mut self, ref_count: u32) -> Self {
544        self.ref_count = ref_count;
545        self
546    }
547
548    /// Mark as pinned and return `self`.
549    pub fn pinned(mut self) -> Self {
550        self.pinned = true;
551        self
552    }
553}
554
555// ─── Tests ────────────────────────────────────────────────────────────────────
556
557#[cfg(test)]
558mod tests {
559    use std::collections::HashSet;
560
561    use crate::garbage_collector::{
562        GcError, GcObject, GcObjectId, GcPhase, StorageGarbageCollector, StorageGcConfig,
563    };
564
565    // ── Helpers ──────────────────────────────────────────────────────────────
566
567    fn default_gc() -> StorageGarbageCollector {
568        StorageGarbageCollector::new(StorageGcConfig::default())
569    }
570
571    fn make_obj(id: &str, size: u64) -> GcObject {
572        GcObject::new(GcObjectId::new(id), size, 0, vec![])
573    }
574
575    fn make_obj_with_links(id: &str, size: u64, links: Vec<&str>) -> GcObject {
576        let link_ids = links.into_iter().map(GcObjectId::new).collect();
577        GcObject::new(GcObjectId::new(id), size, 0, link_ids)
578    }
579
580    fn oid(s: &str) -> GcObjectId {
581        GcObjectId::new(s)
582    }
583
584    // ── Constructor ──────────────────────────────────────────────────────────
585
586    #[test]
587    fn test_new_is_idle() {
588        let gc = default_gc();
589        assert_eq!(gc.phase, GcPhase::Idle);
590        assert_eq!(gc.object_count(), 0);
591        assert_eq!(gc.root_count(), 0);
592    }
593
594    #[test]
595    fn test_default_config_values() {
596        let cfg = StorageGcConfig::default();
597        assert!(cfg.max_live_bytes > 0);
598        assert!(cfg.sweep_threshold_fraction > 0.0);
599        assert!(cfg.sweep_threshold_fraction <= 1.0);
600        assert_eq!(cfg.batch_size, 1000);
601        assert!(cfg.pin_preserve);
602    }
603
604    // ── add_object ────────────────────────────────────────────────────────────
605
606    #[test]
607    fn test_add_object_succeeds() {
608        let mut gc = default_gc();
609        gc.add_object(make_obj("a", 100)).expect("add object");
610        assert_eq!(gc.object_count(), 1);
611    }
612
613    #[test]
614    fn test_add_duplicate_fails() {
615        let mut gc = default_gc();
616        gc.add_object(make_obj("a", 100)).expect("first add");
617        let err = gc.add_object(make_obj("a", 200)).unwrap_err();
618        assert!(matches!(err, GcError::ObjectAlreadyExists(_)));
619    }
620
621    #[test]
622    fn test_add_multiple_objects() {
623        let mut gc = default_gc();
624        for i in 0..10u32 {
625            gc.add_object(make_obj(&format!("obj{i}"), 64))
626                .expect("add");
627        }
628        assert_eq!(gc.object_count(), 10);
629    }
630
631    // ── remove_object ─────────────────────────────────────────────────────────
632
633    #[test]
634    fn test_remove_object_succeeds() {
635        let mut gc = default_gc();
636        gc.add_object(make_obj("a", 100)).expect("add");
637        let removed = gc.remove_object(&oid("a")).expect("remove");
638        assert_eq!(removed.id, oid("a"));
639        assert_eq!(gc.object_count(), 0);
640    }
641
642    #[test]
643    fn test_remove_nonexistent_fails() {
644        let mut gc = default_gc();
645        let err = gc.remove_object(&oid("nope")).unwrap_err();
646        assert!(matches!(err, GcError::ObjectNotFound(_)));
647    }
648
649    #[test]
650    fn test_remove_pinned_fails() {
651        let mut gc = default_gc();
652        gc.add_object(make_obj("a", 100).pinned()).expect("add");
653        let err = gc.remove_object(&oid("a")).unwrap_err();
654        assert!(matches!(err, GcError::ObjectPinned(_)));
655    }
656
657    #[test]
658    fn test_remove_referenced_fails() {
659        let mut gc = default_gc();
660        gc.add_object(make_obj("a", 100).with_ref_count(2))
661            .expect("add");
662        let err = gc.remove_object(&oid("a")).unwrap_err();
663        assert!(
664            matches!(err, GcError::ObjectReferenced { ref_count: 2, .. }),
665            "got: {err:?}"
666        );
667    }
668
669    // ── add_root / remove_root ────────────────────────────────────────────────
670
671    #[test]
672    fn test_add_root_existing_object() {
673        let mut gc = default_gc();
674        gc.add_object(make_obj("r", 50)).expect("add");
675        assert!(gc.add_root(&oid("r")));
676        assert_eq!(gc.root_count(), 1);
677    }
678
679    #[test]
680    fn test_add_root_missing_object_returns_false() {
681        let mut gc = default_gc();
682        assert!(!gc.add_root(&oid("ghost")));
683        assert_eq!(gc.root_count(), 0);
684    }
685
686    #[test]
687    fn test_remove_root() {
688        let mut gc = default_gc();
689        gc.add_object(make_obj("r", 50)).expect("add");
690        gc.add_root(&oid("r"));
691        assert!(gc.remove_root(&oid("r")));
692        assert_eq!(gc.root_count(), 0);
693    }
694
695    #[test]
696    fn test_remove_root_not_present() {
697        let mut gc = default_gc();
698        assert!(!gc.remove_root(&oid("none")));
699    }
700
701    // ── ref counting ─────────────────────────────────────────────────────────
702
703    #[test]
704    fn test_increment_ref() {
705        let mut gc = default_gc();
706        gc.add_object(make_obj("a", 10)).expect("add");
707        assert!(gc.increment_ref(&oid("a")));
708        let obj = gc.objects.get(&oid("a")).expect("obj");
709        assert_eq!(obj.ref_count, 1);
710    }
711
712    #[test]
713    fn test_decrement_ref() {
714        let mut gc = default_gc();
715        gc.add_object(make_obj("a", 10).with_ref_count(3))
716            .expect("add");
717        assert!(gc.decrement_ref(&oid("a")));
718        let obj = gc.objects.get(&oid("a")).expect("obj");
719        assert_eq!(obj.ref_count, 2);
720    }
721
722    #[test]
723    fn test_decrement_ref_floors_at_zero() {
724        let mut gc = default_gc();
725        gc.add_object(make_obj("a", 10)).expect("add");
726        gc.decrement_ref(&oid("a")); // already 0
727        let obj = gc.objects.get(&oid("a")).expect("obj");
728        assert_eq!(obj.ref_count, 0);
729    }
730
731    #[test]
732    fn test_increment_ref_missing_returns_false() {
733        let mut gc = default_gc();
734        assert!(!gc.increment_ref(&oid("nope")));
735    }
736
737    // ── pinning ───────────────────────────────────────────────────────────────
738
739    #[test]
740    fn test_pin_and_unpin() {
741        let mut gc = default_gc();
742        gc.add_object(make_obj("a", 10)).expect("add");
743        assert!(gc.pin(&oid("a")));
744        assert!(gc.objects[&oid("a")].pinned);
745        assert!(gc.unpin(&oid("a")));
746        assert!(!gc.objects[&oid("a")].pinned);
747    }
748
749    #[test]
750    fn test_pin_missing_returns_false() {
751        let mut gc = default_gc();
752        assert!(!gc.pin(&oid("nope")));
753    }
754
755    // ── live_bytes / reachable_bytes ──────────────────────────────────────────
756
757    #[test]
758    fn test_live_bytes_empty() {
759        let gc = default_gc();
760        assert_eq!(gc.live_bytes(), 0);
761    }
762
763    #[test]
764    fn test_live_bytes_sum() {
765        let mut gc = default_gc();
766        gc.add_object(make_obj("a", 100)).expect("add");
767        gc.add_object(make_obj("b", 200)).expect("add");
768        assert_eq!(gc.live_bytes(), 300);
769    }
770
771    #[test]
772    fn test_reachable_bytes() {
773        let mut gc = default_gc();
774        gc.add_object(make_obj("a", 100)).expect("add");
775        gc.add_object(make_obj("b", 200)).expect("add");
776        let mut reachable = HashSet::new();
777        reachable.insert(oid("a"));
778        assert_eq!(gc.reachable_bytes(&reachable), 100);
779    }
780
781    // ── should_run ────────────────────────────────────────────────────────────
782
783    #[test]
784    fn test_should_run_below_threshold() {
785        let cfg = StorageGcConfig {
786            max_live_bytes: 1000,
787            sweep_threshold_fraction: 0.8,
788            ..StorageGcConfig::default()
789        };
790        let mut gc = StorageGarbageCollector::new(cfg);
791        gc.add_object(make_obj("a", 500)).expect("add"); // 50%
792        assert!(!gc.should_run());
793    }
794
795    #[test]
796    fn test_should_run_at_threshold() {
797        let cfg = StorageGcConfig {
798            max_live_bytes: 1000,
799            sweep_threshold_fraction: 0.8,
800            ..StorageGcConfig::default()
801        };
802        let mut gc = StorageGarbageCollector::new(cfg);
803        gc.add_object(make_obj("a", 800)).expect("add"); // exactly 80%
804        assert!(gc.should_run());
805    }
806
807    #[test]
808    fn test_should_run_zero_max_bytes() {
809        let cfg = StorageGcConfig {
810            max_live_bytes: 0,
811            ..StorageGcConfig::default()
812        };
813        let mut gc = StorageGarbageCollector::new(cfg);
814        gc.add_object(make_obj("a", 1)).expect("add");
815        assert!(!gc.should_run()); // division by zero guard
816    }
817
818    // ── mark_reachable ────────────────────────────────────────────────────────
819
820    #[test]
821    fn test_mark_reachable_from_root() {
822        let mut gc = default_gc();
823        gc.add_object(make_obj("root", 10)).expect("add");
824        gc.add_object(make_obj("child", 10)).expect("add");
825        // No link yet → child not reachable
826        gc.add_root(&oid("root"));
827        let reachable = gc.mark_reachable(1);
828        assert!(reachable.contains(&oid("root")));
829        assert!(!reachable.contains(&oid("child")));
830    }
831
832    #[test]
833    fn test_mark_reachable_follows_links() {
834        let mut gc = default_gc();
835        gc.add_object(make_obj_with_links("root", 10, vec!["child"]))
836            .expect("add");
837        gc.add_object(make_obj("child", 10)).expect("add");
838        gc.add_root(&oid("root"));
839        let reachable = gc.mark_reachable(1);
840        assert!(reachable.contains(&oid("child")));
841    }
842
843    #[test]
844    fn test_mark_reachable_deep_chain() {
845        let mut gc = default_gc();
846        // a -> b -> c -> d
847        gc.add_object(make_obj_with_links("a", 10, vec!["b"]))
848            .expect("add");
849        gc.add_object(make_obj_with_links("b", 10, vec!["c"]))
850            .expect("add");
851        gc.add_object(make_obj_with_links("c", 10, vec!["d"]))
852            .expect("add");
853        gc.add_object(make_obj("d", 10)).expect("add");
854        gc.add_root(&oid("a"));
855        let reachable = gc.mark_reachable(1);
856        for id in &["a", "b", "c", "d"] {
857            assert!(reachable.contains(&oid(id)), "missing: {id}");
858        }
859    }
860
861    #[test]
862    fn test_mark_reachable_pinned_as_seed() {
863        let mut gc = default_gc();
864        // pinned but not root → still marked
865        gc.add_object(make_obj("pinned", 10).pinned()).expect("add");
866        let reachable = gc.mark_reachable(1);
867        assert!(reachable.contains(&oid("pinned")));
868    }
869
870    #[test]
871    fn test_mark_sets_last_marked() {
872        let mut gc = default_gc();
873        gc.add_object(make_obj("a", 10)).expect("add");
874        gc.add_root(&oid("a"));
875        gc.mark_reachable(42);
876        assert_eq!(gc.objects[&oid("a")].last_marked, Some(42));
877    }
878
879    // ── run_gc ────────────────────────────────────────────────────────────────
880
881    #[test]
882    fn test_run_gc_sweeps_unreachable() {
883        let mut gc = default_gc();
884        gc.add_object(make_obj("orphan", 512)).expect("add");
885        let run = gc.run_gc(1);
886        assert_eq!(run.objects_swept, 1);
887        assert_eq!(run.bytes_freed, 512);
888        assert_eq!(gc.object_count(), 0);
889    }
890
891    #[test]
892    fn test_run_gc_keeps_roots() {
893        let mut gc = default_gc();
894        gc.add_object(make_obj("root", 100)).expect("add");
895        gc.add_root(&oid("root"));
896        let run = gc.run_gc(1);
897        assert_eq!(run.objects_swept, 0);
898        assert_eq!(run.bytes_freed, 0);
899        assert_eq!(gc.object_count(), 1);
900    }
901
902    #[test]
903    fn test_run_gc_keeps_pinned() {
904        let mut gc = default_gc();
905        gc.add_object(make_obj("pin", 100).pinned()).expect("add");
906        let run = gc.run_gc(1);
907        assert_eq!(run.objects_swept, 0);
908        assert_eq!(gc.object_count(), 1);
909    }
910
911    #[test]
912    fn test_run_gc_keeps_referenced() {
913        let mut gc = default_gc();
914        gc.add_object(make_obj("ref", 100).with_ref_count(1))
915            .expect("add");
916        let run = gc.run_gc(1);
917        // ref_count > 0 even though not reachable from root → not swept
918        assert_eq!(run.objects_swept, 0);
919    }
920
921    #[test]
922    fn test_run_gc_increments_run_id() {
923        let mut gc = default_gc();
924        let r1 = gc.run_gc(1);
925        let r2 = gc.run_gc(2);
926        assert_eq!(r1.id + 1, r2.id);
927    }
928
929    #[test]
930    fn test_run_gc_records_stats() {
931        let mut gc = default_gc();
932        gc.add_object(make_obj("orphan", 1024)).expect("add");
933        gc.run_gc(1);
934        let stats = gc.stats();
935        assert_eq!(stats.total_runs, 1);
936        assert_eq!(stats.last_run_freed_bytes, 1024);
937    }
938
939    #[test]
940    fn test_run_gc_marks_objects_count() {
941        let mut gc = default_gc();
942        gc.add_object(make_obj("a", 10)).expect("add");
943        gc.add_object(make_obj("b", 10)).expect("add");
944        gc.add_root(&oid("a"));
945        let run = gc.run_gc(1);
946        assert_eq!(run.objects_marked, 1); // only "a" reachable
947        assert_eq!(run.objects_swept, 1); // "b" swept
948    }
949
950    #[test]
951    fn test_run_gc_completed_at() {
952        let mut gc = default_gc();
953        let run = gc.run_gc(999);
954        assert_eq!(run.completed_at, Some(999));
955        assert_eq!(run.started_at, 999);
956    }
957
958    #[test]
959    fn test_run_gc_idempotent_on_empty_store() {
960        let mut gc = default_gc();
961        for epoch in 0..5u64 {
962            let run = gc.run_gc(epoch);
963            assert_eq!(run.objects_swept, 0);
964        }
965    }
966
967    // ── DAG traversal edge cases ──────────────────────────────────────────────
968
969    #[test]
970    fn test_diamond_dag() {
971        // root -> left, right; left -> leaf; right -> leaf
972        let mut gc = default_gc();
973        gc.add_object(make_obj_with_links("root", 10, vec!["left", "right"]))
974            .expect("add");
975        gc.add_object(make_obj_with_links("left", 10, vec!["leaf"]))
976            .expect("add");
977        gc.add_object(make_obj_with_links("right", 10, vec!["leaf"]))
978            .expect("add");
979        gc.add_object(make_obj("leaf", 10)).expect("add");
980        gc.add_root(&oid("root"));
981
982        let run = gc.run_gc(1);
983        assert_eq!(run.objects_swept, 0);
984        assert_eq!(gc.object_count(), 4);
985    }
986
987    #[test]
988    fn test_orphaned_subgraph() {
989        // root -> child (both reachable); orphan_a -> orphan_b (both swept)
990        let mut gc = default_gc();
991        gc.add_object(make_obj_with_links("root", 10, vec!["child"]))
992            .expect("add");
993        gc.add_object(make_obj("child", 20)).expect("add");
994        gc.add_object(make_obj_with_links("orphan_a", 30, vec!["orphan_b"]))
995            .expect("add");
996        gc.add_object(make_obj("orphan_b", 40)).expect("add");
997        gc.add_root(&oid("root"));
998
999        let run = gc.run_gc(1);
1000        assert_eq!(run.objects_swept, 2);
1001        assert_eq!(run.bytes_freed, 70);
1002    }
1003
1004    #[test]
1005    fn test_multiple_roots() {
1006        let mut gc = default_gc();
1007        gc.add_object(make_obj("r1", 10)).expect("add");
1008        gc.add_object(make_obj("r2", 10)).expect("add");
1009        gc.add_object(make_obj("orphan", 10)).expect("add");
1010        gc.add_root(&oid("r1"));
1011        gc.add_root(&oid("r2"));
1012
1013        let run = gc.run_gc(1);
1014        assert_eq!(run.objects_swept, 1);
1015        assert_eq!(gc.object_count(), 2);
1016    }
1017
1018    // ── stats ─────────────────────────────────────────────────────────────────
1019
1020    #[test]
1021    fn test_stats_pinned_count() {
1022        let mut gc = default_gc();
1023        gc.add_object(make_obj("a", 10).pinned()).expect("add");
1024        gc.add_object(make_obj("b", 10)).expect("add");
1025        let stats = gc.stats();
1026        assert_eq!(stats.pinned_count, 1);
1027    }
1028
1029    #[test]
1030    fn test_stats_zero_runs() {
1031        let gc = default_gc();
1032        let stats = gc.stats();
1033        assert_eq!(stats.total_runs, 0);
1034        assert_eq!(stats.last_run_freed_bytes, 0);
1035    }
1036
1037    // ── GcObject builder ──────────────────────────────────────────────────────
1038
1039    #[test]
1040    fn test_gc_object_new_defaults() {
1041        let obj = GcObject::new(oid("x"), 64, 100, vec![]);
1042        assert_eq!(obj.ref_count, 0);
1043        assert!(!obj.pinned);
1044        assert!(obj.last_marked.is_none());
1045    }
1046
1047    #[test]
1048    fn test_gc_object_with_ref_count() {
1049        let obj = GcObject::new(oid("x"), 64, 0, vec![]).with_ref_count(5);
1050        assert_eq!(obj.ref_count, 5);
1051    }
1052
1053    #[test]
1054    fn test_gc_object_pinned_builder() {
1055        let obj = GcObject::new(oid("x"), 64, 0, vec![]).pinned();
1056        assert!(obj.pinned);
1057    }
1058
1059    // ── GcObjectId ────────────────────────────────────────────────────────────
1060
1061    #[test]
1062    fn test_gc_object_id_display() {
1063        let id = GcObjectId::new("bafy123");
1064        assert_eq!(id.to_string(), "bafy123");
1065    }
1066
1067    #[test]
1068    fn test_gc_object_id_as_str() {
1069        let id = GcObjectId::new("bafy123");
1070        assert_eq!(id.as_str(), "bafy123");
1071    }
1072
1073    // ── GC run history cap ────────────────────────────────────────────────────
1074
1075    #[test]
1076    fn test_gc_run_history_capped_at_256() {
1077        let mut gc = default_gc();
1078        for epoch in 0..300u64 {
1079            gc.run_gc(epoch);
1080        }
1081        assert!(gc.gc_runs.len() <= 256, "history exceeded 256 entries");
1082    }
1083
1084    // ── Mixed scenario ────────────────────────────────────────────────────────
1085
1086    #[test]
1087    fn test_full_lifecycle() {
1088        let mut gc = default_gc();
1089
1090        // Build a DAG: root -> a -> b, c (orphan)
1091        gc.add_object(make_obj_with_links("root", 100, vec!["a"]))
1092            .expect("add root");
1093        gc.add_object(make_obj_with_links("a", 200, vec!["b"]))
1094            .expect("add a");
1095        gc.add_object(make_obj("b", 300)).expect("add b");
1096        gc.add_object(make_obj("c", 400)).expect("add c");
1097
1098        gc.add_root(&oid("root"));
1099
1100        // First GC: c swept
1101        let run1 = gc.run_gc(1);
1102        assert_eq!(run1.objects_swept, 1);
1103        assert_eq!(run1.bytes_freed, 400);
1104
1105        // Remove root → a and b become unreachable
1106        gc.remove_root(&oid("root"));
1107        let run2 = gc.run_gc(2);
1108        assert_eq!(run2.objects_swept, 3);
1109        assert_eq!(run2.bytes_freed, 600);
1110
1111        assert_eq!(gc.object_count(), 0);
1112        let stats = gc.stats();
1113        assert_eq!(stats.total_runs, 2);
1114    }
1115}