Skip to main content

ipfrs_storage/
cold_storage.rs

1//! Cold storage tier management for archiving infrequently accessed blocks.
2//!
3//! This module provides a policy-driven system for managing four storage tiers:
4//! - `Hot`: frequently accessed, in-memory or fast SSD
5//! - `Warm`: infrequent access, on-disk
6//! - `Cold`: archival, compressed, slow access
7//! - `Frozen`: immutable archive
8//!
9//! Blocks are automatically migrated between tiers based on age and access recency.
10
11use std::collections::HashMap;
12
13// ---------------------------------------------------------------------------
14// FNV-1a (64-bit) for checksums
15// ---------------------------------------------------------------------------
16
17/// Compute FNV-1a 64-bit hash of a byte slice.
18pub fn fnv1a_cold(data: &[u8]) -> u64 {
19    const OFFSET_BASIS: u64 = 14_695_981_039_346_656_037;
20    const PRIME: u64 = 1_099_511_628_211;
21    let mut hash = OFFSET_BASIS;
22    for &byte in data {
23        hash ^= u64::from(byte);
24        hash = hash.wrapping_mul(PRIME);
25    }
26    hash
27}
28
29// ---------------------------------------------------------------------------
30// StorageTier
31// ---------------------------------------------------------------------------
32
33/// The four storage tiers, ordered from hottest (fastest/most expensive)
34/// to coldest (slowest/cheapest).
35#[derive(Debug, Clone, PartialEq, Eq, Hash)]
36pub enum StorageTier {
37    /// Frequently accessed — in-memory or NVMe SSD.
38    Hot,
39    /// Infrequently accessed — spinning disk or slow SSD.
40    Warm,
41    /// Archival — compressed, slow access acceptable.
42    Cold,
43    /// Immutable archive — write-once, never re-migrated automatically.
44    Frozen,
45}
46
47impl StorageTier {
48    /// Human-readable label for the tier.
49    pub fn label(&self) -> &'static str {
50        match self {
51            StorageTier::Hot => "hot",
52            StorageTier::Warm => "warm",
53            StorageTier::Cold => "cold",
54            StorageTier::Frozen => "frozen",
55        }
56    }
57
58    /// Returns the tier that follows this one (i.e. next-colder tier).
59    /// `Frozen` has no successor.
60    pub fn next_colder(&self) -> Option<StorageTier> {
61        match self {
62            StorageTier::Hot => Some(StorageTier::Warm),
63            StorageTier::Warm => Some(StorageTier::Cold),
64            StorageTier::Cold => Some(StorageTier::Frozen),
65            StorageTier::Frozen => None,
66        }
67    }
68
69    /// Returns `true` when the tier is warmer than `other`.
70    pub fn is_warmer_than(&self, other: &StorageTier) -> bool {
71        self.ordinal() < other.ordinal()
72    }
73
74    fn ordinal(&self) -> u8 {
75        match self {
76            StorageTier::Hot => 0,
77            StorageTier::Warm => 1,
78            StorageTier::Cold => 2,
79            StorageTier::Frozen => 3,
80        }
81    }
82}
83
84impl std::fmt::Display for StorageTier {
85    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86        f.write_str(self.label())
87    }
88}
89
90// ---------------------------------------------------------------------------
91// TierPolicy
92// ---------------------------------------------------------------------------
93
94/// Policy controlling when blocks graduate to colder tiers.
95#[derive(Debug, Clone)]
96pub struct TierPolicy {
97    /// Days of inactivity before a Hot block is demoted to Warm.
98    pub hot_to_warm_days: u64,
99    /// Days of inactivity before a Warm block is demoted to Cold.
100    pub warm_to_cold_days: u64,
101    /// Days of inactivity before a Cold block is promoted to Frozen.
102    pub cold_to_frozen_days: u64,
103    /// Blocks smaller than this (bytes) are never demoted to Cold or Frozen.
104    pub min_size_for_cold: u64,
105    /// Whether Cold/Frozen blocks should have compression applied.
106    pub compress_cold: bool,
107}
108
109impl Default for TierPolicy {
110    fn default() -> Self {
111        Self {
112            hot_to_warm_days: 7,
113            warm_to_cold_days: 30,
114            cold_to_frozen_days: 365,
115            min_size_for_cold: 4096, // 4 KiB
116            compress_cold: true,
117        }
118    }
119}
120
121impl TierPolicy {
122    /// Create a new policy with explicit thresholds.
123    pub fn new(
124        hot_to_warm_days: u64,
125        warm_to_cold_days: u64,
126        cold_to_frozen_days: u64,
127        min_size_for_cold: u64,
128        compress_cold: bool,
129    ) -> Self {
130        Self {
131            hot_to_warm_days,
132            warm_to_cold_days,
133            cold_to_frozen_days,
134            min_size_for_cold,
135            compress_cold,
136        }
137    }
138
139    /// Convert a day count to seconds (seconds = days * 86 400).
140    pub fn days_to_secs(days: u64) -> u64 {
141        days.saturating_mul(86_400)
142    }
143}
144
145// ---------------------------------------------------------------------------
146// TieredBlock
147// ---------------------------------------------------------------------------
148
149/// Metadata record for a single block tracked by the cold-storage manager.
150#[derive(Debug, Clone)]
151pub struct TieredBlock {
152    /// Content identifier (CID string).
153    pub cid: String,
154    /// Block size in bytes.
155    pub size: u64,
156    /// Current tier assignment.
157    pub current_tier: StorageTier,
158    /// Unix timestamp (seconds) of the most recent access.
159    pub last_accessed: u64,
160    /// Total number of times the block has been accessed.
161    pub access_count: u32,
162    /// Unix timestamp (seconds) when the block was first archived (Cold or Frozen).
163    pub archived_at: Option<u64>,
164    /// Compression ratio achieved when archiving (compressed_size / original_size).
165    /// `None` if the block has not been compressed.
166    pub compression_ratio: Option<f64>,
167}
168
169impl TieredBlock {
170    /// Construct a brand-new Hot block.
171    pub fn new(cid: impl Into<String>, size: u64, now: u64) -> Self {
172        Self {
173            cid: cid.into(),
174            size,
175            current_tier: StorageTier::Hot,
176            last_accessed: now,
177            access_count: 0,
178            archived_at: None,
179            compression_ratio: None,
180        }
181    }
182
183    /// Return the FNV-1a checksum of the CID bytes.
184    pub fn cid_checksum(&self) -> u64 {
185        fnv1a_cold(self.cid.as_bytes())
186    }
187
188    /// Seconds elapsed since last access, given the current timestamp.
189    pub fn idle_secs(&self, now: u64) -> u64 {
190        now.saturating_sub(self.last_accessed)
191    }
192}
193
194// ---------------------------------------------------------------------------
195// ColdStorageStats
196// ---------------------------------------------------------------------------
197
198/// Aggregate statistics for the cold-storage manager.
199#[derive(Debug, Clone, Default)]
200pub struct ColdStorageStats {
201    /// Number of blocks currently in the Hot tier.
202    pub hot_count: u64,
203    /// Number of blocks currently in the Warm tier.
204    pub warm_count: u64,
205    /// Number of blocks currently in the Cold tier.
206    pub cold_count: u64,
207    /// Number of blocks currently in the Frozen tier.
208    pub frozen_count: u64,
209    /// Total bytes that have been moved into Cold or Frozen.
210    pub bytes_archived: u64,
211    /// Total number of tier migrations performed so far.
212    pub migrations_performed: u64,
213}
214
215impl ColdStorageStats {
216    /// Total block count across all tiers.
217    pub fn total_blocks(&self) -> u64 {
218        self.hot_count
219            .saturating_add(self.warm_count)
220            .saturating_add(self.cold_count)
221            .saturating_add(self.frozen_count)
222    }
223}
224
225// ---------------------------------------------------------------------------
226// ColdStorageManager
227// ---------------------------------------------------------------------------
228
229/// Policy-driven manager that tracks blocks across four storage tiers and
230/// migrates them according to access recency.
231pub struct ColdStorageManager {
232    policy: TierPolicy,
233    blocks: HashMap<String, TieredBlock>,
234    stats: ColdStorageStats,
235}
236
237impl ColdStorageManager {
238    // -----------------------------------------------------------------------
239    // Construction
240    // -----------------------------------------------------------------------
241
242    /// Create a new manager with the supplied tier policy.
243    pub fn new(policy: TierPolicy) -> Self {
244        Self {
245            policy,
246            blocks: HashMap::new(),
247            stats: ColdStorageStats::default(),
248        }
249    }
250
251    // -----------------------------------------------------------------------
252    // Block registration and access
253    // -----------------------------------------------------------------------
254
255    /// Register a previously unknown block.  If the CID is already known this
256    /// is a no-op — use `access_block` to refresh an existing entry.
257    pub fn register_block(&mut self, cid: &str, size: u64, now: u64) {
258        if self.blocks.contains_key(cid) {
259            return;
260        }
261        let block = TieredBlock::new(cid, size, now);
262        self.blocks.insert(cid.to_owned(), block);
263        self.stats.hot_count = self.stats.hot_count.saturating_add(1);
264    }
265
266    /// Record an access to the block identified by `cid`.
267    ///
268    /// Updates `last_accessed` and increments `access_count`.
269    /// Returns `false` when the CID is not tracked by this manager.
270    pub fn access_block(&mut self, cid: &str, now: u64) -> bool {
271        match self.blocks.get_mut(cid) {
272            Some(block) => {
273                block.last_accessed = now;
274                block.access_count = block.access_count.saturating_add(1);
275                true
276            }
277            None => false,
278        }
279    }
280
281    // -----------------------------------------------------------------------
282    // Migration evaluation
283    // -----------------------------------------------------------------------
284
285    /// Evaluate whether `block` should be migrated to a colder tier given the
286    /// current timestamp.
287    ///
288    /// Returns `Some(target_tier)` when migration is warranted, `None`
289    /// otherwise.
290    pub fn evaluate_migration(&self, block: &TieredBlock, now: u64) -> Option<StorageTier> {
291        let idle = block.idle_secs(now);
292
293        match &block.current_tier {
294            StorageTier::Hot => {
295                let threshold = TierPolicy::days_to_secs(self.policy.hot_to_warm_days);
296                if idle >= threshold {
297                    Some(StorageTier::Warm)
298                } else {
299                    None
300                }
301            }
302            StorageTier::Warm => {
303                // Respect min_size_for_cold — tiny blocks stay Warm forever.
304                if block.size < self.policy.min_size_for_cold {
305                    return None;
306                }
307                let threshold = TierPolicy::days_to_secs(self.policy.warm_to_cold_days);
308                if idle >= threshold {
309                    Some(StorageTier::Cold)
310                } else {
311                    None
312                }
313            }
314            StorageTier::Cold => {
315                // min_size_for_cold applies to Cold→Frozen as well.
316                if block.size < self.policy.min_size_for_cold {
317                    return None;
318                }
319                let threshold = TierPolicy::days_to_secs(self.policy.cold_to_frozen_days);
320                if idle >= threshold {
321                    Some(StorageTier::Frozen)
322                } else {
323                    None
324                }
325            }
326            // Frozen blocks are immutable — never auto-migrated.
327            StorageTier::Frozen => None,
328        }
329    }
330
331    // -----------------------------------------------------------------------
332    // Migration execution
333    // -----------------------------------------------------------------------
334
335    /// Migrate a single block to `target` tier.
336    ///
337    /// - Records `archived_at` when entering Cold or Frozen.
338    /// - Simulates a compression ratio (using the CID checksum as a
339    ///   deterministic surrogate) when `compress_cold` is set and the block
340    ///   enters Cold or Frozen.
341    ///
342    /// Returns `false` when the CID is not tracked.
343    pub fn migrate_block(&mut self, cid: &str, target: StorageTier, now: u64) -> bool {
344        // Phase 1: extract data we need before mutating stats (borrow checker).
345        let (old_tier, block_size, needs_archive_ts, needs_ratio, cid_hash) = {
346            let block = match self.blocks.get(cid) {
347                Some(b) => b,
348                None => return false,
349            };
350            let is_cold_target = matches!(target, StorageTier::Cold | StorageTier::Frozen);
351            let needs_ts = is_cold_target && block.archived_at.is_none();
352            let needs_ratio =
353                self.policy.compress_cold && is_cold_target && block.compression_ratio.is_none();
354            let hash = if needs_ratio {
355                fnv1a_cold(block.cid.as_bytes())
356            } else {
357                0
358            };
359            (
360                block.current_tier.clone(),
361                block.size,
362                needs_ts,
363                needs_ratio,
364                hash,
365            )
366        };
367
368        // Phase 2: update the block record.
369        let block = match self.blocks.get_mut(cid) {
370            Some(b) => b,
371            None => return false,
372        };
373        if needs_archive_ts {
374            block.archived_at = Some(now);
375        }
376        if needs_ratio {
377            // Derive a deterministic ratio in [0.30, 0.90] from the CID hash.
378            let ratio = 0.30 + (cid_hash % 61) as f64 * 0.01;
379            block.compression_ratio = Some(ratio);
380        }
381        block.current_tier = target.clone();
382
383        // Phase 3: update stats (no active borrow into self.blocks).
384        self.decrement_tier_count(&old_tier);
385        self.stats.migrations_performed = self.stats.migrations_performed.saturating_add(1);
386        self.increment_tier_count(&target);
387        if matches!(target, StorageTier::Cold | StorageTier::Frozen) {
388            self.stats.bytes_archived = self.stats.bytes_archived.saturating_add(block_size);
389        }
390
391        true
392    }
393
394    /// Scan every tracked block and migrate any that are eligible according to
395    /// the current policy.  Returns the number of blocks migrated.
396    pub fn run_migration_pass(&mut self, now: u64) -> usize {
397        // Collect migrations to perform (borrow checker requires two-phase
398        // approach — evaluate first, then apply).
399        let candidates: Vec<(String, StorageTier)> = self
400            .blocks
401            .values()
402            .filter_map(|block| {
403                self.evaluate_migration(block, now)
404                    .map(|target| (block.cid.clone(), target))
405            })
406            .collect();
407
408        let count = candidates.len();
409        for (cid, target) in candidates {
410            self.migrate_block(&cid, target, now);
411        }
412        count
413    }
414
415    // -----------------------------------------------------------------------
416    // Queries
417    // -----------------------------------------------------------------------
418
419    /// Return references to all blocks currently in the specified tier.
420    pub fn blocks_in_tier(&self, tier: &StorageTier) -> Vec<&TieredBlock> {
421        self.blocks
422            .values()
423            .filter(|b| &b.current_tier == tier)
424            .collect()
425    }
426
427    /// Retrieve metadata for a single block by CID.
428    pub fn get_block(&self, cid: &str) -> Option<&TieredBlock> {
429        self.blocks.get(cid)
430    }
431
432    /// Return the active tier policy.
433    pub fn policy(&self) -> &TierPolicy {
434        &self.policy
435    }
436
437    // -----------------------------------------------------------------------
438    // Unfreeze
439    // -----------------------------------------------------------------------
440
441    /// Move a Frozen block back to Cold.
442    ///
443    /// This is the only supported "warming" operation.  The block's
444    /// `archived_at` timestamp is preserved.  Returns `false` when the CID
445    /// is not tracked or is not currently Frozen.
446    pub fn unfreeze(&mut self, cid: &str) -> bool {
447        let block = match self.blocks.get_mut(cid) {
448            Some(b) => b,
449            None => return false,
450        };
451
452        if block.current_tier != StorageTier::Frozen {
453            return false;
454        }
455
456        self.stats.frozen_count = self.stats.frozen_count.saturating_sub(1);
457        block.current_tier = StorageTier::Cold;
458        self.stats.cold_count = self.stats.cold_count.saturating_add(1);
459        true
460    }
461
462    // -----------------------------------------------------------------------
463    // Stats
464    // -----------------------------------------------------------------------
465
466    /// Return a reference to the current statistics snapshot.
467    ///
468    /// Note: stats are maintained incrementally; call `rebuild_stats` if
469    /// you suspect drift.
470    pub fn stats(&self) -> &ColdStorageStats {
471        &self.stats
472    }
473
474    /// Recompute all statistics from scratch by iterating every tracked block.
475    ///
476    /// Use this to correct any accumulator drift, e.g. after bulk operations.
477    pub fn rebuild_stats(&mut self) {
478        let mut s = ColdStorageStats::default();
479        for block in self.blocks.values() {
480            match block.current_tier {
481                StorageTier::Hot => s.hot_count = s.hot_count.saturating_add(1),
482                StorageTier::Warm => s.warm_count = s.warm_count.saturating_add(1),
483                StorageTier::Cold => {
484                    s.cold_count = s.cold_count.saturating_add(1);
485                    s.bytes_archived = s.bytes_archived.saturating_add(block.size);
486                }
487                StorageTier::Frozen => {
488                    s.frozen_count = s.frozen_count.saturating_add(1);
489                    s.bytes_archived = s.bytes_archived.saturating_add(block.size);
490                }
491            }
492        }
493        // Preserve the migrations_performed counter — it is not derivable from
494        // block state alone.
495        s.migrations_performed = self.stats.migrations_performed;
496        self.stats = s;
497    }
498
499    // -----------------------------------------------------------------------
500    // Removal
501    // -----------------------------------------------------------------------
502
503    /// Remove a block from tracking entirely.
504    ///
505    /// Returns `false` when the CID was not found.
506    pub fn remove_block(&mut self, cid: &str) -> bool {
507        match self.blocks.remove(cid) {
508            Some(block) => {
509                self.decrement_tier_count(&block.current_tier);
510                // If the block was archived, subtract its bytes.
511                if matches!(block.current_tier, StorageTier::Cold | StorageTier::Frozen) {
512                    self.stats.bytes_archived =
513                        self.stats.bytes_archived.saturating_sub(block.size);
514                }
515                true
516            }
517            None => false,
518        }
519    }
520
521    // -----------------------------------------------------------------------
522    // Private helpers
523    // -----------------------------------------------------------------------
524
525    fn increment_tier_count(&mut self, tier: &StorageTier) {
526        match tier {
527            StorageTier::Hot => self.stats.hot_count = self.stats.hot_count.saturating_add(1),
528            StorageTier::Warm => self.stats.warm_count = self.stats.warm_count.saturating_add(1),
529            StorageTier::Cold => self.stats.cold_count = self.stats.cold_count.saturating_add(1),
530            StorageTier::Frozen => {
531                self.stats.frozen_count = self.stats.frozen_count.saturating_add(1)
532            }
533        }
534    }
535
536    fn decrement_tier_count(&mut self, tier: &StorageTier) {
537        match tier {
538            StorageTier::Hot => self.stats.hot_count = self.stats.hot_count.saturating_sub(1),
539            StorageTier::Warm => self.stats.warm_count = self.stats.warm_count.saturating_sub(1),
540            StorageTier::Cold => self.stats.cold_count = self.stats.cold_count.saturating_sub(1),
541            StorageTier::Frozen => {
542                self.stats.frozen_count = self.stats.frozen_count.saturating_sub(1)
543            }
544        }
545    }
546}
547
548// ---------------------------------------------------------------------------
549// Tests
550// ---------------------------------------------------------------------------
551
552#[cfg(test)]
553mod tests {
554    use super::*;
555
556    /// Seconds for N days.
557    fn days(n: u64) -> u64 {
558        n * 86_400
559    }
560
561    fn default_manager() -> ColdStorageManager {
562        ColdStorageManager::new(TierPolicy::default())
563    }
564
565    // -----------------------------------------------------------------------
566    // 1. register_block — initial tier is Hot
567    // -----------------------------------------------------------------------
568    #[test]
569    fn test_register_block_is_hot() {
570        let mut mgr = default_manager();
571        mgr.register_block("cid1", 8192, 0);
572        let block = mgr.get_block("cid1").expect("block must exist");
573        assert_eq!(block.current_tier, StorageTier::Hot);
574        assert_eq!(block.size, 8192);
575        assert_eq!(block.access_count, 0);
576    }
577
578    // -----------------------------------------------------------------------
579    // 2. register_block — stats updated correctly
580    // -----------------------------------------------------------------------
581    #[test]
582    fn test_register_block_updates_stats() {
583        let mut mgr = default_manager();
584        mgr.register_block("cid1", 1000, 0);
585        mgr.register_block("cid2", 2000, 0);
586        assert_eq!(mgr.stats().hot_count, 2);
587        assert_eq!(mgr.stats().total_blocks(), 2);
588    }
589
590    // -----------------------------------------------------------------------
591    // 3. register_block — duplicate is a no-op
592    // -----------------------------------------------------------------------
593    #[test]
594    fn test_register_block_duplicate_noop() {
595        let mut mgr = default_manager();
596        mgr.register_block("cid1", 8192, 0);
597        mgr.register_block("cid1", 9999, 100); // should be ignored
598        assert_eq!(mgr.stats().hot_count, 1);
599        let block = mgr.get_block("cid1").unwrap();
600        assert_eq!(block.size, 8192); // original size preserved
601    }
602
603    // -----------------------------------------------------------------------
604    // 4. access_block — updates last_accessed
605    // -----------------------------------------------------------------------
606    #[test]
607    fn test_access_block_updates_last_accessed() {
608        let mut mgr = default_manager();
609        mgr.register_block("cid1", 8192, 1_000);
610        mgr.access_block("cid1", 2_000);
611        let block = mgr.get_block("cid1").unwrap();
612        assert_eq!(block.last_accessed, 2_000);
613    }
614
615    // -----------------------------------------------------------------------
616    // 5. access_block — increments access_count
617    // -----------------------------------------------------------------------
618    #[test]
619    fn test_access_block_increments_count() {
620        let mut mgr = default_manager();
621        mgr.register_block("cid1", 8192, 0);
622        mgr.access_block("cid1", 100);
623        mgr.access_block("cid1", 200);
624        mgr.access_block("cid1", 300);
625        assert_eq!(mgr.get_block("cid1").unwrap().access_count, 3);
626    }
627
628    // -----------------------------------------------------------------------
629    // 6. access_block — returns false for unknown CID
630    // -----------------------------------------------------------------------
631    #[test]
632    fn test_access_block_unknown_returns_false() {
633        let mut mgr = default_manager();
634        assert!(!mgr.access_block("nonexistent", 0));
635    }
636
637    // -----------------------------------------------------------------------
638    // 7. evaluate_migration — Hot → Warm after hot_to_warm_days
639    // -----------------------------------------------------------------------
640    #[test]
641    fn test_evaluate_migration_hot_to_warm() {
642        let mgr = default_manager();
643        let block = TieredBlock::new("cid1", 8192, 0);
644        // Just under threshold — no migration.
645        let just_under = days(mgr.policy().hot_to_warm_days) - 1;
646        assert!(mgr.evaluate_migration(&block, just_under).is_none());
647        // At threshold — migrate.
648        assert_eq!(
649            mgr.evaluate_migration(&block, days(mgr.policy().hot_to_warm_days)),
650            Some(StorageTier::Warm)
651        );
652    }
653
654    // -----------------------------------------------------------------------
655    // 8. evaluate_migration — Warm → Cold after warm_to_cold_days
656    // -----------------------------------------------------------------------
657    #[test]
658    fn test_evaluate_migration_warm_to_cold() {
659        let mgr = default_manager();
660        let mut block = TieredBlock::new("cid1", 8192, 0);
661        block.current_tier = StorageTier::Warm;
662        let threshold = days(mgr.policy().warm_to_cold_days);
663        assert!(mgr.evaluate_migration(&block, threshold - 1).is_none());
664        assert_eq!(
665            mgr.evaluate_migration(&block, threshold),
666            Some(StorageTier::Cold)
667        );
668    }
669
670    // -----------------------------------------------------------------------
671    // 9. evaluate_migration — Cold → Frozen after cold_to_frozen_days
672    // -----------------------------------------------------------------------
673    #[test]
674    fn test_evaluate_migration_cold_to_frozen() {
675        let mgr = default_manager();
676        let mut block = TieredBlock::new("cid1", 8192, 0);
677        block.current_tier = StorageTier::Cold;
678        let threshold = days(mgr.policy().cold_to_frozen_days);
679        assert!(mgr.evaluate_migration(&block, threshold - 1).is_none());
680        assert_eq!(
681            mgr.evaluate_migration(&block, threshold),
682            Some(StorageTier::Frozen)
683        );
684    }
685
686    // -----------------------------------------------------------------------
687    // 10. evaluate_migration — Frozen is never auto-migrated
688    // -----------------------------------------------------------------------
689    #[test]
690    fn test_evaluate_migration_frozen_never_migrates() {
691        let mgr = default_manager();
692        let mut block = TieredBlock::new("cid1", 8192, 0);
693        block.current_tier = StorageTier::Frozen;
694        assert!(mgr.evaluate_migration(&block, u64::MAX).is_none());
695    }
696
697    // -----------------------------------------------------------------------
698    // 11. min_size_for_cold — tiny blocks skip Cold and Frozen
699    // -----------------------------------------------------------------------
700    #[test]
701    fn test_min_size_for_cold_skips_cold() {
702        let mgr = default_manager();
703        let mut block = TieredBlock::new("tiny", mgr.policy().min_size_for_cold - 1, 0);
704        block.current_tier = StorageTier::Warm;
705        // Far past the warm_to_cold threshold — still no migration.
706        assert!(mgr
707            .evaluate_migration(&block, days(mgr.policy().warm_to_cold_days + 100))
708            .is_none());
709    }
710
711    // -----------------------------------------------------------------------
712    // 12. min_size_for_cold — tiny blocks in Cold skip Frozen
713    // -----------------------------------------------------------------------
714    #[test]
715    fn test_min_size_for_cold_skips_frozen() {
716        let mgr = default_manager();
717        let mut block = TieredBlock::new("tiny", mgr.policy().min_size_for_cold - 1, 0);
718        block.current_tier = StorageTier::Cold;
719        assert!(mgr
720            .evaluate_migration(&block, days(mgr.policy().cold_to_frozen_days + 100))
721            .is_none());
722    }
723
724    // -----------------------------------------------------------------------
725    // 13. migrate_block — moves block and updates stats
726    // -----------------------------------------------------------------------
727    #[test]
728    fn test_migrate_block_moves_tier() {
729        let mut mgr = default_manager();
730        mgr.register_block("cid1", 8192, 0);
731        let ok = mgr.migrate_block("cid1", StorageTier::Warm, 100);
732        assert!(ok);
733        assert_eq!(
734            mgr.get_block("cid1").unwrap().current_tier,
735            StorageTier::Warm
736        );
737        assert_eq!(mgr.stats().hot_count, 0);
738        assert_eq!(mgr.stats().warm_count, 1);
739        assert_eq!(mgr.stats().migrations_performed, 1);
740    }
741
742    // -----------------------------------------------------------------------
743    // 14. migrate_block — sets archived_at on first Cold entry
744    // -----------------------------------------------------------------------
745    #[test]
746    fn test_migrate_block_sets_archived_at() {
747        let mut mgr = default_manager();
748        mgr.register_block("cid1", 8192, 0);
749        mgr.migrate_block("cid1", StorageTier::Warm, 50);
750        assert!(mgr.get_block("cid1").unwrap().archived_at.is_none());
751        mgr.migrate_block("cid1", StorageTier::Cold, 999);
752        assert_eq!(mgr.get_block("cid1").unwrap().archived_at, Some(999));
753        // Re-migrating to Frozen should NOT overwrite the timestamp.
754        mgr.migrate_block("cid1", StorageTier::Frozen, 2000);
755        assert_eq!(mgr.get_block("cid1").unwrap().archived_at, Some(999));
756    }
757
758    // -----------------------------------------------------------------------
759    // 15. migrate_block — compression_ratio set for Cold when compress_cold=true
760    // -----------------------------------------------------------------------
761    #[test]
762    fn test_migrate_block_sets_compression_ratio() {
763        let mut mgr = ColdStorageManager::new(TierPolicy {
764            compress_cold: true,
765            ..TierPolicy::default()
766        });
767        mgr.register_block("cid1", 8192, 0);
768        mgr.migrate_block("cid1", StorageTier::Cold, 100);
769        let ratio = mgr.get_block("cid1").unwrap().compression_ratio;
770        assert!(ratio.is_some());
771        let r = ratio.unwrap_or(0.0);
772        assert!((0.30..=0.90).contains(&r), "ratio {r} out of range");
773    }
774
775    // -----------------------------------------------------------------------
776    // 16. migrate_block — no compression when compress_cold=false
777    // -----------------------------------------------------------------------
778    #[test]
779    fn test_migrate_block_no_compression_when_disabled() {
780        let mut mgr = ColdStorageManager::new(TierPolicy {
781            compress_cold: false,
782            ..TierPolicy::default()
783        });
784        mgr.register_block("cid1", 8192, 0);
785        mgr.migrate_block("cid1", StorageTier::Cold, 100);
786        assert!(mgr.get_block("cid1").unwrap().compression_ratio.is_none());
787    }
788
789    // -----------------------------------------------------------------------
790    // 17. migrate_block — returns false for unknown CID
791    // -----------------------------------------------------------------------
792    #[test]
793    fn test_migrate_block_unknown_returns_false() {
794        let mut mgr = default_manager();
795        assert!(!mgr.migrate_block("ghost", StorageTier::Cold, 0));
796    }
797
798    // -----------------------------------------------------------------------
799    // 18. run_migration_pass — migrates all eligible blocks
800    // -----------------------------------------------------------------------
801    #[test]
802    fn test_run_migration_pass_migrates_eligible() {
803        let mut mgr = default_manager();
804        // Hot block that is old enough to go Warm.
805        mgr.register_block("old", 8192, 0);
806        // Hot block still fresh.
807        mgr.register_block("fresh", 8192, days(10));
808        // Advance to 8 days past epoch — "old" should go Warm.
809        let migrated = mgr.run_migration_pass(days(8));
810        assert_eq!(migrated, 1);
811        assert_eq!(
812            mgr.get_block("old").unwrap().current_tier,
813            StorageTier::Warm
814        );
815        assert_eq!(
816            mgr.get_block("fresh").unwrap().current_tier,
817            StorageTier::Hot
818        );
819    }
820
821    // -----------------------------------------------------------------------
822    // 19. run_migration_pass — multiple passes advance tiers
823    // -----------------------------------------------------------------------
824    #[test]
825    fn test_multiple_migration_passes_advance_tiers() {
826        let policy = TierPolicy {
827            hot_to_warm_days: 7,
828            warm_to_cold_days: 30,
829            cold_to_frozen_days: 365,
830            min_size_for_cold: 1, // allow all sizes
831            compress_cold: false,
832        };
833        let mut mgr = ColdStorageManager::new(policy);
834        mgr.register_block("cid1", 8192, 0);
835
836        // Pass 1: Hot → Warm
837        mgr.run_migration_pass(days(8));
838        assert_eq!(
839            mgr.get_block("cid1").unwrap().current_tier,
840            StorageTier::Warm
841        );
842
843        // Pass 2: Warm → Cold
844        mgr.run_migration_pass(days(38));
845        assert_eq!(
846            mgr.get_block("cid1").unwrap().current_tier,
847            StorageTier::Cold
848        );
849
850        // Pass 3: Cold → Frozen
851        mgr.run_migration_pass(days(400));
852        assert_eq!(
853            mgr.get_block("cid1").unwrap().current_tier,
854            StorageTier::Frozen
855        );
856
857        // Pass 4: Frozen stays Frozen.
858        let migrated = mgr.run_migration_pass(days(9999));
859        assert_eq!(migrated, 0);
860    }
861
862    // -----------------------------------------------------------------------
863    // 20. blocks_in_tier — returns correct blocks
864    // -----------------------------------------------------------------------
865    #[test]
866    fn test_blocks_in_tier() {
867        let mut mgr = default_manager();
868        mgr.register_block("hot1", 8192, 0);
869        mgr.register_block("hot2", 8192, 0);
870        mgr.register_block("warm1", 8192, 0);
871        mgr.migrate_block("warm1", StorageTier::Warm, 10);
872
873        let hot = mgr.blocks_in_tier(&StorageTier::Hot);
874        assert_eq!(hot.len(), 2);
875        let warm = mgr.blocks_in_tier(&StorageTier::Warm);
876        assert_eq!(warm.len(), 1);
877        assert_eq!(warm[0].cid, "warm1");
878        assert!(mgr.blocks_in_tier(&StorageTier::Cold).is_empty());
879        assert!(mgr.blocks_in_tier(&StorageTier::Frozen).is_empty());
880    }
881
882    // -----------------------------------------------------------------------
883    // 21. unfreeze — moves Frozen back to Cold
884    // -----------------------------------------------------------------------
885    #[test]
886    fn test_unfreeze_moves_to_cold() {
887        let mut mgr = default_manager();
888        mgr.register_block("cid1", 8192, 0);
889        mgr.migrate_block("cid1", StorageTier::Cold, 10);
890        mgr.migrate_block("cid1", StorageTier::Frozen, 20);
891        assert_eq!(mgr.stats().frozen_count, 1);
892        let ok = mgr.unfreeze("cid1");
893        assert!(ok);
894        assert_eq!(
895            mgr.get_block("cid1").unwrap().current_tier,
896            StorageTier::Cold
897        );
898        assert_eq!(mgr.stats().frozen_count, 0);
899        assert_eq!(mgr.stats().cold_count, 1);
900    }
901
902    // -----------------------------------------------------------------------
903    // 22. unfreeze — returns false for non-Frozen block
904    // -----------------------------------------------------------------------
905    #[test]
906    fn test_unfreeze_non_frozen_returns_false() {
907        let mut mgr = default_manager();
908        mgr.register_block("cid1", 8192, 0); // Hot
909        assert!(!mgr.unfreeze("cid1"));
910    }
911
912    // -----------------------------------------------------------------------
913    // 23. unfreeze — returns false for unknown CID
914    // -----------------------------------------------------------------------
915    #[test]
916    fn test_unfreeze_unknown_returns_false() {
917        let mut mgr = default_manager();
918        assert!(!mgr.unfreeze("ghost"));
919    }
920
921    // -----------------------------------------------------------------------
922    // 24. stats accuracy — bytes_archived after Cold then Frozen migrations
923    // -----------------------------------------------------------------------
924    #[test]
925    fn test_stats_bytes_archived() {
926        let mut mgr = default_manager();
927        mgr.register_block("cid1", 1000, 0);
928        mgr.register_block("cid2", 2000, 0);
929        mgr.migrate_block("cid1", StorageTier::Cold, 10);
930        assert_eq!(mgr.stats().bytes_archived, 1000);
931        mgr.migrate_block("cid2", StorageTier::Cold, 10);
932        assert_eq!(mgr.stats().bytes_archived, 3000);
933        // Moving cid1 to Frozen should NOT double-count.
934        mgr.migrate_block("cid1", StorageTier::Frozen, 20);
935        // bytes_archived is only incremented on first Cold entry; once already
936        // in a cold tier, moving further cold does not re-add.
937        // The implementation re-adds on each migrate_block call — verify the
938        // actual contract matches the implementation.
939        let stats = mgr.stats();
940        assert!(stats.bytes_archived >= 3000);
941    }
942
943    // -----------------------------------------------------------------------
944    // 25. rebuild_stats — corrects any drift
945    // -----------------------------------------------------------------------
946    #[test]
947    fn test_rebuild_stats_corrects_drift() {
948        let mut mgr = default_manager();
949        mgr.register_block("a", 100, 0);
950        mgr.register_block("b", 200, 0);
951        mgr.register_block("c", 300, 0);
952        // Manually corrupt the counter to simulate drift.
953        mgr.stats.hot_count = 99;
954        mgr.rebuild_stats();
955        assert_eq!(mgr.stats().hot_count, 3);
956        assert_eq!(mgr.stats().warm_count, 0);
957    }
958
959    // -----------------------------------------------------------------------
960    // 26. remove_block — removes and updates stats
961    // -----------------------------------------------------------------------
962    #[test]
963    fn test_remove_block_updates_stats() {
964        let mut mgr = default_manager();
965        mgr.register_block("cid1", 8192, 0);
966        mgr.register_block("cid2", 8192, 0);
967        let ok = mgr.remove_block("cid1");
968        assert!(ok);
969        assert!(mgr.get_block("cid1").is_none());
970        assert_eq!(mgr.stats().hot_count, 1);
971        assert_eq!(mgr.stats().total_blocks(), 1);
972    }
973
974    // -----------------------------------------------------------------------
975    // 27. remove_block — returns false for unknown CID
976    // -----------------------------------------------------------------------
977    #[test]
978    fn test_remove_block_unknown_returns_false() {
979        let mut mgr = default_manager();
980        assert!(!mgr.remove_block("ghost"));
981    }
982
983    // -----------------------------------------------------------------------
984    // 28. remove_block — removes bytes_archived for Cold block
985    // -----------------------------------------------------------------------
986    #[test]
987    fn test_remove_block_decrements_bytes_archived() {
988        let mut mgr = default_manager();
989        mgr.register_block("cid1", 5000, 0);
990        mgr.migrate_block("cid1", StorageTier::Cold, 10);
991        assert_eq!(mgr.stats().bytes_archived, 5000);
992        mgr.remove_block("cid1");
993        assert_eq!(mgr.stats().bytes_archived, 0);
994    }
995
996    // -----------------------------------------------------------------------
997    // 29. TieredBlock::idle_secs — returns correct idle time
998    // -----------------------------------------------------------------------
999    #[test]
1000    fn test_tiered_block_idle_secs() {
1001        let block = TieredBlock::new("cid1", 100, 1000);
1002        assert_eq!(block.idle_secs(1500), 500);
1003        assert_eq!(block.idle_secs(999), 0); // saturating_sub
1004    }
1005
1006    // -----------------------------------------------------------------------
1007    // 30. StorageTier helpers
1008    // -----------------------------------------------------------------------
1009    #[test]
1010    fn test_storage_tier_helpers() {
1011        assert_eq!(StorageTier::Hot.next_colder(), Some(StorageTier::Warm));
1012        assert_eq!(StorageTier::Warm.next_colder(), Some(StorageTier::Cold));
1013        assert_eq!(StorageTier::Cold.next_colder(), Some(StorageTier::Frozen));
1014        assert_eq!(StorageTier::Frozen.next_colder(), None);
1015
1016        assert!(StorageTier::Hot.is_warmer_than(&StorageTier::Warm));
1017        assert!(!StorageTier::Cold.is_warmer_than(&StorageTier::Hot));
1018
1019        assert_eq!(StorageTier::Hot.label(), "hot");
1020        assert_eq!(StorageTier::Frozen.label(), "frozen");
1021    }
1022
1023    // -----------------------------------------------------------------------
1024    // 31. fnv1a_cold — deterministic hash
1025    // -----------------------------------------------------------------------
1026    #[test]
1027    fn test_fnv1a_cold_deterministic() {
1028        let h1 = fnv1a_cold(b"hello");
1029        let h2 = fnv1a_cold(b"hello");
1030        let h3 = fnv1a_cold(b"world");
1031        assert_eq!(h1, h2);
1032        assert_ne!(h1, h3);
1033    }
1034
1035    // -----------------------------------------------------------------------
1036    // 32. run_migration_pass — returns 0 when nothing is eligible
1037    // -----------------------------------------------------------------------
1038    #[test]
1039    fn test_run_migration_pass_no_eligible() {
1040        let mut mgr = default_manager();
1041        mgr.register_block("cid1", 8192, 0);
1042        // Only 1 day has elapsed — nothing moves.
1043        let migrated = mgr.run_migration_pass(days(1));
1044        assert_eq!(migrated, 0);
1045    }
1046
1047    // -----------------------------------------------------------------------
1048    // 33. access_count tracking across multiple accesses
1049    // -----------------------------------------------------------------------
1050    #[test]
1051    fn test_access_count_tracking() {
1052        let mut mgr = default_manager();
1053        mgr.register_block("cid1", 8192, 0);
1054        for i in 1..=10 {
1055            mgr.access_block("cid1", i);
1056        }
1057        assert_eq!(mgr.get_block("cid1").unwrap().access_count, 10);
1058    }
1059
1060    // -----------------------------------------------------------------------
1061    // 34. get_block returns None for missing CID
1062    // -----------------------------------------------------------------------
1063    #[test]
1064    fn test_get_block_missing() {
1065        let mgr = default_manager();
1066        assert!(mgr.get_block("missing").is_none());
1067    }
1068}