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}