Skip to main content

ipfrs_storage/
tier_migrator.rs

1//! Block tier migration manager.
2//!
3//! Manages migration of blocks between storage tiers (Hot/Warm/Cold/Archive)
4//! based on access patterns and configurable policies.
5//!
6//! # Example
7//!
8//! ```rust
9//! use ipfrs_storage::tier_migrator::{BlockTierMigrator, MigrationPolicy, StorageTier};
10//!
11//! let policy = MigrationPolicy::default();
12//! let mut migrator = BlockTierMigrator::new(policy);
13//!
14//! let now = 1_000_000u64;
15//! migrator.register("cid1".to_string(), StorageTier::Hot, 8192, now);
16//!
17//! // After idle time exceeds hot_to_warm_idle_secs, plan migration
18//! let tasks = migrator.plan_migrations(now + 90_000);
19//! for task in &tasks {
20//!     migrator.apply_migration(task);
21//! }
22//! ```
23
24use std::collections::HashMap;
25
26// ---------------------------------------------------------------------------
27// StorageTier
28// ---------------------------------------------------------------------------
29
30/// Storage tier classification with ordering from hottest (0) to coldest (3).
31#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
32pub enum StorageTier {
33    /// Hot tier — low latency, highest cost per GB.
34    Hot = 0,
35    /// Warm tier — moderate latency and cost.
36    Warm = 1,
37    /// Cold tier — higher latency, low cost.
38    Cold = 2,
39    /// Archive tier — very high latency, cheapest storage.
40    Archive = 3,
41}
42
43impl StorageTier {
44    /// Cost in USD per GB per month.
45    pub fn cost_per_gb(&self) -> f64 {
46        match self {
47            StorageTier::Hot => 0.10,
48            StorageTier::Warm => 0.04,
49            StorageTier::Cold => 0.01,
50            StorageTier::Archive => 0.002,
51        }
52    }
53
54    /// Typical read access latency in milliseconds.
55    pub fn access_latency_ms(&self) -> u64 {
56        match self {
57            StorageTier::Hot => 1,
58            StorageTier::Warm => 10,
59            StorageTier::Cold => 100,
60            StorageTier::Archive => 1000,
61        }
62    }
63}
64
65// ---------------------------------------------------------------------------
66// MigrationPolicy
67// ---------------------------------------------------------------------------
68
69/// Policy parameters that govern when blocks are migrated between tiers.
70#[derive(Clone, Debug)]
71pub struct MigrationPolicy {
72    /// Seconds of idle time before a Hot block is demoted to Warm.
73    pub hot_to_warm_idle_secs: u64,
74    /// Seconds of idle time before a Warm block is demoted to Cold.
75    pub warm_to_cold_idle_secs: u64,
76    /// Seconds of idle time before a Cold block is demoted to Archive.
77    pub cold_to_archive_idle_secs: u64,
78    /// Blocks strictly smaller than this byte threshold are not moved to Cold or Archive.
79    pub min_size_for_cold: u64,
80}
81
82impl Default for MigrationPolicy {
83    fn default() -> Self {
84        Self {
85            hot_to_warm_idle_secs: 86_400,        // 1 day
86            warm_to_cold_idle_secs: 604_800,      // 7 days
87            cold_to_archive_idle_secs: 2_592_000, // 30 days
88            min_size_for_cold: 4096,
89        }
90    }
91}
92
93// ---------------------------------------------------------------------------
94// BlockTierRecord
95// ---------------------------------------------------------------------------
96
97/// Metadata record for a single block tracked by the migrator.
98#[derive(Clone, Debug)]
99pub struct BlockTierRecord {
100    /// Content identifier of the block.
101    pub cid: String,
102    /// Current storage tier.
103    pub tier: StorageTier,
104    /// Size of the block in bytes.
105    pub size_bytes: u64,
106    /// Unix timestamp (seconds) of the most recent access.
107    pub last_accessed_secs: u64,
108    /// Total number of accesses recorded.
109    pub access_count: u64,
110}
111
112impl BlockTierRecord {
113    /// Seconds elapsed since the last access, saturating at zero.
114    pub fn idle_secs(&self, now: u64) -> u64 {
115        now.saturating_sub(self.last_accessed_secs)
116    }
117
118    /// Estimated monthly storage cost (USD) for this block.
119    pub fn monthly_cost(&self) -> f64 {
120        let gib = self.size_bytes as f64 / (1024_u64.pow(3) as f64);
121        gib * self.tier.cost_per_gb()
122    }
123}
124
125// ---------------------------------------------------------------------------
126// MigrationTask
127// ---------------------------------------------------------------------------
128
129/// A planned migration action for a single block.
130#[derive(Clone, Debug, PartialEq, Eq)]
131pub struct MigrationTask {
132    /// Content identifier of the block to migrate.
133    pub cid: String,
134    /// Source tier.
135    pub from_tier: StorageTier,
136    /// Destination tier.
137    pub to_tier: StorageTier,
138    /// Block size in bytes (informational).
139    pub size_bytes: u64,
140}
141
142// ---------------------------------------------------------------------------
143// MigratorStats
144// ---------------------------------------------------------------------------
145
146/// Aggregate statistics over all tracked blocks.
147#[derive(Clone, Debug, Default)]
148pub struct MigratorStats {
149    /// Total number of tracked blocks.
150    pub total_blocks: usize,
151    /// Number of blocks per tier, indexed by `StorageTier as usize`.
152    pub blocks_per_tier: [usize; 4],
153    /// Total bytes stored per tier, indexed by `StorageTier as usize`.
154    pub total_bytes_per_tier: [u64; 4],
155}
156
157impl MigratorStats {
158    /// Sum of monthly storage costs across all provided records.
159    pub fn total_monthly_cost(&self, records: &[BlockTierRecord]) -> f64 {
160        records.iter().map(|r| r.monthly_cost()).sum()
161    }
162}
163
164// ---------------------------------------------------------------------------
165// BlockTierMigrator
166// ---------------------------------------------------------------------------
167
168/// Manages migration of blocks between storage tiers based on access patterns
169/// and a configurable [`MigrationPolicy`].
170pub struct BlockTierMigrator {
171    /// Tracked block records keyed by CID.
172    pub records: HashMap<String, BlockTierRecord>,
173    /// Active migration policy.
174    pub policy: MigrationPolicy,
175}
176
177impl BlockTierMigrator {
178    /// Create a new migrator with the given policy.
179    pub fn new(policy: MigrationPolicy) -> Self {
180        Self {
181            records: HashMap::new(),
182            policy,
183        }
184    }
185
186    /// Register a new block in the given tier.
187    ///
188    /// If a block with the same CID already exists it is overwritten.
189    pub fn register(&mut self, cid: String, tier: StorageTier, size_bytes: u64, now_secs: u64) {
190        self.records.insert(
191            cid.clone(),
192            BlockTierRecord {
193                cid,
194                tier,
195                size_bytes,
196                last_accessed_secs: now_secs,
197                access_count: 0,
198            },
199        );
200    }
201
202    /// Record an access for a block.
203    ///
204    /// Updates `last_accessed_secs`, increments `access_count`, and promotes
205    /// the block back to [`StorageTier::Hot`] if it is currently in a colder tier.
206    pub fn record_access(&mut self, cid: &str, now_secs: u64) {
207        if let Some(record) = self.records.get_mut(cid) {
208            record.last_accessed_secs = now_secs;
209            record.access_count = record.access_count.saturating_add(1);
210            if record.tier != StorageTier::Hot {
211                record.tier = StorageTier::Hot;
212            }
213        }
214    }
215
216    /// Compute a set of migration tasks based on current idle times and the
217    /// active policy.
218    ///
219    /// Only tasks where the target tier differs from the current tier are emitted.
220    /// Small blocks (below `policy.min_size_for_cold`) are never moved to Cold or
221    /// Archive.
222    pub fn plan_migrations(&self, now_secs: u64) -> Vec<MigrationTask> {
223        let mut tasks = Vec::new();
224
225        for record in self.records.values() {
226            let target = self.target_tier(record, now_secs);
227            if target != record.tier {
228                tasks.push(MigrationTask {
229                    cid: record.cid.clone(),
230                    from_tier: record.tier,
231                    to_tier: target,
232                    size_bytes: record.size_bytes,
233                });
234            }
235        }
236
237        tasks
238    }
239
240    /// Apply a previously planned migration task, updating the stored tier.
241    ///
242    /// If the block referenced by the task is not found, this is a no-op.
243    pub fn apply_migration(&mut self, task: &MigrationTask) {
244        if let Some(record) = self.records.get_mut(&task.cid) {
245            record.tier = task.to_tier;
246        }
247    }
248
249    /// Compute aggregate statistics over all tracked blocks.
250    pub fn stats(&self) -> MigratorStats {
251        let mut blocks_per_tier = [0usize; 4];
252        let mut total_bytes_per_tier = [0u64; 4];
253
254        for record in self.records.values() {
255            let idx = record.tier as usize;
256            blocks_per_tier[idx] += 1;
257            total_bytes_per_tier[idx] = total_bytes_per_tier[idx].saturating_add(record.size_bytes);
258        }
259
260        MigratorStats {
261            total_blocks: self.records.len(),
262            blocks_per_tier,
263            total_bytes_per_tier,
264        }
265    }
266
267    // -----------------------------------------------------------------------
268    // Internal helpers
269    // -----------------------------------------------------------------------
270
271    /// Determine the ideal target tier for a record given the current time.
272    fn target_tier(&self, record: &BlockTierRecord, now_secs: u64) -> StorageTier {
273        let idle = record.idle_secs(now_secs);
274        let small = record.size_bytes < self.policy.min_size_for_cold;
275
276        match record.tier {
277            StorageTier::Hot => {
278                if idle >= self.policy.hot_to_warm_idle_secs {
279                    StorageTier::Warm
280                } else {
281                    StorageTier::Hot
282                }
283            }
284            StorageTier::Warm => {
285                if idle >= self.policy.warm_to_cold_idle_secs && !small {
286                    StorageTier::Cold
287                } else {
288                    StorageTier::Warm
289                }
290            }
291            StorageTier::Cold => {
292                if idle >= self.policy.cold_to_archive_idle_secs && !small {
293                    StorageTier::Archive
294                } else {
295                    StorageTier::Cold
296                }
297            }
298            StorageTier::Archive => StorageTier::Archive,
299        }
300    }
301}
302
303// ---------------------------------------------------------------------------
304// Tests
305// ---------------------------------------------------------------------------
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310
311    fn default_migrator() -> BlockTierMigrator {
312        BlockTierMigrator::new(MigrationPolicy::default())
313    }
314
315    // 1. Register creates a record
316    #[test]
317    fn test_register_creates_record() {
318        let mut m = default_migrator();
319        m.register("cid1".to_string(), StorageTier::Hot, 1024, 1000);
320        assert!(m.records.contains_key("cid1"));
321    }
322
323    // 2. Register sets correct tier
324    #[test]
325    fn test_register_sets_tier() {
326        let mut m = default_migrator();
327        m.register("cid2".to_string(), StorageTier::Warm, 2048, 2000);
328        assert_eq!(m.records["cid2"].tier, StorageTier::Warm);
329    }
330
331    // 3. Register initialises access_count to 0
332    #[test]
333    fn test_register_access_count_zero() {
334        let mut m = default_migrator();
335        m.register("cid3".to_string(), StorageTier::Cold, 512, 3000);
336        assert_eq!(m.records["cid3"].access_count, 0);
337    }
338
339    // 4. record_access promotes cold block to Hot
340    #[test]
341    fn test_record_access_promotes_cold_to_hot() {
342        let mut m = default_migrator();
343        m.register("cid4".to_string(), StorageTier::Cold, 8192, 1000);
344        m.record_access("cid4", 2000);
345        assert_eq!(m.records["cid4"].tier, StorageTier::Hot);
346    }
347
348    // 5. record_access promotes warm block to Hot
349    #[test]
350    fn test_record_access_promotes_warm_to_hot() {
351        let mut m = default_migrator();
352        m.register("cid5".to_string(), StorageTier::Warm, 8192, 1000);
353        m.record_access("cid5", 2000);
354        assert_eq!(m.records["cid5"].tier, StorageTier::Hot);
355    }
356
357    // 6. record_access increments access_count
358    #[test]
359    fn test_record_access_increments_count() {
360        let mut m = default_migrator();
361        m.register("cid6".to_string(), StorageTier::Hot, 1024, 1000);
362        m.record_access("cid6", 1001);
363        m.record_access("cid6", 1002);
364        assert_eq!(m.records["cid6"].access_count, 2);
365    }
366
367    // 7. record_access updates last_accessed_secs
368    #[test]
369    fn test_record_access_updates_timestamp() {
370        let mut m = default_migrator();
371        m.register("cid7".to_string(), StorageTier::Hot, 1024, 1000);
372        m.record_access("cid7", 9999);
373        assert_eq!(m.records["cid7"].last_accessed_secs, 9999);
374    }
375
376    // 8. record_access on unknown CID is a no-op (no panic)
377    #[test]
378    fn test_record_access_unknown_cid_noop() {
379        let mut m = default_migrator();
380        m.record_access("nonexistent", 5000); // must not panic
381    }
382
383    // 9. plan_migrations: Hot → Warm after idle exceeds hot_to_warm threshold
384    #[test]
385    fn test_plan_migrations_hot_to_warm() {
386        let mut m = default_migrator();
387        let start = 0u64;
388        m.register("cid9".to_string(), StorageTier::Hot, 8192, start);
389        // idle = 86_401 > 86_400
390        let tasks = m.plan_migrations(start + 86_401);
391        assert_eq!(tasks.len(), 1);
392        assert_eq!(tasks[0].from_tier, StorageTier::Hot);
393        assert_eq!(tasks[0].to_tier, StorageTier::Warm);
394    }
395
396    // 10. plan_migrations: no task when idle < hot_to_warm threshold
397    #[test]
398    fn test_plan_migrations_hot_not_yet_idle() {
399        let mut m = default_migrator();
400        let start = 0u64;
401        m.register("cid10".to_string(), StorageTier::Hot, 8192, start);
402        let tasks = m.plan_migrations(start + 86_399);
403        assert!(tasks.is_empty());
404    }
405
406    // 11. plan_migrations: Warm → Cold for large block after threshold
407    #[test]
408    fn test_plan_migrations_warm_to_cold_large_block() {
409        let mut m = default_migrator();
410        let start = 0u64;
411        m.register("cid11".to_string(), StorageTier::Warm, 8192, start);
412        let tasks = m.plan_migrations(start + 604_801);
413        assert_eq!(tasks.len(), 1);
414        assert_eq!(tasks[0].from_tier, StorageTier::Warm);
415        assert_eq!(tasks[0].to_tier, StorageTier::Cold);
416    }
417
418    // 12. plan_migrations: small block stays Warm even after warm_to_cold threshold
419    #[test]
420    fn test_plan_migrations_small_block_stays_warm() {
421        let mut m = default_migrator();
422        let start = 0u64;
423        // size_bytes (1024) < min_size_for_cold (4096)
424        m.register("cid12".to_string(), StorageTier::Warm, 1024, start);
425        let tasks = m.plan_migrations(start + 604_801);
426        assert!(tasks.is_empty());
427    }
428
429    // 13. plan_migrations: Cold → Archive after threshold
430    #[test]
431    fn test_plan_migrations_cold_to_archive() {
432        let mut m = default_migrator();
433        let start = 0u64;
434        m.register("cid13".to_string(), StorageTier::Cold, 8192, start);
435        let tasks = m.plan_migrations(start + 2_592_001);
436        assert_eq!(tasks.len(), 1);
437        assert_eq!(tasks[0].from_tier, StorageTier::Cold);
438        assert_eq!(tasks[0].to_tier, StorageTier::Archive);
439    }
440
441    // 14. plan_migrations: Archive blocks never migrate
442    #[test]
443    fn test_plan_migrations_archive_stays() {
444        let mut m = default_migrator();
445        m.register("cid14".to_string(), StorageTier::Archive, 8192, 0);
446        let tasks = m.plan_migrations(u64::MAX);
447        assert!(tasks.is_empty());
448    }
449
450    // 15. apply_migration updates the block's tier
451    #[test]
452    fn test_apply_migration_updates_tier() {
453        let mut m = default_migrator();
454        m.register("cid15".to_string(), StorageTier::Hot, 8192, 0);
455        let task = MigrationTask {
456            cid: "cid15".to_string(),
457            from_tier: StorageTier::Hot,
458            to_tier: StorageTier::Warm,
459            size_bytes: 8192,
460        };
461        m.apply_migration(&task);
462        assert_eq!(m.records["cid15"].tier, StorageTier::Warm);
463    }
464
465    // 16. apply_migration on unknown CID is a no-op
466    #[test]
467    fn test_apply_migration_unknown_cid_noop() {
468        let mut m = default_migrator();
469        let task = MigrationTask {
470            cid: "ghost".to_string(),
471            from_tier: StorageTier::Hot,
472            to_tier: StorageTier::Warm,
473            size_bytes: 512,
474        };
475        m.apply_migration(&task); // must not panic
476    }
477
478    // 17. stats: counts blocks per tier correctly
479    #[test]
480    fn test_stats_counts_per_tier() {
481        let mut m = default_migrator();
482        m.register("h1".to_string(), StorageTier::Hot, 1024, 0);
483        m.register("h2".to_string(), StorageTier::Hot, 1024, 0);
484        m.register("w1".to_string(), StorageTier::Warm, 2048, 0);
485        m.register("c1".to_string(), StorageTier::Cold, 4096, 0);
486        m.register("a1".to_string(), StorageTier::Archive, 8192, 0);
487
488        let s = m.stats();
489        assert_eq!(s.total_blocks, 5);
490        assert_eq!(s.blocks_per_tier[StorageTier::Hot as usize], 2);
491        assert_eq!(s.blocks_per_tier[StorageTier::Warm as usize], 1);
492        assert_eq!(s.blocks_per_tier[StorageTier::Cold as usize], 1);
493        assert_eq!(s.blocks_per_tier[StorageTier::Archive as usize], 1);
494    }
495
496    // 18. stats: total bytes per tier
497    #[test]
498    fn test_stats_bytes_per_tier() {
499        let mut m = default_migrator();
500        m.register("h1".to_string(), StorageTier::Hot, 500, 0);
501        m.register("h2".to_string(), StorageTier::Hot, 300, 0);
502        m.register("w1".to_string(), StorageTier::Warm, 1000, 0);
503
504        let s = m.stats();
505        assert_eq!(s.total_bytes_per_tier[StorageTier::Hot as usize], 800);
506        assert_eq!(s.total_bytes_per_tier[StorageTier::Warm as usize], 1000);
507        assert_eq!(s.total_bytes_per_tier[StorageTier::Cold as usize], 0);
508    }
509
510    // 19. idle_secs saturates at zero when now < last_accessed
511    #[test]
512    fn test_idle_secs_saturates() {
513        let record = BlockTierRecord {
514            cid: "x".to_string(),
515            tier: StorageTier::Hot,
516            size_bytes: 1024,
517            last_accessed_secs: 9999,
518            access_count: 0,
519        };
520        assert_eq!(record.idle_secs(1000), 0);
521    }
522
523    // 20. monthly_cost calculation
524    #[test]
525    fn test_monthly_cost_hot() {
526        let record = BlockTierRecord {
527            cid: "y".to_string(),
528            tier: StorageTier::Hot,
529            size_bytes: 1024 * 1024 * 1024, // 1 GiB
530            last_accessed_secs: 0,
531            access_count: 0,
532        };
533        let cost = record.monthly_cost();
534        // 1 GiB * $0.10/GiB = $0.10
535        assert!((cost - 0.10).abs() < 1e-9);
536    }
537
538    // 21. MigratorStats::total_monthly_cost sums correctly
539    #[test]
540    fn test_total_monthly_cost() {
541        let records = vec![
542            BlockTierRecord {
543                cid: "a".to_string(),
544                tier: StorageTier::Hot,
545                size_bytes: 1024 * 1024 * 1024,
546                last_accessed_secs: 0,
547                access_count: 0,
548            },
549            BlockTierRecord {
550                cid: "b".to_string(),
551                tier: StorageTier::Archive,
552                size_bytes: 1024 * 1024 * 1024,
553                last_accessed_secs: 0,
554                access_count: 0,
555            },
556        ];
557        let stats = MigratorStats::default();
558        let total = stats.total_monthly_cost(&records);
559        // 0.10 + 0.002 = 0.102
560        assert!((total - 0.102).abs() < 1e-9);
561    }
562
563    // 22. StorageTier ordering
564    #[test]
565    fn test_tier_ordering() {
566        assert!(StorageTier::Hot < StorageTier::Warm);
567        assert!(StorageTier::Warm < StorageTier::Cold);
568        assert!(StorageTier::Cold < StorageTier::Archive);
569    }
570
571    // 23. cost_per_gb values
572    #[test]
573    fn test_cost_per_gb() {
574        assert!((StorageTier::Hot.cost_per_gb() - 0.10).abs() < 1e-12);
575        assert!((StorageTier::Warm.cost_per_gb() - 0.04).abs() < 1e-12);
576        assert!((StorageTier::Cold.cost_per_gb() - 0.01).abs() < 1e-12);
577        assert!((StorageTier::Archive.cost_per_gb() - 0.002).abs() < 1e-12);
578    }
579
580    // 24. access_latency_ms values
581    #[test]
582    fn test_access_latency_ms() {
583        assert_eq!(StorageTier::Hot.access_latency_ms(), 1);
584        assert_eq!(StorageTier::Warm.access_latency_ms(), 10);
585        assert_eq!(StorageTier::Cold.access_latency_ms(), 100);
586        assert_eq!(StorageTier::Archive.access_latency_ms(), 1000);
587    }
588
589    // 25. plan_migrations + apply_migration round-trip for full demotion chain
590    #[test]
591    fn test_full_demotion_chain() {
592        let mut m = default_migrator();
593        let start = 0u64;
594        m.register("chain".to_string(), StorageTier::Hot, 8192, start);
595
596        // Hot → Warm
597        let now1 = start + 86_401;
598        let tasks = m.plan_migrations(now1);
599        assert_eq!(tasks.len(), 1);
600        m.apply_migration(&tasks[0]);
601        assert_eq!(m.records["chain"].tier, StorageTier::Warm);
602
603        // Warm → Cold (last_accessed still = start, idle from start)
604        let now2 = start + 604_801;
605        let tasks = m.plan_migrations(now2);
606        assert_eq!(tasks.len(), 1);
607        m.apply_migration(&tasks[0]);
608        assert_eq!(m.records["chain"].tier, StorageTier::Cold);
609
610        // Cold → Archive
611        let now3 = start + 2_592_001;
612        let tasks = m.plan_migrations(now3);
613        assert_eq!(tasks.len(), 1);
614        m.apply_migration(&tasks[0]);
615        assert_eq!(m.records["chain"].tier, StorageTier::Archive);
616
617        // Archive stays
618        let tasks = m.plan_migrations(u64::MAX);
619        assert!(tasks.is_empty());
620    }
621}