Skip to main content

ipfrs_storage/
tier_manager.rs

1//! StorageTierManager — policy-driven hot/warm/cold/archive tier classification.
2//!
3//! Tracks per-block access rates and reclassifies blocks across four storage
4//! tiers: Hot, Warm, Cold, and Archive. Provides eviction candidates sorted by
5//! least-recently-used ordering and exposes atomic statistics for observability.
6
7use parking_lot::RwLock;
8use std::collections::HashMap;
9use std::sync::atomic::{AtomicU64, Ordering};
10use std::time::{Duration, Instant};
11
12// ---------------------------------------------------------------------------
13// StorageTier
14// ---------------------------------------------------------------------------
15
16/// Four-level storage tier hierarchy, ordered from fastest/most-expensive to
17/// slowest/cheapest.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
19pub enum StorageTier {
20    /// Hot tier: NVMe / memory cache — target for blocks accessed >1 /s.
21    Hot,
22    /// Warm tier: fast SSD — blocks accessed >0.1 /s.
23    Warm,
24    /// Cold tier: HDD / object store — blocks accessed >0.01 /s.
25    Cold,
26    /// Archive tier: tape / deep cold storage — everything else.
27    Archive,
28}
29
30impl StorageTier {
31    /// Representative one-way latency estimate in milliseconds for each tier.
32    pub fn tier_latency_ms_estimate(&self) -> u64 {
33        match self {
34            StorageTier::Hot => 1,
35            StorageTier::Warm => 10,
36            StorageTier::Cold => 100,
37            StorageTier::Archive => 10_000,
38        }
39    }
40
41    /// Human-readable tier name (used as key in `tier_summary`).
42    pub fn name(&self) -> &'static str {
43        match self {
44            StorageTier::Hot => "hot",
45            StorageTier::Warm => "warm",
46            StorageTier::Cold => "cold",
47            StorageTier::Archive => "archive",
48        }
49    }
50}
51
52// ---------------------------------------------------------------------------
53// TierPolicy
54// ---------------------------------------------------------------------------
55
56/// Access-rate thresholds that govern classification into tiers.
57///
58/// A block is placed in the *highest* tier whose threshold it meets:
59/// - `access_rate >= hot_threshold`    → `Hot`
60/// - `access_rate >= warm_threshold`   → `Warm`
61/// - `access_rate >= cold_threshold`   → `Cold`
62/// - otherwise                         → `Archive`
63#[derive(Debug, Clone)]
64pub struct TierPolicy {
65    /// Minimum accesses/sec to remain in Hot tier (default: 1.0).
66    pub hot_threshold_access_rate: f64,
67    /// Minimum accesses/sec to remain in Warm tier (default: 0.1).
68    pub warm_threshold_access_rate: f64,
69    /// Minimum accesses/sec to remain in Cold tier (default: 0.01).
70    pub cold_threshold_access_rate: f64,
71}
72
73impl Default for TierPolicy {
74    fn default() -> Self {
75        Self {
76            hot_threshold_access_rate: 1.0,
77            warm_threshold_access_rate: 0.1,
78            cold_threshold_access_rate: 0.01,
79        }
80    }
81}
82
83impl TierPolicy {
84    /// Classify a block given its current `access_rate` (accesses per second).
85    pub fn classify(&self, access_rate: f64) -> StorageTier {
86        if access_rate >= self.hot_threshold_access_rate {
87            StorageTier::Hot
88        } else if access_rate >= self.warm_threshold_access_rate {
89            StorageTier::Warm
90        } else if access_rate >= self.cold_threshold_access_rate {
91            StorageTier::Cold
92        } else {
93            StorageTier::Archive
94        }
95    }
96}
97
98// ---------------------------------------------------------------------------
99// BlockTierRecord
100// ---------------------------------------------------------------------------
101
102/// Per-block tracking record held inside `StorageTierManager`.
103#[derive(Debug, Clone)]
104pub struct BlockTierRecord {
105    /// Content identifier string for the block.
106    pub cid: String,
107    /// Current tier assignment.
108    pub current_tier: StorageTier,
109    /// Cumulative access count since the record was created.
110    pub access_count: u64,
111    /// Monotonic timestamp of the most-recent access.
112    pub last_accessed: Instant,
113    /// Block size in bytes (supplied by the caller on first access).
114    pub size_bytes: u64,
115    /// Monotonic timestamp of record creation.
116    pub created_at: Instant,
117}
118
119impl BlockTierRecord {
120    /// Instantaneous access rate over the supplied measurement `window`.
121    ///
122    /// Returns `access_count / window.as_secs_f64()`, or `0.0` if the window
123    /// has zero length (avoids division by zero).
124    pub fn access_rate_per_sec(&self, window: Duration) -> f64 {
125        let secs = window.as_secs_f64();
126        if secs <= 0.0 {
127            return 0.0;
128        }
129        self.access_count as f64 / secs
130    }
131}
132
133// ---------------------------------------------------------------------------
134// TierTransition
135// ---------------------------------------------------------------------------
136
137/// Describes a single tier change produced by `reclassify_all`.
138#[derive(Debug, Clone, PartialEq, Eq)]
139pub struct TierTransition {
140    /// CID that changed tier.
141    pub cid: String,
142    /// Previous tier.
143    pub from: StorageTier,
144    /// New tier.
145    pub to: StorageTier,
146}
147
148// ---------------------------------------------------------------------------
149// TierStats
150// ---------------------------------------------------------------------------
151
152/// Atomic counters for observability.
153#[derive(Debug, Default)]
154pub struct TierStats {
155    /// Total number of `record_access` calls processed.
156    pub total_accesses_recorded: AtomicU64,
157    /// Total number of `reclassify_all` calls made.
158    pub total_reclassifications: AtomicU64,
159    /// Total number of individual tier transitions detected across all calls.
160    pub total_transitions: AtomicU64,
161}
162
163/// Point-in-time snapshot of `TierStats` (non-atomic values for easy inspection).
164#[derive(Debug, Clone, PartialEq, Eq)]
165pub struct TierStatsSnapshot {
166    pub total_accesses_recorded: u64,
167    pub total_reclassifications: u64,
168    pub total_transitions: u64,
169}
170
171impl TierStats {
172    /// Capture a consistent snapshot of the counters.
173    pub fn snapshot(&self) -> TierStatsSnapshot {
174        TierStatsSnapshot {
175            total_accesses_recorded: self.total_accesses_recorded.load(Ordering::Relaxed),
176            total_reclassifications: self.total_reclassifications.load(Ordering::Relaxed),
177            total_transitions: self.total_transitions.load(Ordering::Relaxed),
178        }
179    }
180}
181
182// ---------------------------------------------------------------------------
183// StorageTierManager
184// ---------------------------------------------------------------------------
185
186/// Central manager for block tier tracking and reclassification.
187///
188/// # Concurrency
189///
190/// All block records are protected by a single `RwLock<HashMap<…>>`. Reads
191/// (classification queries, summaries) hold a shared lock; writes
192/// (access recording, reclassification) hold an exclusive lock.
193pub struct StorageTierManager {
194    /// Block records keyed by CID string.
195    pub records: RwLock<HashMap<String, BlockTierRecord>>,
196    /// Policy that governs tier classification.
197    pub policy: TierPolicy,
198    /// Observable statistics.
199    pub stats: TierStats,
200}
201
202impl StorageTierManager {
203    /// Create a new manager with the supplied policy.
204    pub fn new(policy: TierPolicy) -> Self {
205        Self {
206            records: RwLock::new(HashMap::new()),
207            policy,
208            stats: TierStats::default(),
209        }
210    }
211
212    /// Create a new manager with the default policy.
213    pub fn with_default_policy() -> Self {
214        Self::new(TierPolicy::default())
215    }
216
217    // -----------------------------------------------------------------------
218    // Mutation
219    // -----------------------------------------------------------------------
220
221    /// Record one access to the block identified by `cid`.
222    ///
223    /// Creates the record on first access; subsequent calls increment
224    /// `access_count` and update `last_accessed`.  The `size_bytes` parameter
225    /// is used only when creating a new record — it is ignored for existing
226    /// records (callers do not need to supply it after the first call).
227    pub fn record_access(&self, cid: &str, size_bytes: u64) {
228        let now = Instant::now();
229        let mut guard = self.records.write();
230        match guard.get_mut(cid) {
231            Some(rec) => {
232                rec.access_count += 1;
233                rec.last_accessed = now;
234            }
235            None => {
236                guard.insert(
237                    cid.to_owned(),
238                    BlockTierRecord {
239                        cid: cid.to_owned(),
240                        current_tier: StorageTier::Archive,
241                        access_count: 1,
242                        last_accessed: now,
243                        size_bytes,
244                        created_at: now,
245                    },
246                );
247            }
248        }
249        self.stats
250            .total_accesses_recorded
251            .fetch_add(1, Ordering::Relaxed);
252    }
253
254    // -----------------------------------------------------------------------
255    // Classification
256    // -----------------------------------------------------------------------
257
258    /// Classify the block identified by `cid` using its current access rate
259    /// computed over `window`.
260    ///
261    /// Returns `StorageTier::Archive` for unknown CIDs.
262    pub fn classify(&self, cid: &str, window: Duration) -> StorageTier {
263        let guard = self.records.read();
264        match guard.get(cid) {
265            Some(rec) => self.policy.classify(rec.access_rate_per_sec(window)),
266            None => StorageTier::Archive,
267        }
268    }
269
270    /// Reclassify every known block using `window` and return a list of
271    /// [`TierTransition`]s for blocks whose tier changed.
272    ///
273    /// Also updates `current_tier` on the record so that subsequent calls to
274    /// `blocks_in_tier` reflect the new assignment.
275    pub fn reclassify_all(&self, window: Duration) -> Vec<TierTransition> {
276        let mut transitions = Vec::new();
277        {
278            let mut guard = self.records.write();
279            for rec in guard.values_mut() {
280                let new_tier = self.policy.classify(rec.access_rate_per_sec(window));
281                if new_tier != rec.current_tier {
282                    transitions.push(TierTransition {
283                        cid: rec.cid.clone(),
284                        from: rec.current_tier,
285                        to: new_tier,
286                    });
287                    rec.current_tier = new_tier;
288                }
289            }
290        }
291        self.stats
292            .total_reclassifications
293            .fetch_add(1, Ordering::Relaxed);
294        self.stats
295            .total_transitions
296            .fetch_add(transitions.len() as u64, Ordering::Relaxed);
297        transitions
298    }
299
300    // -----------------------------------------------------------------------
301    // Queries
302    // -----------------------------------------------------------------------
303
304    /// Return all CIDs whose `current_tier` matches `tier`.
305    pub fn blocks_in_tier(&self, tier: StorageTier) -> Vec<String> {
306        let guard = self.records.read();
307        guard
308            .values()
309            .filter(|rec| rec.current_tier == tier)
310            .map(|rec| rec.cid.clone())
311            .collect()
312    }
313
314    /// Return a map from tier name (`"hot"`, `"warm"`, `"cold"`, `"archive"`)
315    /// to the number of blocks currently assigned to that tier.
316    pub fn tier_summary(&self) -> HashMap<String, usize> {
317        let guard = self.records.read();
318        let mut summary: HashMap<String, usize> = HashMap::new();
319        for tier in &[
320            StorageTier::Hot,
321            StorageTier::Warm,
322            StorageTier::Cold,
323            StorageTier::Archive,
324        ] {
325            summary.insert(tier.name().to_owned(), 0);
326        }
327        for rec in guard.values() {
328            *summary
329                .entry(rec.current_tier.name().to_owned())
330                .or_insert(0) += 1;
331        }
332        summary
333    }
334
335    /// Return up to `max_count` CIDs from `tier` sorted from least-recently
336    /// accessed (oldest `last_accessed`) to most-recently accessed (best
337    /// eviction candidates first).
338    pub fn eviction_candidates(&self, tier: StorageTier, max_count: usize) -> Vec<String> {
339        let guard = self.records.read();
340        let mut candidates: Vec<&BlockTierRecord> = guard
341            .values()
342            .filter(|rec| rec.current_tier == tier)
343            .collect();
344        // Sort ascending by last_accessed so the oldest (LRU) come first.
345        candidates.sort_by_key(|rec| rec.last_accessed);
346        candidates
347            .into_iter()
348            .take(max_count)
349            .map(|rec| rec.cid.clone())
350            .collect()
351    }
352}
353
354// ---------------------------------------------------------------------------
355// Tests
356// ---------------------------------------------------------------------------
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361    use std::thread;
362    use std::time::Duration;
363
364    // ---- TierPolicy::classify --------------------------------------------------
365
366    #[test]
367    fn test_classify_hot() {
368        let policy = TierPolicy::default();
369        assert_eq!(policy.classify(5.0), StorageTier::Hot);
370        assert_eq!(policy.classify(1.0), StorageTier::Hot);
371    }
372
373    #[test]
374    fn test_classify_warm() {
375        let policy = TierPolicy::default();
376        assert_eq!(policy.classify(0.5), StorageTier::Warm);
377        assert_eq!(policy.classify(0.1), StorageTier::Warm);
378    }
379
380    #[test]
381    fn test_classify_cold() {
382        let policy = TierPolicy::default();
383        assert_eq!(policy.classify(0.05), StorageTier::Cold);
384        assert_eq!(policy.classify(0.01), StorageTier::Cold);
385    }
386
387    #[test]
388    fn test_classify_archive() {
389        let policy = TierPolicy::default();
390        assert_eq!(policy.classify(0.005), StorageTier::Archive);
391        assert_eq!(policy.classify(0.0), StorageTier::Archive);
392    }
393
394    // ---- Tier latency estimates ------------------------------------------------
395
396    #[test]
397    fn test_tier_latency_estimates() {
398        assert_eq!(StorageTier::Hot.tier_latency_ms_estimate(), 1);
399        assert_eq!(StorageTier::Warm.tier_latency_ms_estimate(), 10);
400        assert_eq!(StorageTier::Cold.tier_latency_ms_estimate(), 100);
401        assert_eq!(StorageTier::Archive.tier_latency_ms_estimate(), 10_000);
402    }
403
404    // ---- record_access creates record -----------------------------------------
405
406    #[test]
407    fn test_record_access_creates_record() {
408        let mgr = StorageTierManager::with_default_policy();
409        mgr.record_access("cid-alpha", 1024);
410
411        let guard = mgr.records.read();
412        let rec = guard.get("cid-alpha").expect("record should exist");
413        assert_eq!(rec.cid, "cid-alpha");
414        assert_eq!(rec.access_count, 1);
415        assert_eq!(rec.size_bytes, 1024);
416    }
417
418    #[test]
419    fn test_record_access_increments_existing() {
420        let mgr = StorageTierManager::with_default_policy();
421        mgr.record_access("cid-beta", 512);
422        mgr.record_access("cid-beta", 512);
423        mgr.record_access("cid-beta", 512);
424
425        let guard = mgr.records.read();
426        let rec = guard.get("cid-beta").expect("record should exist");
427        assert_eq!(rec.access_count, 3);
428    }
429
430    // ---- access_rate_per_sec formula ------------------------------------------
431
432    #[test]
433    fn test_access_rate_per_sec_formula() {
434        let rec = BlockTierRecord {
435            cid: "test".to_owned(),
436            current_tier: StorageTier::Archive,
437            access_count: 100,
438            last_accessed: Instant::now(),
439            size_bytes: 256,
440            created_at: Instant::now(),
441        };
442        let rate = rec.access_rate_per_sec(Duration::from_secs(10));
443        // 100 accesses / 10 s = 10.0
444        assert!((rate - 10.0).abs() < 1e-9, "expected 10.0, got {rate}");
445    }
446
447    #[test]
448    fn test_access_rate_zero_window() {
449        let rec = BlockTierRecord {
450            cid: "test".to_owned(),
451            current_tier: StorageTier::Archive,
452            access_count: 50,
453            last_accessed: Instant::now(),
454            size_bytes: 128,
455            created_at: Instant::now(),
456        };
457        // Zero-length window must not panic and must return 0.0.
458        let rate = rec.access_rate_per_sec(Duration::from_secs(0));
459        assert_eq!(rate, 0.0);
460    }
461
462    // ---- classify() -----------------------------------------------------------
463
464    #[test]
465    fn test_classify_returns_correct_tier_after_accesses() {
466        let mgr = StorageTierManager::with_default_policy();
467        // Record 50 accesses — over a 10-second window that is 5.0 /s → Hot.
468        for _ in 0..50 {
469            mgr.record_access("hot-cid", 128);
470        }
471        let tier = mgr.classify("hot-cid", Duration::from_secs(10));
472        assert_eq!(tier, StorageTier::Hot);
473    }
474
475    #[test]
476    fn test_classify_unknown_cid_returns_archive() {
477        let mgr = StorageTierManager::with_default_policy();
478        let tier = mgr.classify("no-such-cid", Duration::from_secs(60));
479        assert_eq!(tier, StorageTier::Archive);
480    }
481
482    // ---- reclassify_all() -----------------------------------------------------
483
484    #[test]
485    fn test_reclassify_all_detects_transitions() {
486        let mgr = StorageTierManager::with_default_policy();
487
488        // Insert a record manually in Archive, then fire enough accesses to
489        // push it to Hot over a short window.
490        mgr.record_access("migrate-cid", 64);
491        // Override tier to Archive so there is definitely a transition.
492        {
493            let mut guard = mgr.records.write();
494            if let Some(rec) = guard.get_mut("migrate-cid") {
495                rec.current_tier = StorageTier::Archive;
496                rec.access_count = 200;
497            }
498        }
499
500        // 200 accesses over 10 s = 20 /s → Hot.
501        let transitions = mgr.reclassify_all(Duration::from_secs(10));
502        assert_eq!(transitions.len(), 1);
503        assert_eq!(transitions[0].cid, "migrate-cid");
504        assert_eq!(transitions[0].from, StorageTier::Archive);
505        assert_eq!(transitions[0].to, StorageTier::Hot);
506    }
507
508    #[test]
509    fn test_reclassify_all_no_transitions_when_unchanged() {
510        let mgr = StorageTierManager::with_default_policy();
511        // 1 access over 60 s = ~0.0167 /s → Cold.
512        mgr.record_access("stable-cid", 256);
513        // First reclassify: Archive → Cold (1 transition).
514        let t1 = mgr.reclassify_all(Duration::from_secs(60));
515        assert_eq!(t1.len(), 1);
516        // Second reclassify with same window: no change.
517        let t2 = mgr.reclassify_all(Duration::from_secs(60));
518        assert_eq!(t2.len(), 0);
519    }
520
521    // ---- blocks_in_tier() -----------------------------------------------------
522
523    #[test]
524    fn test_blocks_in_tier_returns_correct_cids() {
525        let mgr = StorageTierManager::with_default_policy();
526        // Manually plant two Hot records and one Warm record.
527        {
528            let mut guard = mgr.records.write();
529            for name in &["hot-1", "hot-2"] {
530                guard.insert(
531                    name.to_string(),
532                    BlockTierRecord {
533                        cid: name.to_string(),
534                        current_tier: StorageTier::Hot,
535                        access_count: 100,
536                        last_accessed: Instant::now(),
537                        size_bytes: 64,
538                        created_at: Instant::now(),
539                    },
540                );
541            }
542            guard.insert(
543                "warm-1".to_owned(),
544                BlockTierRecord {
545                    cid: "warm-1".to_owned(),
546                    current_tier: StorageTier::Warm,
547                    access_count: 5,
548                    last_accessed: Instant::now(),
549                    size_bytes: 64,
550                    created_at: Instant::now(),
551                },
552            );
553        }
554        let mut hot_blocks = mgr.blocks_in_tier(StorageTier::Hot);
555        hot_blocks.sort();
556        assert_eq!(hot_blocks, vec!["hot-1", "hot-2"]);
557
558        let warm_blocks = mgr.blocks_in_tier(StorageTier::Warm);
559        assert_eq!(warm_blocks, vec!["warm-1"]);
560
561        let cold_blocks = mgr.blocks_in_tier(StorageTier::Cold);
562        assert!(cold_blocks.is_empty());
563    }
564
565    // ---- eviction_candidates() ------------------------------------------------
566
567    #[test]
568    fn test_eviction_candidates_sorted_by_lru() {
569        let mgr = StorageTierManager::with_default_policy();
570
571        // Insert three Cold records with increasing last_accessed timestamps.
572        // We sleep briefly between insertions so Instant::now() differs.
573        let cids = ["cold-a", "cold-b", "cold-c"];
574        for cid in &cids {
575            mgr.record_access(cid, 128);
576            // A tiny sleep ensures distinct Instant values across platforms.
577            thread::sleep(Duration::from_millis(2));
578        }
579        // Force all to Cold tier.
580        {
581            let mut guard = mgr.records.write();
582            for cid in &cids {
583                if let Some(rec) = guard.get_mut(*cid) {
584                    rec.current_tier = StorageTier::Cold;
585                }
586            }
587        }
588
589        // Oldest access → first candidate.
590        let candidates = mgr.eviction_candidates(StorageTier::Cold, 2);
591        assert_eq!(candidates.len(), 2);
592        // "cold-a" was accessed first, so it should be the top eviction candidate.
593        assert_eq!(candidates[0], "cold-a");
594        assert_eq!(candidates[1], "cold-b");
595    }
596
597    // ---- tier_summary() -------------------------------------------------------
598
599    #[test]
600    fn test_tier_summary_counts_correct() {
601        let mgr = StorageTierManager::with_default_policy();
602        {
603            let mut guard = mgr.records.write();
604            let tiers = [
605                ("s-hot-1", StorageTier::Hot),
606                ("s-hot-2", StorageTier::Hot),
607                ("s-warm-1", StorageTier::Warm),
608                ("s-cold-1", StorageTier::Cold),
609            ];
610            for (name, tier) in &tiers {
611                guard.insert(
612                    name.to_string(),
613                    BlockTierRecord {
614                        cid: name.to_string(),
615                        current_tier: *tier,
616                        access_count: 1,
617                        last_accessed: Instant::now(),
618                        size_bytes: 64,
619                        created_at: Instant::now(),
620                    },
621                );
622            }
623        }
624        let summary = mgr.tier_summary();
625        assert_eq!(*summary.get("hot").unwrap_or(&0), 2);
626        assert_eq!(*summary.get("warm").unwrap_or(&0), 1);
627        assert_eq!(*summary.get("cold").unwrap_or(&0), 1);
628        assert_eq!(*summary.get("archive").unwrap_or(&0), 0);
629    }
630
631    // ---- Stats accumulation ---------------------------------------------------
632
633    #[test]
634    fn test_stats_accumulation() {
635        let mgr = StorageTierManager::with_default_policy();
636
637        mgr.record_access("stat-cid-1", 64);
638        mgr.record_access("stat-cid-2", 64);
639        mgr.record_access("stat-cid-1", 64); // increment
640
641        let snap_before = mgr.stats.snapshot();
642        assert_eq!(snap_before.total_accesses_recorded, 3);
643
644        // Two CIDs default to Archive; reclassify over a short window will
645        // produce 0 or more transitions depending on rate.
646        mgr.reclassify_all(Duration::from_secs(1));
647        let snap_after = mgr.stats.snapshot();
648        assert_eq!(snap_after.total_reclassifications, 1);
649        // total_transitions may be 0 or more — just verify it is consistent.
650        assert!(snap_after.total_transitions >= snap_before.total_transitions);
651    }
652}