Skip to main content

ipfrs_storage/
object_storage_tiering.rs

1//! ObjectStorageTiering — hierarchical object storage tiering (hot/warm/cold).
2//!
3//! Provides a production-quality tiered object storage system that automatically
4//! migrates objects between Hot, Warm, and Cold storage tiers based on configurable
5//! policies (access frequency, recency, size thresholds, cost optimization).
6//!
7//! # Type Aliases
8//!
9//! Due to name collisions with existing crate types, all public types use the
10//! `Ost` prefix:
11//! - `OstStorageTier`  — Hot/Warm/Cold tier enum
12//! - `OstTierPolicy`   — tiering policy variants
13//! - `OstTierConfig`   — tier capacity and policy configuration
14//! - `OstTierTransition` — record of an object moving between tiers
15//!
16//! # Example
17//!
18//! ```rust
19//! use ipfrs_storage::object_storage_tiering::{
20//!     ObjectStorageTiering, OstStorageTier, OstTierConfig, OstTierPolicy, TieredObject,
21//! };
22//!
23//! let config = OstTierConfig {
24//!     hot_capacity_bytes: 1_024 * 1_024,
25//!     warm_capacity_bytes: 10 * 1_024 * 1_024,
26//!     cold_capacity_bytes: u64::MAX,
27//!     policy: OstTierPolicy::AccessFrequency(5),
28//!     promotion_threshold: 3,
29//!     demotion_interval_us: 60_000_000,
30//! };
31//!
32//! let mut tiering = ObjectStorageTiering::new(config);
33//!
34//! let obj = TieredObject {
35//!     id: "obj-1".to_string(),
36//!     size_bytes: 512,
37//!     current_tier: OstStorageTier::Hot,
38//!     access_count: 0,
39//!     last_accessed: 0,
40//!     created_at: 0,
41//!     tags: vec![],
42//!     cost_per_hour: 0.001,
43//! };
44//!
45//! let tier = tiering.store(obj).unwrap();
46//! assert_eq!(tier, OstStorageTier::Hot);
47//! ```
48
49use std::collections::{HashMap, VecDeque};
50
51// ---------------------------------------------------------------------------
52// OstStorageTier
53// ---------------------------------------------------------------------------
54
55/// Three-level object storage tier hierarchy.
56///
57/// `Ost` prefix used to avoid collision with `StorageTier` in `cold_storage`,
58/// `lifecycle`, `tier_manager`, and `tier_migration_engine`.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
60pub enum OstStorageTier {
61    /// Hot tier — fastest access, highest cost, limited capacity.
62    Hot,
63    /// Warm tier — balanced access speed, medium cost.
64    Warm,
65    /// Cold tier — slowest access, cheapest, essentially unlimited capacity.
66    Cold,
67}
68
69impl OstStorageTier {
70    /// Returns the tier one level colder, or `None` if already at Cold.
71    pub fn colder(self) -> Option<OstStorageTier> {
72        match self {
73            OstStorageTier::Hot => Some(OstStorageTier::Warm),
74            OstStorageTier::Warm => Some(OstStorageTier::Cold),
75            OstStorageTier::Cold => None,
76        }
77    }
78
79    /// Returns the tier one level hotter, or `None` if already at Hot.
80    pub fn hotter(self) -> Option<OstStorageTier> {
81        match self {
82            OstStorageTier::Cold => Some(OstStorageTier::Warm),
83            OstStorageTier::Warm => Some(OstStorageTier::Hot),
84            OstStorageTier::Hot => None,
85        }
86    }
87
88    /// Human-readable name.
89    pub fn name(&self) -> &'static str {
90        match self {
91            OstStorageTier::Hot => "hot",
92            OstStorageTier::Warm => "warm",
93            OstStorageTier::Cold => "cold",
94        }
95    }
96
97    /// Numeric rank: lower = hotter.
98    pub fn rank(self) -> u8 {
99        match self {
100            OstStorageTier::Hot => 0,
101            OstStorageTier::Warm => 1,
102            OstStorageTier::Cold => 2,
103        }
104    }
105}
106
107impl std::fmt::Display for OstStorageTier {
108    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109        f.write_str(self.name())
110    }
111}
112
113// ---------------------------------------------------------------------------
114// OstTierPolicy
115// ---------------------------------------------------------------------------
116
117/// Policy that drives automatic tier promotions and demotions.
118///
119/// `Ost` prefix avoids collision with `TierPolicy` in `cold_storage`,
120/// `tier_manager`, and `tier_migration_engine`.
121#[derive(Debug, Clone)]
122pub enum OstTierPolicy {
123    /// Promote to Hot when `access_count > n`; no automatic demotion.
124    AccessFrequency(u32),
125    /// Demote to Cold when not accessed for `n` microseconds.
126    RecencyBased(u64),
127    /// Keep each tier under its byte capacity; demote largest objects first.
128    SizeThreshold {
129        /// Maximum bytes allowed in the Hot tier.
130        hot_max_bytes: u64,
131        /// Maximum bytes allowed in the Warm tier.
132        warm_max_bytes: u64,
133    },
134    /// Only explicit `promote`/`demote` calls move objects; no auto policy.
135    ManualOnly,
136    /// Demote low-access-count objects to minimise cost; multiplier on
137    /// `cost_per_byte_per_hour`.
138    CostOptimized(f64),
139}
140
141// ---------------------------------------------------------------------------
142// OstTierConfig
143// ---------------------------------------------------------------------------
144
145/// Capacity and policy configuration for the tiering engine.
146///
147/// `Ost` prefix avoids collision with `TierConfig` in `tiering`.
148#[derive(Debug, Clone)]
149pub struct OstTierConfig {
150    /// Maximum bytes that can live in the Hot tier.
151    pub hot_capacity_bytes: u64,
152    /// Maximum bytes that can live in the Warm tier.
153    pub warm_capacity_bytes: u64,
154    /// Maximum bytes for the Cold tier (`u64::MAX` = effectively unlimited).
155    pub cold_capacity_bytes: u64,
156    /// Policy that drives automatic promotions and demotions.
157    pub policy: OstTierPolicy,
158    /// Access-count threshold for Warm → Hot promotion.
159    pub promotion_threshold: u32,
160    /// How often (in microseconds) to check for cold-eligible objects.
161    pub demotion_interval_us: u64,
162}
163
164impl Default for OstTierConfig {
165    fn default() -> Self {
166        Self {
167            hot_capacity_bytes: 512 * 1_024 * 1_024,         // 512 MiB
168            warm_capacity_bytes: 10 * 1_024 * 1_024 * 1_024, // 10 GiB
169            cold_capacity_bytes: u64::MAX,
170            policy: OstTierPolicy::AccessFrequency(10),
171            promotion_threshold: 5,
172            demotion_interval_us: 3_600_000_000, // 1 hour
173        }
174    }
175}
176
177// ---------------------------------------------------------------------------
178// TieredObject
179// ---------------------------------------------------------------------------
180
181/// An object managed by the tiering engine.
182#[derive(Debug, Clone)]
183pub struct TieredObject {
184    /// Unique identifier for this object.
185    pub id: String,
186    /// Size of the object in bytes.
187    pub size_bytes: u64,
188    /// Current storage tier.
189    pub current_tier: OstStorageTier,
190    /// Total number of times this object has been accessed.
191    pub access_count: u64,
192    /// Timestamp (microseconds since epoch) of the last access.
193    pub last_accessed: u64,
194    /// Timestamp (microseconds since epoch) when the object was created.
195    pub created_at: u64,
196    /// Arbitrary string tags for metadata.
197    pub tags: Vec<String>,
198    /// Estimated cost per hour for storing this object (in abstract units).
199    pub cost_per_hour: f64,
200}
201
202// ---------------------------------------------------------------------------
203// OstTierTransition
204// ---------------------------------------------------------------------------
205
206/// Record of an object moving between storage tiers.
207///
208/// `Ost` prefix avoids collision with `TierTransition` in `tier_manager`.
209#[derive(Debug, Clone)]
210pub struct OstTierTransition {
211    /// ID of the object that was moved.
212    pub object_id: String,
213    /// Tier the object was moved *from*.
214    pub from_tier: OstStorageTier,
215    /// Tier the object was moved *to*.
216    pub to_tier: OstStorageTier,
217    /// Human-readable reason for the transition.
218    pub reason: String,
219    /// Timestamp (microseconds since epoch) of the transition.
220    pub transitioned_at: u64,
221    /// Number of bytes that were moved.
222    pub bytes_moved: u64,
223}
224
225// ---------------------------------------------------------------------------
226// TieringStats
227// ---------------------------------------------------------------------------
228
229/// Aggregate statistics for the tiering engine.
230#[derive(Debug, Clone, Default)]
231pub struct TieringStats {
232    /// Number of objects currently in the Hot tier.
233    pub hot_objects: usize,
234    /// Number of objects currently in the Warm tier.
235    pub warm_objects: usize,
236    /// Number of objects currently in the Cold tier.
237    pub cold_objects: usize,
238    /// Total bytes in the Hot tier.
239    pub hot_bytes: u64,
240    /// Total bytes in the Warm tier.
241    pub warm_bytes: u64,
242    /// Total bytes in the Cold tier.
243    pub cold_bytes: u64,
244    /// Total number of promotions performed (demotion → hotter tier).
245    pub promotions: u64,
246    /// Total number of demotions performed (hotter → colder tier).
247    pub demotions: u64,
248    /// Sum of `cost_per_hour` across all managed objects.
249    pub total_cost_per_hour: f64,
250}
251
252// ---------------------------------------------------------------------------
253// TieringError
254// ---------------------------------------------------------------------------
255
256/// Errors returned by the tiering engine.
257#[derive(Debug, Clone, PartialEq)]
258pub enum TieringError {
259    /// No object with the given ID was found.
260    ObjectNotFound(String),
261    /// The target tier does not have enough capacity.
262    TierFull {
263        /// Which tier is full.
264        tier: OstStorageTier,
265        /// How many bytes were needed.
266        needed: u64,
267        /// How many bytes are currently available.
268        available: u64,
269    },
270    /// The requested operation conflicts with the active policy.
271    PolicyConflict(String),
272    /// The supplied configuration is invalid.
273    InvalidConfiguration(String),
274}
275
276impl std::fmt::Display for TieringError {
277    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
278        match self {
279            TieringError::ObjectNotFound(id) => write!(f, "object not found: {id}"),
280            TieringError::TierFull {
281                tier,
282                needed,
283                available,
284            } => {
285                write!(
286                    f,
287                    "tier {tier} full: needed {needed} bytes, {available} available"
288                )
289            }
290            TieringError::PolicyConflict(msg) => write!(f, "policy conflict: {msg}"),
291            TieringError::InvalidConfiguration(msg) => {
292                write!(f, "invalid configuration: {msg}")
293            }
294        }
295    }
296}
297
298impl std::error::Error for TieringError {}
299
300// ---------------------------------------------------------------------------
301// Internal helpers
302// ---------------------------------------------------------------------------
303
304/// Maximum number of transitions kept in the history ring buffer.
305const MAX_HISTORY: usize = 500;
306
307/// Returns the capacity of the given tier as configured.
308fn tier_capacity(cfg: &OstTierConfig, tier: OstStorageTier) -> u64 {
309    match tier {
310        OstStorageTier::Hot => cfg.hot_capacity_bytes,
311        OstStorageTier::Warm => cfg.warm_capacity_bytes,
312        OstStorageTier::Cold => cfg.cold_capacity_bytes,
313    }
314}
315
316// ---------------------------------------------------------------------------
317// ObjectStorageTiering
318// ---------------------------------------------------------------------------
319
320/// Production-quality hierarchical object storage tiering engine.
321///
322/// Maintains a set of `TieredObject` instances distributed across Hot, Warm,
323/// and Cold tiers. Automatic promotion/demotion is driven by the
324/// `OstTierPolicy` supplied in `OstTierConfig`.
325pub struct ObjectStorageTiering {
326    config: OstTierConfig,
327    /// Primary object store: id → object.
328    objects: HashMap<String, TieredObject>,
329    /// Per-tier byte usage counters.
330    tier_bytes: [u64; 3],
331    /// Ring buffer of the last `MAX_HISTORY` tier transitions.
332    history: VecDeque<OstTierTransition>,
333    /// Running counts for statistics.
334    promotions: u64,
335    demotions: u64,
336    /// IDs of objects that need a promotion on the next `run_policy` call
337    /// (populated by `retrieve` when access crosses the threshold).
338    pending_promotions: Vec<String>,
339}
340
341impl ObjectStorageTiering {
342    // ------------------------------------------------------------------
343    // Construction
344    // ------------------------------------------------------------------
345
346    /// Create a new tiering engine with the supplied configuration.
347    pub fn new(config: OstTierConfig) -> Self {
348        Self {
349            config,
350            objects: HashMap::new(),
351            tier_bytes: [0u64; 3],
352            history: VecDeque::with_capacity(MAX_HISTORY + 1),
353            promotions: 0,
354            demotions: 0,
355            pending_promotions: Vec::new(),
356        }
357    }
358
359    // ------------------------------------------------------------------
360    // Private helpers
361    // ------------------------------------------------------------------
362
363    /// Bytes currently used by `tier`.
364    fn used_bytes(&self, tier: OstStorageTier) -> u64 {
365        self.tier_bytes[tier.rank() as usize]
366    }
367
368    /// Free bytes available in `tier`.
369    fn available_bytes(&self, tier: OstStorageTier) -> u64 {
370        let cap = tier_capacity(&self.config, tier);
371        let used = self.used_bytes(tier);
372        cap.saturating_sub(used)
373    }
374
375    /// Add `delta` bytes to the usage counter for `tier`.
376    fn add_bytes(&mut self, tier: OstStorageTier, delta: u64) {
377        self.tier_bytes[tier.rank() as usize] =
378            self.tier_bytes[tier.rank() as usize].saturating_add(delta);
379    }
380
381    /// Subtract `delta` bytes from the usage counter for `tier`.
382    fn sub_bytes(&mut self, tier: OstStorageTier, delta: u64) {
383        self.tier_bytes[tier.rank() as usize] =
384            self.tier_bytes[tier.rank() as usize].saturating_sub(delta);
385    }
386
387    /// Append a transition to the history ring buffer.
388    fn push_history(&mut self, t: OstTierTransition) {
389        if self.history.len() >= MAX_HISTORY {
390            self.history.pop_front();
391        }
392        self.history.push_back(t);
393    }
394
395    /// Determine the preferred initial tier for a new object of `size_bytes`.
396    /// Falls back to progressively colder tiers if a tier is full.
397    fn initial_tier_for(&self, size_bytes: u64) -> Result<OstStorageTier, TieringError> {
398        for tier in [
399            OstStorageTier::Hot,
400            OstStorageTier::Warm,
401            OstStorageTier::Cold,
402        ] {
403            if self.available_bytes(tier) >= size_bytes {
404                return Ok(tier);
405            }
406        }
407        Err(TieringError::TierFull {
408            tier: OstStorageTier::Cold,
409            needed: size_bytes,
410            available: self.available_bytes(OstStorageTier::Cold),
411        })
412    }
413
414    /// Internal move: update bookkeeping and record the transition.
415    /// Does NOT check capacity — caller must ensure space exists.
416    fn internal_move(
417        &mut self,
418        id: &str,
419        to_tier: OstStorageTier,
420        reason: &str,
421        current_ts: u64,
422    ) -> OstTierTransition {
423        // We need to get size and from_tier from the object.
424        let (from_tier, size_bytes) = {
425            let obj = self.objects.get(id).expect("caller verified existence");
426            (obj.current_tier, obj.size_bytes)
427        };
428        // Update accounting.
429        self.sub_bytes(from_tier, size_bytes);
430        self.add_bytes(to_tier, size_bytes);
431        // Update the object itself.
432        if let Some(obj) = self.objects.get_mut(id) {
433            obj.current_tier = to_tier;
434        }
435        let t = OstTierTransition {
436            object_id: id.to_string(),
437            from_tier,
438            to_tier,
439            reason: reason.to_string(),
440            transitioned_at: current_ts,
441            bytes_moved: size_bytes,
442        };
443        self.push_history(t.clone());
444        t
445    }
446
447    // ------------------------------------------------------------------
448    // Public API
449    // ------------------------------------------------------------------
450
451    /// Store an object, placing it in the most appropriate tier based on
452    /// policy and available capacity.
453    ///
454    /// New objects always attempt Hot first, then Warm, then Cold. The
455    /// `current_tier` field of the supplied object is ignored; the actual
456    /// assigned tier is returned.
457    ///
458    /// Returns `TieringError::TierFull` if no tier can accept the object.
459    pub fn store(&mut self, mut object: TieredObject) -> Result<OstStorageTier, TieringError> {
460        let tier = self.initial_tier_for(object.size_bytes)?;
461        object.current_tier = tier;
462        self.add_bytes(tier, object.size_bytes);
463        self.objects.insert(object.id.clone(), object);
464        Ok(tier)
465    }
466
467    /// Retrieve an object by ID, updating its `last_accessed` timestamp and
468    /// incrementing `access_count`. If the access count crosses the configured
469    /// promotion threshold the object is queued for promotion.
470    ///
471    /// Returns `TieringError::ObjectNotFound` if the ID is unknown.
472    pub fn retrieve(&mut self, id: &str, current_ts: u64) -> Result<&TieredObject, TieringError> {
473        let obj = self
474            .objects
475            .get_mut(id)
476            .ok_or_else(|| TieringError::ObjectNotFound(id.to_string()))?;
477        obj.last_accessed = current_ts;
478        obj.access_count = obj.access_count.saturating_add(1);
479        // Queue for promotion if threshold crossed and not already hot.
480        let should_queue = obj.access_count > obj.access_count.saturating_sub(1)
481            && obj.access_count == (self.config.promotion_threshold as u64).saturating_add(1)
482            && obj.current_tier != OstStorageTier::Hot;
483        if should_queue {
484            self.pending_promotions.push(id.to_string());
485        }
486        Ok(self.objects.get(id).expect("just inserted/accessed"))
487    }
488
489    /// Promote an object to a hotter tier.
490    ///
491    /// Returns `TieringError::TierFull` if the target tier lacks capacity.
492    /// Returns `TieringError::ObjectNotFound` if the ID is unknown.
493    /// Returns `TieringError::PolicyConflict` if `to` is colder than or equal
494    /// to the object's current tier (use `demote` instead).
495    pub fn promote(
496        &mut self,
497        id: &str,
498        to: OstStorageTier,
499        current_ts: u64,
500    ) -> Result<OstTierTransition, TieringError> {
501        let (current_tier, size_bytes) = {
502            let obj = self
503                .objects
504                .get(id)
505                .ok_or_else(|| TieringError::ObjectNotFound(id.to_string()))?;
506            (obj.current_tier, obj.size_bytes)
507        };
508        if to >= current_tier {
509            return Err(TieringError::PolicyConflict(format!(
510                "promote: target tier {to} is not hotter than current tier {current_tier}"
511            )));
512        }
513        let avail = self.available_bytes(to);
514        if avail < size_bytes {
515            return Err(TieringError::TierFull {
516                tier: to,
517                needed: size_bytes,
518                available: avail,
519            });
520        }
521        self.promotions += 1;
522        Ok(self.internal_move(id, to, "explicit promotion", current_ts))
523    }
524
525    /// Demote an object to a colder tier.
526    ///
527    /// Returns `TieringError::TierFull` if the target tier lacks capacity.
528    /// Returns `TieringError::ObjectNotFound` if the ID is unknown.
529    /// Returns `TieringError::PolicyConflict` if `to` is hotter than or equal
530    /// to the object's current tier.
531    pub fn demote(
532        &mut self,
533        id: &str,
534        to: OstStorageTier,
535        current_ts: u64,
536    ) -> Result<OstTierTransition, TieringError> {
537        let (current_tier, size_bytes) = {
538            let obj = self
539                .objects
540                .get(id)
541                .ok_or_else(|| TieringError::ObjectNotFound(id.to_string()))?;
542            (obj.current_tier, obj.size_bytes)
543        };
544        if to <= current_tier {
545            return Err(TieringError::PolicyConflict(format!(
546                "demote: target tier {to} is not colder than current tier {current_tier}"
547            )));
548        }
549        let avail = self.available_bytes(to);
550        if avail < size_bytes {
551            return Err(TieringError::TierFull {
552                tier: to,
553                needed: size_bytes,
554                available: avail,
555            });
556        }
557        self.demotions += 1;
558        Ok(self.internal_move(id, to, "explicit demotion", current_ts))
559    }
560
561    /// Evaluate all objects against the active policy and perform automatic
562    /// promotions and demotions. Returns all transitions that occurred.
563    ///
564    /// Policies applied:
565    /// - `AccessFrequency(n)`: objects with `access_count > n` not in Hot → promote.
566    /// - `RecencyBased(window_us)`: objects not accessed in `window_us` µs → Cold.
567    /// - `SizeThreshold`: enforce per-tier byte caps by demoting largest objects first.
568    /// - `CostOptimized(multiplier)`: demote objects whose cost-adjusted score
569    ///   ranks them as low-value, keeping total cost under budget.
570    /// - `ManualOnly`: no automatic transitions.
571    pub fn run_policy(&mut self, current_ts: u64) -> Vec<OstTierTransition> {
572        let mut transitions = Vec::new();
573
574        match self.config.policy.clone() {
575            OstTierPolicy::AccessFrequency(threshold) => {
576                transitions.extend(self.run_access_frequency_policy(threshold, current_ts));
577            }
578            OstTierPolicy::RecencyBased(window_us) => {
579                transitions.extend(self.run_recency_policy(window_us, current_ts));
580            }
581            OstTierPolicy::SizeThreshold {
582                hot_max_bytes,
583                warm_max_bytes,
584            } => {
585                transitions.extend(self.run_size_threshold_policy(
586                    hot_max_bytes,
587                    warm_max_bytes,
588                    current_ts,
589                ));
590            }
591            OstTierPolicy::ManualOnly => {}
592            OstTierPolicy::CostOptimized(multiplier) => {
593                transitions.extend(self.run_cost_optimized_policy(multiplier, current_ts));
594            }
595        }
596
597        // Also drain pending promotions from retrieve().
598        let pending: Vec<String> = std::mem::take(&mut self.pending_promotions);
599        for id in pending {
600            if let Some(obj) = self.objects.get(&id) {
601                if obj.current_tier == OstStorageTier::Hot {
602                    continue;
603                }
604                let target = OstStorageTier::Hot;
605                let size = obj.size_bytes;
606                if self.available_bytes(target) >= size {
607                    self.promotions += 1;
608                    let t =
609                        self.internal_move(&id, target, "access-threshold promotion", current_ts);
610                    transitions.push(t);
611                }
612            }
613        }
614
615        transitions
616    }
617
618    /// Demote objects from `tier` to make room for `needed_bytes`, using
619    /// Least-Recently-Used ordering. Returns all transitions performed.
620    ///
621    /// Objects are demoted one tier at a time (Hot→Warm or Warm→Cold).
622    pub fn evict_tier(
623        &mut self,
624        tier: OstStorageTier,
625        needed_bytes: u64,
626    ) -> Vec<OstTierTransition> {
627        let current_ts = 0u64; // timestamp not provided; use 0 as sentinel
628        self.evict_tier_ts(tier, needed_bytes, current_ts)
629    }
630
631    /// Like `evict_tier` but accepts an explicit timestamp.
632    pub fn evict_tier_ts(
633        &mut self,
634        tier: OstStorageTier,
635        needed_bytes: u64,
636        current_ts: u64,
637    ) -> Vec<OstTierTransition> {
638        let target_tier = match tier.colder() {
639            Some(t) => t,
640            None => return vec![], // Cold has nowhere colder
641        };
642
643        // Collect candidates sorted by LRU (oldest last_accessed first).
644        let mut candidates: Vec<(String, u64, u64)> = self
645            .objects
646            .values()
647            .filter(|o| o.current_tier == tier)
648            .map(|o| (o.id.clone(), o.last_accessed, o.size_bytes))
649            .collect();
650        candidates.sort_by_key(|(_, last_accessed, _)| *last_accessed);
651
652        let mut freed = 0u64;
653        let mut transitions = Vec::new();
654
655        for (id, _last_accessed, size) in candidates {
656            if freed >= needed_bytes {
657                break;
658            }
659            let avail = self.available_bytes(target_tier);
660            if avail < size {
661                // Target tier also full — cascade demotion if possible.
662                continue;
663            }
664            self.demotions += 1;
665            let t = self.internal_move(&id, target_tier, "lru eviction", current_ts);
666            freed += size;
667            transitions.push(t);
668        }
669
670        transitions
671    }
672
673    /// Return references to all objects currently in `tier`.
674    pub fn tier_objects(&self, tier: OstStorageTier) -> Vec<&TieredObject> {
675        self.objects
676            .values()
677            .filter(|o| o.current_tier == tier)
678            .collect()
679    }
680
681    /// Return a copy of the last up-to-500 tier transitions (oldest first).
682    pub fn transition_history(&self) -> Vec<OstTierTransition> {
683        self.history.iter().cloned().collect()
684    }
685
686    /// Return aggregate statistics for all tiers.
687    pub fn stats(&self) -> TieringStats {
688        let mut stats = TieringStats {
689            promotions: self.promotions,
690            demotions: self.demotions,
691            ..Default::default()
692        };
693        for obj in self.objects.values() {
694            stats.total_cost_per_hour += obj.cost_per_hour;
695            match obj.current_tier {
696                OstStorageTier::Hot => {
697                    stats.hot_objects += 1;
698                    stats.hot_bytes += obj.size_bytes;
699                }
700                OstStorageTier::Warm => {
701                    stats.warm_objects += 1;
702                    stats.warm_bytes += obj.size_bytes;
703                }
704                OstStorageTier::Cold => {
705                    stats.cold_objects += 1;
706                    stats.cold_bytes += obj.size_bytes;
707                }
708            }
709        }
710        stats
711    }
712
713    // ------------------------------------------------------------------
714    // Policy runners (private)
715    // ------------------------------------------------------------------
716
717    fn run_access_frequency_policy(
718        &mut self,
719        threshold: u32,
720        current_ts: u64,
721    ) -> Vec<OstTierTransition> {
722        let promote_ids: Vec<String> = self
723            .objects
724            .values()
725            .filter(|o| o.access_count > threshold as u64 && o.current_tier != OstStorageTier::Hot)
726            .map(|o| o.id.clone())
727            .collect();
728
729        let mut transitions = Vec::new();
730        for id in promote_ids {
731            let size = match self.objects.get(&id) {
732                Some(o) => o.size_bytes,
733                None => continue,
734            };
735            if self.available_bytes(OstStorageTier::Hot) >= size {
736                self.promotions += 1;
737                let t = self.internal_move(
738                    &id,
739                    OstStorageTier::Hot,
740                    "access-frequency promotion",
741                    current_ts,
742                );
743                transitions.push(t);
744            }
745        }
746        transitions
747    }
748
749    fn run_recency_policy(&mut self, window_us: u64, current_ts: u64) -> Vec<OstTierTransition> {
750        let cutoff = current_ts.saturating_sub(window_us);
751        let demote_ids: Vec<String> = self
752            .objects
753            .values()
754            .filter(|o| o.last_accessed < cutoff && o.current_tier != OstStorageTier::Cold)
755            .map(|o| o.id.clone())
756            .collect();
757
758        let mut transitions = Vec::new();
759        for id in demote_ids {
760            let size = match self.objects.get(&id) {
761                Some(o) => o.size_bytes,
762                None => continue,
763            };
764            if self.available_bytes(OstStorageTier::Cold) >= size {
765                self.demotions += 1;
766                let t =
767                    self.internal_move(&id, OstStorageTier::Cold, "recency demotion", current_ts);
768                transitions.push(t);
769            }
770        }
771        transitions
772    }
773
774    fn run_size_threshold_policy(
775        &mut self,
776        hot_max_bytes: u64,
777        warm_max_bytes: u64,
778        current_ts: u64,
779    ) -> Vec<OstTierTransition> {
780        let mut transitions = Vec::new();
781
782        // Enforce Hot cap: demote largest objects first until within cap.
783        while self.used_bytes(OstStorageTier::Hot) > hot_max_bytes {
784            // Pick the largest hot object.
785            let victim = self
786                .objects
787                .values()
788                .filter(|o| o.current_tier == OstStorageTier::Hot)
789                .max_by_key(|o| o.size_bytes)
790                .map(|o| (o.id.clone(), o.size_bytes));
791            match victim {
792                None => break,
793                Some((id, size)) => {
794                    if self.available_bytes(OstStorageTier::Warm) >= size {
795                        self.demotions += 1;
796                        let t = self.internal_move(
797                            &id,
798                            OstStorageTier::Warm,
799                            "size-threshold hot→warm",
800                            current_ts,
801                        );
802                        transitions.push(t);
803                    } else if self.available_bytes(OstStorageTier::Cold) >= size {
804                        self.demotions += 1;
805                        let t = self.internal_move(
806                            &id,
807                            OstStorageTier::Cold,
808                            "size-threshold hot→cold",
809                            current_ts,
810                        );
811                        transitions.push(t);
812                    } else {
813                        break; // nowhere to put it
814                    }
815                }
816            }
817        }
818
819        // Enforce Warm cap.
820        while self.used_bytes(OstStorageTier::Warm) > warm_max_bytes {
821            let victim = self
822                .objects
823                .values()
824                .filter(|o| o.current_tier == OstStorageTier::Warm)
825                .max_by_key(|o| o.size_bytes)
826                .map(|o| (o.id.clone(), o.size_bytes));
827            match victim {
828                None => break,
829                Some((id, size)) => {
830                    if self.available_bytes(OstStorageTier::Cold) >= size {
831                        self.demotions += 1;
832                        let t = self.internal_move(
833                            &id,
834                            OstStorageTier::Cold,
835                            "size-threshold warm→cold",
836                            current_ts,
837                        );
838                        transitions.push(t);
839                    } else {
840                        break;
841                    }
842                }
843            }
844        }
845
846        transitions
847    }
848
849    fn run_cost_optimized_policy(
850        &mut self,
851        multiplier: f64,
852        current_ts: u64,
853    ) -> Vec<OstTierTransition> {
854        // Score each non-Cold object: score = access_count / (cost_per_hour * multiplier + 1.0).
855        // Low score → good demotion candidate.
856        let mut scored: Vec<(String, f64, OstStorageTier, u64)> = self
857            .objects
858            .values()
859            .filter(|o| o.current_tier != OstStorageTier::Cold)
860            .map(|o| {
861                let score = o.access_count as f64 / (o.cost_per_hour * multiplier + 1.0);
862                (o.id.clone(), score, o.current_tier, o.size_bytes)
863            })
864            .collect();
865        // Sort ascending — lowest score first.
866        scored.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
867
868        // Demote bottom 25% (at least 1 if any candidates).
869        let demote_count = (scored.len() as f64 * 0.25).ceil() as usize;
870        let mut transitions = Vec::new();
871
872        for (id, _score, current_tier, size) in scored.into_iter().take(demote_count) {
873            let target = match current_tier.colder() {
874                Some(t) => t,
875                None => continue,
876            };
877            if self.available_bytes(target) >= size {
878                self.demotions += 1;
879                let t = self.internal_move(&id, target, "cost-optimized demotion", current_ts);
880                transitions.push(t);
881            }
882        }
883
884        transitions
885    }
886}
887
888// ---------------------------------------------------------------------------
889// Tests
890// ---------------------------------------------------------------------------
891
892#[cfg(test)]
893mod tests {
894    use super::*;
895
896    // -----------------------------------------------------------------------
897    // Tiny deterministic PRNG (xorshift64) — avoids the `rand` crate.
898    // -----------------------------------------------------------------------
899
900    fn xorshift64(state: &mut u64) -> u64 {
901        *state ^= *state << 13;
902        *state ^= *state >> 7;
903        *state ^= *state << 17;
904        *state
905    }
906
907    fn make_object(id: &str, size: u64, access_count: u64, last_accessed: u64) -> TieredObject {
908        TieredObject {
909            id: id.to_string(),
910            size_bytes: size,
911            current_tier: OstStorageTier::Hot, // will be overridden by store()
912            access_count,
913            last_accessed,
914            created_at: 0,
915            tags: vec![],
916            cost_per_hour: 0.001 * size as f64,
917        }
918    }
919
920    fn default_config() -> OstTierConfig {
921        OstTierConfig {
922            hot_capacity_bytes: 10_000,
923            warm_capacity_bytes: 100_000,
924            cold_capacity_bytes: u64::MAX,
925            policy: OstTierPolicy::ManualOnly,
926            promotion_threshold: 5,
927            demotion_interval_us: 1_000_000,
928        }
929    }
930
931    fn tiering() -> ObjectStorageTiering {
932        ObjectStorageTiering::new(default_config())
933    }
934
935    // -----------------------------------------------------------------------
936    // store() tests
937    // -----------------------------------------------------------------------
938
939    #[test]
940    fn test_store_small_goes_to_hot() {
941        let mut t = tiering();
942        let obj = make_object("a", 100, 0, 0);
943        let tier = t.store(obj).unwrap();
944        assert_eq!(tier, OstStorageTier::Hot);
945    }
946
947    #[test]
948    fn test_store_fills_hot_then_warm() {
949        let mut t = tiering();
950        // Fill hot tier completely.
951        let obj1 = make_object("big", 10_000, 0, 0);
952        assert_eq!(t.store(obj1).unwrap(), OstStorageTier::Hot);
953        // Next object overflows hot, should go to warm.
954        let obj2 = make_object("next", 100, 0, 0);
955        assert_eq!(t.store(obj2).unwrap(), OstStorageTier::Warm);
956    }
957
958    #[test]
959    fn test_store_fills_all_tiers_cold() {
960        let mut cfg = default_config();
961        cfg.cold_capacity_bytes = 1_000_000;
962        let mut t = ObjectStorageTiering::new(cfg);
963        let _ = t.store(make_object("h", 10_000, 0, 0)).unwrap();
964        let _ = t.store(make_object("w", 100_000, 0, 0)).unwrap();
965        let tier = t.store(make_object("c", 500_000, 0, 0)).unwrap();
966        assert_eq!(tier, OstStorageTier::Cold);
967    }
968
969    #[test]
970    fn test_store_all_full_returns_tier_full_error() {
971        let mut cfg = default_config();
972        cfg.cold_capacity_bytes = 5;
973        let mut t = ObjectStorageTiering::new(cfg);
974        let _ = t.store(make_object("h", 10_000, 0, 0)).unwrap();
975        let _ = t.store(make_object("w", 100_000, 0, 0)).unwrap();
976        let err = t.store(make_object("c", 100, 0, 0)).unwrap_err();
977        assert!(matches!(
978            err,
979            TieringError::TierFull {
980                tier: OstStorageTier::Cold,
981                ..
982            }
983        ));
984    }
985
986    #[test]
987    fn test_store_updates_tier_bytes() {
988        let mut t = tiering();
989        t.store(make_object("x", 1_000, 0, 0)).unwrap();
990        assert_eq!(t.used_bytes(OstStorageTier::Hot), 1_000);
991    }
992
993    #[test]
994    fn test_store_multiple_objects() {
995        let mut t = tiering();
996        let mut state = 42u64;
997        for i in 0..20 {
998            let size = (xorshift64(&mut state) % 400) + 10;
999            let id = format!("obj-{i}");
1000            t.store(make_object(&id, size, 0, 0)).unwrap();
1001        }
1002        let s = t.stats();
1003        assert_eq!(s.hot_objects + s.warm_objects + s.cold_objects, 20);
1004    }
1005
1006    // -----------------------------------------------------------------------
1007    // retrieve() tests
1008    // -----------------------------------------------------------------------
1009
1010    #[test]
1011    fn test_retrieve_found() {
1012        let mut t = tiering();
1013        t.store(make_object("r1", 100, 0, 0)).unwrap();
1014        let obj = t.retrieve("r1", 1000).unwrap();
1015        assert_eq!(obj.id, "r1");
1016    }
1017
1018    #[test]
1019    fn test_retrieve_updates_access_count() {
1020        let mut t = tiering();
1021        t.store(make_object("r2", 100, 0, 0)).unwrap();
1022        t.retrieve("r2", 1000).unwrap();
1023        t.retrieve("r2", 2000).unwrap();
1024        let obj = t.retrieve("r2", 3000).unwrap();
1025        assert_eq!(obj.access_count, 3);
1026    }
1027
1028    #[test]
1029    fn test_retrieve_updates_last_accessed() {
1030        let mut t = tiering();
1031        t.store(make_object("r3", 100, 0, 0)).unwrap();
1032        t.retrieve("r3", 9999).unwrap();
1033        let obj = t.retrieve("r3", 9999).unwrap();
1034        assert_eq!(obj.last_accessed, 9999);
1035    }
1036
1037    #[test]
1038    fn test_retrieve_not_found_error() {
1039        let mut t = tiering();
1040        let err = t.retrieve("nope", 0).unwrap_err();
1041        assert!(matches!(err, TieringError::ObjectNotFound(_)));
1042    }
1043
1044    #[test]
1045    fn test_retrieve_queues_pending_promotion() {
1046        let mut cfg = default_config();
1047        cfg.promotion_threshold = 2;
1048        let mut t = ObjectStorageTiering::new(cfg);
1049        // Store into warm (fill hot first).
1050        t.store(make_object("fill-hot", 10_000, 0, 0)).unwrap();
1051        t.store(make_object("warm-obj", 100, 0, 0)).unwrap();
1052        // Access warm-obj enough times to cross threshold.
1053        t.retrieve("warm-obj", 1).unwrap();
1054        t.retrieve("warm-obj", 2).unwrap();
1055        t.retrieve("warm-obj", 3).unwrap(); // crosses threshold (>2 = 3)
1056        assert!(!t.pending_promotions.is_empty());
1057    }
1058
1059    // -----------------------------------------------------------------------
1060    // promote() tests
1061    // -----------------------------------------------------------------------
1062
1063    #[test]
1064    fn test_promote_warm_to_hot() {
1065        let mut cfg = default_config();
1066        cfg.hot_capacity_bytes = 10_000;
1067        let mut t = ObjectStorageTiering::new(cfg);
1068        // Fill hot completely.
1069        t.store(make_object("hot-fill", 10_000, 0, 0)).unwrap();
1070        // "w" overflows to warm.
1071        t.store(make_object("w", 100, 0, 0)).unwrap();
1072        assert_eq!(t.objects["w"].current_tier, OstStorageTier::Warm);
1073        // Evict hot-fill from hot to make room.
1074        let transitions = t.evict_tier_ts(OstStorageTier::Hot, 10_000, 100);
1075        assert!(!transitions.is_empty());
1076        // Now hot is empty; promote "w" from warm to hot.
1077        let r = t.promote("w", OstStorageTier::Hot, 200);
1078        assert!(r.is_ok());
1079        assert_eq!(t.objects["w"].current_tier, OstStorageTier::Hot);
1080    }
1081
1082    #[test]
1083    fn test_promote_cold_to_warm() {
1084        // Use a config where warm has plenty of room after hot is filled.
1085        let mut cfg = default_config();
1086        cfg.hot_capacity_bytes = 10_000;
1087        cfg.warm_capacity_bytes = 200_000;
1088        let mut t = ObjectStorageTiering::new(cfg);
1089        // Fill hot entirely.
1090        t.store(make_object("hot-fill", 10_000, 0, 0)).unwrap();
1091        // Partially fill warm.
1092        t.store(make_object("warm-partial", 50_000, 0, 0)).unwrap();
1093        // Next object goes to cold (warm still has room but let's force cold by
1094        // filling warm too).
1095        t.store(make_object("warm-fill2", 150_000, 0, 0)).unwrap();
1096        // cold-obj should land in cold since warm is now full.
1097        let cold_tier = t.store(make_object("cold-obj", 500, 0, 0)).unwrap();
1098        assert_eq!(cold_tier, OstStorageTier::Cold);
1099        // Evict from warm to make room.
1100        t.evict_tier_ts(OstStorageTier::Warm, 600, 500);
1101        let r = t.promote("cold-obj", OstStorageTier::Warm, 1000);
1102        assert!(r.is_ok());
1103        let tr = r.unwrap();
1104        assert_eq!(tr.from_tier, OstStorageTier::Cold);
1105        assert_eq!(tr.to_tier, OstStorageTier::Warm);
1106    }
1107
1108    #[test]
1109    fn test_promote_increments_promotion_counter() {
1110        let mut t = tiering();
1111        t.store(make_object("hot-fill", 10_000, 0, 0)).unwrap();
1112        t.store(make_object("warm-obj", 100, 0, 0)).unwrap();
1113        t.evict_tier_ts(OstStorageTier::Hot, 200, 50);
1114        t.promote("warm-obj", OstStorageTier::Hot, 100).unwrap();
1115        assert_eq!(t.stats().promotions, 1);
1116    }
1117
1118    #[test]
1119    fn test_promote_rejects_same_or_lower_tier() {
1120        let mut t = tiering();
1121        t.store(make_object("obj", 100, 0, 0)).unwrap();
1122        let err = t.promote("obj", OstStorageTier::Warm, 0).unwrap_err();
1123        assert!(matches!(err, TieringError::PolicyConflict(_)));
1124    }
1125
1126    #[test]
1127    fn test_promote_tier_full_error() {
1128        let mut t = tiering();
1129        // Fill hot.
1130        t.store(make_object("fill", 10_000, 0, 0)).unwrap();
1131        // Store in warm.
1132        t.store(make_object("w", 100, 0, 0)).unwrap();
1133        // Hot is full — promote should fail.
1134        let err = t.promote("w", OstStorageTier::Hot, 0).unwrap_err();
1135        assert!(matches!(
1136            err,
1137            TieringError::TierFull {
1138                tier: OstStorageTier::Hot,
1139                ..
1140            }
1141        ));
1142    }
1143
1144    #[test]
1145    fn test_promote_not_found() {
1146        let mut t = tiering();
1147        let err = t.promote("missing", OstStorageTier::Hot, 0).unwrap_err();
1148        assert!(matches!(err, TieringError::ObjectNotFound(_)));
1149    }
1150
1151    // -----------------------------------------------------------------------
1152    // demote() tests
1153    // -----------------------------------------------------------------------
1154
1155    #[test]
1156    fn test_demote_hot_to_warm() {
1157        let mut t = tiering();
1158        t.store(make_object("obj", 100, 0, 0)).unwrap();
1159        let tr = t.demote("obj", OstStorageTier::Warm, 10).unwrap();
1160        assert_eq!(tr.from_tier, OstStorageTier::Hot);
1161        assert_eq!(tr.to_tier, OstStorageTier::Warm);
1162    }
1163
1164    #[test]
1165    fn test_demote_hot_to_cold() {
1166        let mut t = tiering();
1167        t.store(make_object("obj", 100, 0, 0)).unwrap();
1168        let tr = t.demote("obj", OstStorageTier::Cold, 10).unwrap();
1169        assert_eq!(tr.to_tier, OstStorageTier::Cold);
1170    }
1171
1172    #[test]
1173    fn test_demote_increments_demotion_counter() {
1174        let mut t = tiering();
1175        t.store(make_object("obj", 100, 0, 0)).unwrap();
1176        t.demote("obj", OstStorageTier::Cold, 0).unwrap();
1177        assert_eq!(t.stats().demotions, 1);
1178    }
1179
1180    #[test]
1181    fn test_demote_rejects_same_or_higher_tier() {
1182        let mut t = tiering();
1183        t.store(make_object("hot-fill", 10_000, 0, 0)).unwrap();
1184        t.store(make_object("warm-obj", 100, 0, 0)).unwrap();
1185        // warm-obj is in Warm; demoting to Hot should fail.
1186        let err = t.demote("warm-obj", OstStorageTier::Hot, 0).unwrap_err();
1187        assert!(matches!(err, TieringError::PolicyConflict(_)));
1188    }
1189
1190    #[test]
1191    fn test_demote_not_found() {
1192        let mut t = tiering();
1193        let err = t.demote("ghost", OstStorageTier::Cold, 0).unwrap_err();
1194        assert!(matches!(err, TieringError::ObjectNotFound(_)));
1195    }
1196
1197    #[test]
1198    fn test_demote_tier_full() {
1199        let mut cfg = default_config();
1200        cfg.warm_capacity_bytes = 50; // tiny warm
1201        let mut t = ObjectStorageTiering::new(cfg);
1202        t.store(make_object("obj", 100, 0, 0)).unwrap();
1203        let err = t.demote("obj", OstStorageTier::Warm, 0).unwrap_err();
1204        assert!(matches!(
1205            err,
1206            TieringError::TierFull {
1207                tier: OstStorageTier::Warm,
1208                ..
1209            }
1210        ));
1211    }
1212
1213    // -----------------------------------------------------------------------
1214    // run_policy() — AccessFrequency
1215    // -----------------------------------------------------------------------
1216
1217    #[test]
1218    fn test_run_policy_access_frequency_promotes() {
1219        let mut cfg = default_config();
1220        cfg.policy = OstTierPolicy::AccessFrequency(3);
1221        let mut t = ObjectStorageTiering::new(cfg);
1222        t.store(make_object("fill", 9_900, 0, 0)).unwrap();
1223        t.store(make_object("warm", 50, 0, 0)).unwrap();
1224        // Give "warm" many accesses (but don't promote yet).
1225        if let Some(obj) = t.objects.get_mut("warm") {
1226            obj.access_count = 10;
1227        }
1228        // Evict fill so hot has room.
1229        t.evict_tier_ts(OstStorageTier::Hot, 10_000, 100);
1230        let transitions = t.run_policy(200);
1231        let promoted = transitions
1232            .iter()
1233            .any(|tr| tr.object_id == "warm" && tr.to_tier == OstStorageTier::Hot);
1234        assert!(promoted, "expected warm object to be promoted to hot");
1235    }
1236
1237    #[test]
1238    fn test_run_policy_access_frequency_no_promotion_if_already_hot() {
1239        let mut cfg = default_config();
1240        cfg.policy = OstTierPolicy::AccessFrequency(3);
1241        let mut t = ObjectStorageTiering::new(cfg);
1242        t.store(make_object("obj", 100, 10, 0)).unwrap();
1243        let transitions = t.run_policy(0);
1244        assert!(transitions.iter().all(|tr| tr.object_id != "obj"
1245            || tr.to_tier != OstStorageTier::Hot
1246            || tr.from_tier == OstStorageTier::Hot));
1247    }
1248
1249    // -----------------------------------------------------------------------
1250    // run_policy() — RecencyBased
1251    // -----------------------------------------------------------------------
1252
1253    #[test]
1254    fn test_run_policy_recency_demotes_stale() {
1255        let mut cfg = default_config();
1256        cfg.policy = OstTierPolicy::RecencyBased(1_000_000); // 1 second window
1257        let mut t = ObjectStorageTiering::new(cfg);
1258        // Object last accessed at ts=0, current ts=2_000_000 → stale.
1259        t.store(make_object("stale", 100, 0, 0)).unwrap();
1260        let transitions = t.run_policy(2_000_000);
1261        assert!(transitions
1262            .iter()
1263            .any(|tr| tr.object_id == "stale" && tr.to_tier == OstStorageTier::Cold));
1264    }
1265
1266    #[test]
1267    fn test_run_policy_recency_keeps_fresh() {
1268        let mut cfg = default_config();
1269        cfg.policy = OstTierPolicy::RecencyBased(1_000_000);
1270        let mut t = ObjectStorageTiering::new(cfg);
1271        // Fresh: last_accessed = 1_500_000, current = 2_000_000, window = 1_000_000 → within window.
1272        let mut obj = make_object("fresh", 100, 0, 1_500_000);
1273        obj.last_accessed = 1_500_000;
1274        t.store(obj).unwrap();
1275        let transitions = t.run_policy(2_000_000);
1276        assert!(transitions
1277            .iter()
1278            .all(|tr| tr.object_id != "fresh" || tr.to_tier != OstStorageTier::Cold));
1279    }
1280
1281    #[test]
1282    fn test_run_policy_recency_skips_already_cold() {
1283        let mut cfg = default_config();
1284        cfg.policy = OstTierPolicy::RecencyBased(100);
1285        let mut t = ObjectStorageTiering::new(cfg);
1286        t.store(make_object("hot-fill", 10_000, 0, 0)).unwrap();
1287        t.store(make_object("warm-fill", 100_000, 0, 0)).unwrap();
1288        t.store(make_object("cold-obj", 100, 0, 0)).unwrap();
1289        // cold-obj is already cold; run_policy should not produce a transition for it.
1290        let before_demotions = t.demotions;
1291        let transitions = t.run_policy(999_999_999);
1292        let cold_moved: Vec<_> = transitions
1293            .iter()
1294            .filter(|tr| tr.object_id == "cold-obj")
1295            .collect();
1296        assert!(cold_moved.is_empty() || t.demotions == before_demotions);
1297    }
1298
1299    // -----------------------------------------------------------------------
1300    // run_policy() — SizeThreshold
1301    // -----------------------------------------------------------------------
1302
1303    #[test]
1304    fn test_run_policy_size_threshold_demotes_largest() {
1305        let mut cfg = default_config();
1306        cfg.policy = OstTierPolicy::SizeThreshold {
1307            hot_max_bytes: 5_000,
1308            warm_max_bytes: 50_000,
1309        };
1310        let mut t = ObjectStorageTiering::new(cfg);
1311        // Put 3 objects in hot. hot_capacity=10_000 but threshold=5_000.
1312        t.store(make_object("small", 1_000, 0, 0)).unwrap();
1313        t.store(make_object("medium", 2_000, 0, 0)).unwrap();
1314        t.store(make_object("large", 3_000, 0, 0)).unwrap();
1315        // Hot used = 6_000 > 5_000 threshold.
1316        let transitions = t.run_policy(0);
1317        // Largest object should have been demoted.
1318        assert!(transitions.iter().any(|tr| tr.object_id == "large"));
1319    }
1320
1321    #[test]
1322    fn test_run_policy_size_threshold_no_action_if_within_limits() {
1323        let mut cfg = default_config();
1324        cfg.policy = OstTierPolicy::SizeThreshold {
1325            hot_max_bytes: 10_000,
1326            warm_max_bytes: 100_000,
1327        };
1328        let mut t = ObjectStorageTiering::new(cfg);
1329        t.store(make_object("a", 100, 0, 0)).unwrap();
1330        let transitions = t.run_policy(0);
1331        assert!(transitions.is_empty());
1332    }
1333
1334    // -----------------------------------------------------------------------
1335    // run_policy() — CostOptimized
1336    // -----------------------------------------------------------------------
1337
1338    #[test]
1339    fn test_run_policy_cost_optimized_demotes_low_access() {
1340        let mut cfg = default_config();
1341        cfg.policy = OstTierPolicy::CostOptimized(1.0);
1342        let mut t = ObjectStorageTiering::new(cfg);
1343        // High-value object.
1344        let mut high = make_object("high", 500, 0, 0);
1345        high.access_count = 1000;
1346        t.store(high).unwrap();
1347        // Low-value object.
1348        let mut low = make_object("low", 500, 0, 0);
1349        low.access_count = 1;
1350        t.store(low).unwrap();
1351        let transitions = t.run_policy(0);
1352        // At least one demotion should have occurred.
1353        assert!(!transitions.is_empty());
1354    }
1355
1356    #[test]
1357    fn test_run_policy_manual_only_no_transitions() {
1358        let mut cfg = default_config();
1359        cfg.policy = OstTierPolicy::ManualOnly;
1360        let mut t = ObjectStorageTiering::new(cfg);
1361        t.store(make_object("a", 100, 100, 0)).unwrap();
1362        let transitions = t.run_policy(999_999_999);
1363        assert!(transitions.is_empty());
1364    }
1365
1366    // -----------------------------------------------------------------------
1367    // evict_tier() tests
1368    // -----------------------------------------------------------------------
1369
1370    #[test]
1371    fn test_evict_tier_lru_order() {
1372        let mut t = tiering();
1373        // Three hot objects with different last_accessed times.
1374        let mut o1 = make_object("oldest", 1_000, 0, 100);
1375        o1.last_accessed = 100;
1376        let mut o2 = make_object("middle", 1_000, 0, 200);
1377        o2.last_accessed = 200;
1378        let mut o3 = make_object("newest", 1_000, 0, 300);
1379        o3.last_accessed = 300;
1380        t.store(o1).unwrap();
1381        t.store(o2).unwrap();
1382        t.store(o3).unwrap();
1383        let transitions = t.evict_tier_ts(OstStorageTier::Hot, 1_001, 400);
1384        // Should evict "oldest" first.
1385        assert!(!transitions.is_empty());
1386        assert_eq!(transitions[0].object_id, "oldest");
1387    }
1388
1389    #[test]
1390    fn test_evict_tier_stops_when_enough_freed() {
1391        let mut t = tiering();
1392        for i in 0..5 {
1393            t.store(make_object(&format!("o{i}"), 1_000, 0, i as u64))
1394                .unwrap();
1395        }
1396        let transitions = t.evict_tier_ts(OstStorageTier::Hot, 1_500, 100);
1397        // Should evict exactly 2 objects (2×1000 ≥ 1500).
1398        assert_eq!(transitions.len(), 2);
1399    }
1400
1401    #[test]
1402    fn test_evict_cold_tier_no_op() {
1403        let mut t = tiering();
1404        t.store(make_object("fill-hot", 10_000, 0, 0)).unwrap();
1405        t.store(make_object("fill-warm", 100_000, 0, 0)).unwrap();
1406        t.store(make_object("c", 100, 0, 0)).unwrap();
1407        let transitions = t.evict_tier(OstStorageTier::Cold, 100);
1408        assert!(transitions.is_empty()); // Cold has nowhere colder.
1409    }
1410
1411    #[test]
1412    fn test_evict_tier_increments_demotion_counter() {
1413        let mut t = tiering();
1414        t.store(make_object("a", 1_000, 0, 1)).unwrap();
1415        t.store(make_object("b", 1_000, 0, 2)).unwrap();
1416        t.evict_tier_ts(OstStorageTier::Hot, 1_000, 0);
1417        assert!(t.stats().demotions > 0);
1418    }
1419
1420    // -----------------------------------------------------------------------
1421    // tier_objects() tests
1422    // -----------------------------------------------------------------------
1423
1424    #[test]
1425    fn test_tier_objects_returns_correct_set() {
1426        let mut t = tiering(); // hot=10_000, warm=100_000
1427        t.store(make_object("h1", 100, 0, 0)).unwrap();
1428        t.store(make_object("h2", 100, 0, 0)).unwrap();
1429        // Fill the rest of hot so the next object must go to warm.
1430        t.store(make_object("fill", 9_800, 0, 0)).unwrap();
1431        // hot is now exactly full; this must land in warm.
1432        t.store(make_object("w1", 100, 0, 0)).unwrap();
1433        let hot = t.tier_objects(OstStorageTier::Hot);
1434        assert!(hot.len() >= 2);
1435        let warm = t.tier_objects(OstStorageTier::Warm);
1436        assert!(!warm.is_empty());
1437    }
1438
1439    #[test]
1440    fn test_tier_objects_empty_for_unused_tier() {
1441        let t = tiering();
1442        assert!(t.tier_objects(OstStorageTier::Cold).is_empty());
1443    }
1444
1445    // -----------------------------------------------------------------------
1446    // transition_history() tests
1447    // -----------------------------------------------------------------------
1448
1449    #[test]
1450    fn test_transition_history_records_demotions() {
1451        let mut t = tiering();
1452        t.store(make_object("obj", 100, 0, 0)).unwrap();
1453        t.demote("obj", OstStorageTier::Warm, 50).unwrap();
1454        let history = t.transition_history();
1455        assert_eq!(history.len(), 1);
1456        assert_eq!(history[0].from_tier, OstStorageTier::Hot);
1457        assert_eq!(history[0].to_tier, OstStorageTier::Warm);
1458    }
1459
1460    #[test]
1461    fn test_transition_history_capped_at_500() {
1462        let mut t = tiering();
1463        for i in 0u64..600 {
1464            let id = format!("obj-{i}");
1465            t.store(make_object(&id, 50, 0, 0)).unwrap();
1466            t.demote(&id, OstStorageTier::Warm, i).unwrap();
1467        }
1468        assert_eq!(t.transition_history().len(), 500);
1469    }
1470
1471    #[test]
1472    fn test_transition_history_stores_reason() {
1473        let mut t = tiering();
1474        t.store(make_object("x", 100, 0, 0)).unwrap();
1475        t.demote("x", OstStorageTier::Cold, 1).unwrap();
1476        let history = t.transition_history();
1477        assert!(history[0].reason.contains("demotion"));
1478    }
1479
1480    #[test]
1481    fn test_transition_history_stores_timestamp() {
1482        let mut t = tiering();
1483        t.store(make_object("x", 100, 0, 0)).unwrap();
1484        t.demote("x", OstStorageTier::Cold, 12345).unwrap();
1485        let history = t.transition_history();
1486        assert_eq!(history[0].transitioned_at, 12345);
1487    }
1488
1489    #[test]
1490    fn test_transition_history_bytes_moved() {
1491        let mut t = tiering();
1492        t.store(make_object("big", 7_777, 0, 0)).unwrap();
1493        t.demote("big", OstStorageTier::Warm, 0).unwrap();
1494        let history = t.transition_history();
1495        assert_eq!(history[0].bytes_moved, 7_777);
1496    }
1497
1498    // -----------------------------------------------------------------------
1499    // stats() tests
1500    // -----------------------------------------------------------------------
1501
1502    #[test]
1503    fn test_stats_counts_per_tier() {
1504        let mut t = tiering();
1505        t.store(make_object("h1", 100, 0, 0)).unwrap();
1506        t.store(make_object("h2", 200, 0, 0)).unwrap();
1507        let s = t.stats();
1508        assert_eq!(s.hot_objects, 2);
1509        assert_eq!(s.hot_bytes, 300);
1510    }
1511
1512    #[test]
1513    fn test_stats_total_cost() {
1514        let mut t = tiering();
1515        let mut obj = make_object("c", 100, 0, 0);
1516        obj.cost_per_hour = 1.5;
1517        t.store(obj).unwrap();
1518        let s = t.stats();
1519        assert!((s.total_cost_per_hour - 1.5).abs() < 1e-9);
1520    }
1521
1522    #[test]
1523    fn test_stats_promotions_and_demotions() {
1524        let mut t = tiering(); // hot=10_000
1525                               // Fill hot completely.
1526        t.store(make_object("fill", 10_000, 0, 0)).unwrap();
1527        // "w" must land in warm.
1528        t.store(make_object("w", 100, 0, 0)).unwrap();
1529        // Evict "fill" from hot to warm, making room in hot.
1530        t.evict_tier_ts(OstStorageTier::Hot, 10_000, 0);
1531        // Now hot is empty; promote "w" from warm to hot.
1532        t.promote("w", OstStorageTier::Hot, 1).unwrap();
1533        // Demote "w" to cold.
1534        t.demote("w", OstStorageTier::Cold, 2).unwrap();
1535        let s = t.stats();
1536        assert!(s.promotions >= 1);
1537        assert!(s.demotions >= 2);
1538    }
1539
1540    #[test]
1541    fn test_stats_empty_tiering() {
1542        let t = tiering();
1543        let s = t.stats();
1544        assert_eq!(s.hot_objects, 0);
1545        assert_eq!(s.warm_objects, 0);
1546        assert_eq!(s.cold_objects, 0);
1547        assert_eq!(s.hot_bytes, 0);
1548        assert_eq!(s.promotions, 0);
1549        assert_eq!(s.demotions, 0);
1550    }
1551
1552    // -----------------------------------------------------------------------
1553    // Capacity enforcement tests
1554    // -----------------------------------------------------------------------
1555
1556    #[test]
1557    fn test_capacity_hot_never_exceeded() {
1558        let mut t = tiering(); // hot=10_000
1559        for i in 0..20 {
1560            let id = format!("cap-{i}");
1561            let _ = t.store(make_object(&id, 1_000, 0, 0));
1562        }
1563        assert!(t.used_bytes(OstStorageTier::Hot) <= 10_000);
1564    }
1565
1566    #[test]
1567    fn test_capacity_warm_never_exceeded() {
1568        let mut t = tiering(); // warm=100_000
1569        for i in 0..200 {
1570            let id = format!("w-{i}");
1571            let _ = t.store(make_object(&id, 1_000, 0, 0));
1572        }
1573        assert!(t.used_bytes(OstStorageTier::Warm) <= 100_000);
1574    }
1575
1576    #[test]
1577    fn test_available_bytes_decreases_after_store() {
1578        let mut t = tiering();
1579        let before = t.available_bytes(OstStorageTier::Hot);
1580        t.store(make_object("x", 500, 0, 0)).unwrap();
1581        assert_eq!(t.available_bytes(OstStorageTier::Hot), before - 500);
1582    }
1583
1584    #[test]
1585    fn test_available_bytes_increases_after_demote() {
1586        let mut t = tiering();
1587        t.store(make_object("x", 500, 0, 0)).unwrap();
1588        let before_hot = t.available_bytes(OstStorageTier::Hot);
1589        t.demote("x", OstStorageTier::Warm, 0).unwrap();
1590        assert_eq!(t.available_bytes(OstStorageTier::Hot), before_hot + 500);
1591    }
1592
1593    // -----------------------------------------------------------------------
1594    // Error-case tests
1595    // -----------------------------------------------------------------------
1596
1597    #[test]
1598    fn test_tiering_error_display_object_not_found() {
1599        let e = TieringError::ObjectNotFound("abc".to_string());
1600        assert!(e.to_string().contains("abc"));
1601    }
1602
1603    #[test]
1604    fn test_tiering_error_display_tier_full() {
1605        let e = TieringError::TierFull {
1606            tier: OstStorageTier::Hot,
1607            needed: 100,
1608            available: 50,
1609        };
1610        let s = e.to_string();
1611        assert!(s.contains("hot"));
1612        assert!(s.contains("100"));
1613    }
1614
1615    #[test]
1616    fn test_tiering_error_display_policy_conflict() {
1617        let e = TieringError::PolicyConflict("test msg".to_string());
1618        assert!(e.to_string().contains("test msg"));
1619    }
1620
1621    #[test]
1622    fn test_tiering_error_display_invalid_config() {
1623        let e = TieringError::InvalidConfiguration("bad".to_string());
1624        assert!(e.to_string().contains("bad"));
1625    }
1626
1627    // -----------------------------------------------------------------------
1628    // OstStorageTier helpers
1629    // -----------------------------------------------------------------------
1630
1631    #[test]
1632    fn test_tier_colder() {
1633        assert_eq!(OstStorageTier::Hot.colder(), Some(OstStorageTier::Warm));
1634        assert_eq!(OstStorageTier::Warm.colder(), Some(OstStorageTier::Cold));
1635        assert_eq!(OstStorageTier::Cold.colder(), None);
1636    }
1637
1638    #[test]
1639    fn test_tier_hotter() {
1640        assert_eq!(OstStorageTier::Cold.hotter(), Some(OstStorageTier::Warm));
1641        assert_eq!(OstStorageTier::Warm.hotter(), Some(OstStorageTier::Hot));
1642        assert_eq!(OstStorageTier::Hot.hotter(), None);
1643    }
1644
1645    #[test]
1646    fn test_tier_rank_ordering() {
1647        assert!(OstStorageTier::Hot.rank() < OstStorageTier::Warm.rank());
1648        assert!(OstStorageTier::Warm.rank() < OstStorageTier::Cold.rank());
1649    }
1650
1651    #[test]
1652    fn test_tier_name() {
1653        assert_eq!(OstStorageTier::Hot.name(), "hot");
1654        assert_eq!(OstStorageTier::Warm.name(), "warm");
1655        assert_eq!(OstStorageTier::Cold.name(), "cold");
1656    }
1657
1658    #[test]
1659    fn test_tier_display() {
1660        assert_eq!(OstStorageTier::Hot.to_string(), "hot");
1661    }
1662
1663    // -----------------------------------------------------------------------
1664    // Integration / stress tests
1665    // -----------------------------------------------------------------------
1666
1667    #[test]
1668    fn test_round_trip_store_retrieve_demote_promote() {
1669        let mut cfg = default_config();
1670        cfg.hot_capacity_bytes = 100_000;
1671        cfg.warm_capacity_bytes = 1_000_000;
1672        let mut t = ObjectStorageTiering::new(cfg);
1673        let obj = make_object("rt", 1_000, 0, 0);
1674        let tier = t.store(obj).unwrap();
1675        assert_eq!(tier, OstStorageTier::Hot);
1676        t.retrieve("rt", 500).unwrap();
1677        t.demote("rt", OstStorageTier::Warm, 1000).unwrap();
1678        let obj_ref = t.retrieve("rt", 1500).unwrap();
1679        assert_eq!(obj_ref.current_tier, OstStorageTier::Warm);
1680        t.evict_tier_ts(OstStorageTier::Hot, 1, 2000); // make room
1681        t.promote("rt", OstStorageTier::Hot, 2500).unwrap();
1682        let obj_ref2 = t.retrieve("rt", 3000).unwrap();
1683        assert_eq!(obj_ref2.current_tier, OstStorageTier::Hot);
1684    }
1685
1686    #[test]
1687    fn test_stress_many_objects_stats_consistent() {
1688        let mut cfg = default_config();
1689        cfg.hot_capacity_bytes = 50_000;
1690        cfg.warm_capacity_bytes = 500_000;
1691        let mut t = ObjectStorageTiering::new(cfg);
1692        let mut state = 12345u64;
1693        for i in 0..100 {
1694            let size = xorshift64(&mut state) % 1_000 + 100;
1695            let _ = t.store(make_object(&format!("s{i}"), size, 0, 0));
1696        }
1697        let s = t.stats();
1698        let total_objects = s.hot_objects + s.warm_objects + s.cold_objects;
1699        let total_bytes = s.hot_bytes + s.warm_bytes + s.cold_bytes;
1700        // Verify byte accounting is consistent.
1701        assert_eq!(
1702            t.used_bytes(OstStorageTier::Hot)
1703                + t.used_bytes(OstStorageTier::Warm)
1704                + t.used_bytes(OstStorageTier::Cold),
1705            total_bytes
1706        );
1707        assert_eq!(total_objects, t.objects.len());
1708    }
1709
1710    #[test]
1711    fn test_default_config_sensible_capacities() {
1712        let cfg = OstTierConfig::default();
1713        assert!(cfg.hot_capacity_bytes > 0);
1714        assert!(cfg.warm_capacity_bytes > cfg.hot_capacity_bytes);
1715        assert_eq!(cfg.cold_capacity_bytes, u64::MAX);
1716    }
1717}