Skip to main content

ipfrs_storage/
tier_migration_engine.rs

1//! StorageTierMigrator — intelligent block migration engine for storage tiers.
2//!
3//! Moves blocks between Hot, Warm, Cold, and Archive tiers based on access
4//! frequency, age, and configurable size policies. Supports dry-run mode,
5//! batched execution, and detailed migration logs.
6//!
7//! # Example
8//!
9//! ```rust
10//! use ipfrs_storage::tier_migration_engine::{
11//!     BlockMeta, MigratorConfig, StorageTierMigrator, TierPolicy,
12//!     StorageTier as TmStorageTier,
13//! };
14//!
15//! let policy = TierPolicy {
16//!     tier: TmStorageTier::Hot,
17//!     max_age_ms: Some(3_600_000),   // 1 hour
18//!     min_access_count: Some(5),
19//!     max_block_size_bytes: None,
20//!     min_block_size_bytes: None,
21//! };
22//! let config = MigratorConfig {
23//!     policies: vec![policy],
24//!     dry_run: false,
25//!     batch_size: 100,
26//!     max_migrations_per_run: 1000,
27//! };
28//! let mut migrator = StorageTierMigrator::new(config);
29//!
30//! let now_ms = 1_000_000_u64;
31//! let meta = BlockMeta {
32//!     cid: "QmTest1".to_string(),
33//!     size_bytes: 4096,
34//!     access_count: 2,
35//!     last_accessed_ms: now_ms - 10_000_000,
36//!     created_ms: now_ms - 20_000_000,
37//!     current_tier: TmStorageTier::Hot,
38//! };
39//! migrator.register_block(meta);
40//! let result = migrator.run_migration_cycle(now_ms + 5_000_000);
41//! assert!(result.actions_planned >= 1);
42//! ```
43
44use std::collections::{HashMap, VecDeque};
45
46// ---------------------------------------------------------------------------
47// StorageTier
48// ---------------------------------------------------------------------------
49
50/// Storage tier classification — Hot is fastest/most expensive; Archive is
51/// slowest/cheapest. `priority()` returns a descending-urgency value (Hot=3).
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
53pub enum StorageTier {
54    /// Hot tier: NVMe / memory — lowest latency, highest cost.
55    Hot,
56    /// Warm tier: fast SSD — moderate latency and cost.
57    Warm,
58    /// Cold tier: HDD / object store — high latency, low cost.
59    Cold,
60    /// Archive tier: tape / deep cold — very high latency, cheapest.
61    Archive,
62}
63
64impl StorageTier {
65    /// Descending priority: Hot=3 … Archive=0. Used to sort migration actions
66    /// (hottest blocks with pending demotions are processed first).
67    pub fn priority(&self) -> u8 {
68        match self {
69            StorageTier::Hot => 3,
70            StorageTier::Warm => 2,
71            StorageTier::Cold => 1,
72            StorageTier::Archive => 0,
73        }
74    }
75
76    /// Returns the next colder tier, or `None` if already at Archive.
77    pub fn next_colder(&self) -> Option<StorageTier> {
78        match self {
79            StorageTier::Hot => Some(StorageTier::Warm),
80            StorageTier::Warm => Some(StorageTier::Cold),
81            StorageTier::Cold => Some(StorageTier::Archive),
82            StorageTier::Archive => None,
83        }
84    }
85
86    /// Human-readable name.
87    pub fn name(&self) -> &'static str {
88        match self {
89            StorageTier::Hot => "hot",
90            StorageTier::Warm => "warm",
91            StorageTier::Cold => "cold",
92            StorageTier::Archive => "archive",
93        }
94    }
95}
96
97impl std::fmt::Display for StorageTier {
98    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99        f.write_str(self.name())
100    }
101}
102
103// ---------------------------------------------------------------------------
104// TierPolicy
105// ---------------------------------------------------------------------------
106
107/// Policy that governs when blocks should be demoted *out* of a given tier.
108///
109/// All specified conditions are evaluated independently: the first one that
110/// triggers causes a demotion. `None` means "no limit / don't check".
111#[derive(Debug, Clone)]
112pub struct TierPolicy {
113    /// The tier this policy applies to.
114    pub tier: StorageTier,
115    /// Maximum idle time in milliseconds. Blocks whose
116    /// `last_accessed_ms + max_age_ms < now_ms` are considered stale.
117    pub max_age_ms: Option<u64>,
118    /// Minimum access count required to stay in this tier. Blocks with fewer
119    /// accesses than this threshold are under-accessed.
120    pub min_access_count: Option<u64>,
121    /// Blocks larger than this byte count should not reside in this tier.
122    pub max_block_size_bytes: Option<u64>,
123    /// Blocks smaller than this byte count should not reside in this tier.
124    pub min_block_size_bytes: Option<u64>,
125}
126
127// ---------------------------------------------------------------------------
128// BlockMeta
129// ---------------------------------------------------------------------------
130
131/// Runtime metadata for a single content-addressed block tracked by the migrator.
132#[derive(Debug, Clone)]
133pub struct BlockMeta {
134    /// Content identifier (CID) of the block — primary key.
135    pub cid: String,
136    /// Block payload size in bytes.
137    pub size_bytes: u64,
138    /// Cumulative access count since the block was registered.
139    pub access_count: u64,
140    /// Unix timestamp in milliseconds of the most-recent access.
141    pub last_accessed_ms: u64,
142    /// Unix timestamp in milliseconds when the block was first created/registered.
143    pub created_ms: u64,
144    /// Current storage tier for the block.
145    pub current_tier: StorageTier,
146}
147
148// ---------------------------------------------------------------------------
149// MigrationReason
150// ---------------------------------------------------------------------------
151
152/// Reason code explaining why a migration was triggered.
153#[derive(Debug, Clone, Copy, PartialEq, Eq)]
154pub enum MigrationReason {
155    /// Block has not been accessed within the policy's `max_age_ms` window.
156    TooOld,
157    /// Block's cumulative access count is below `min_access_count`.
158    UnderAccessed,
159    /// Block is too large for the current tier (`max_block_size_bytes` exceeded).
160    Oversized,
161    /// Block is too small for the current tier (`min_block_size_bytes` not met).
162    Undersized,
163    /// An explicit policy change was applied — re-evaluation required.
164    PolicyChange,
165    /// Migration was requested manually (e.g. operator command).
166    Manual,
167}
168
169impl std::fmt::Display for MigrationReason {
170    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
171        let s = match self {
172            MigrationReason::TooOld => "too_old",
173            MigrationReason::UnderAccessed => "under_accessed",
174            MigrationReason::Oversized => "oversized",
175            MigrationReason::Undersized => "undersized",
176            MigrationReason::PolicyChange => "policy_change",
177            MigrationReason::Manual => "manual",
178        };
179        f.write_str(s)
180    }
181}
182
183// ---------------------------------------------------------------------------
184// MigrationAction
185// ---------------------------------------------------------------------------
186
187/// A planned or executed migration for a single block.
188#[derive(Debug, Clone)]
189pub struct MigrationAction {
190    /// CID of the block to migrate.
191    pub cid: String,
192    /// Source tier (current location before migration).
193    pub from_tier: StorageTier,
194    /// Destination tier (target location after migration).
195    pub to_tier: StorageTier,
196    /// Reason the migration was triggered.
197    pub reason: MigrationReason,
198    /// Unix timestamp in milliseconds when this action was scheduled/created.
199    pub scheduled_at: u64,
200}
201
202// ---------------------------------------------------------------------------
203// MigrationResult
204// ---------------------------------------------------------------------------
205
206/// Summary produced by [`StorageTierMigrator::execute_migrations`] or
207/// [`StorageTierMigrator::run_migration_cycle`].
208#[derive(Debug, Clone, Default)]
209pub struct MigrationResult {
210    /// Total migration actions that were planned in this run.
211    pub actions_planned: usize,
212    /// Actions that were actually executed (0 in dry-run mode).
213    pub actions_executed: usize,
214    /// Total bytes migrated across all executed actions.
215    pub bytes_migrated: u64,
216    /// CIDs for which migration failed (e.g. block not found in registry).
217    pub failed_cids: Vec<String>,
218}
219
220// ---------------------------------------------------------------------------
221// MigratorConfig
222// ---------------------------------------------------------------------------
223
224/// Configuration for [`StorageTierMigrator`].
225#[derive(Debug, Clone)]
226pub struct MigratorConfig {
227    /// Ordered list of tier policies evaluated during each migration cycle.
228    /// Policies are sorted internally by `tier.priority()` descending before
229    /// evaluation, so the order supplied here does not matter.
230    pub policies: Vec<TierPolicy>,
231    /// When `true`, migrations are planned but blocks are *not* moved.
232    /// Counters still reflect what *would* have happened.
233    pub dry_run: bool,
234    /// Number of migration actions to process per internal batch.
235    pub batch_size: usize,
236    /// Hard cap on total migrations executed in a single run.
237    pub max_migrations_per_run: usize,
238}
239
240impl Default for MigratorConfig {
241    fn default() -> Self {
242        Self {
243            policies: Vec::new(),
244            dry_run: false,
245            batch_size: 100,
246            max_migrations_per_run: 1000,
247        }
248    }
249}
250
251// ---------------------------------------------------------------------------
252// MigratorStats
253// ---------------------------------------------------------------------------
254
255/// Snapshot of migrator statistics for observability.
256#[derive(Debug, Clone)]
257pub struct MigratorStats {
258    /// Total number of blocks currently tracked.
259    pub total_blocks: usize,
260    /// Blocks currently in Hot tier.
261    pub hot_blocks: usize,
262    /// Blocks currently in Warm tier.
263    pub warm_blocks: usize,
264    /// Blocks currently in Cold tier.
265    pub cold_blocks: usize,
266    /// Blocks currently in Archive tier.
267    pub archive_blocks: usize,
268    /// All-time total migration executions.
269    pub total_migrations: u64,
270    /// All-time total bytes migrated.
271    pub total_bytes_migrated: u64,
272}
273
274// ---------------------------------------------------------------------------
275// StorageTierMigrator
276// ---------------------------------------------------------------------------
277
278/// Production-grade intelligent block migration engine.
279///
280/// Tracks blocks across four storage tiers (Hot, Warm, Cold, Archive) and
281/// demotes them according to configurable [`TierPolicy`] rules based on age,
282/// access frequency, and size constraints.
283///
284/// # Lifecycle
285///
286/// 1. Configure via [`MigratorConfig`] and create with [`StorageTierMigrator::new`].
287/// 2. Register blocks with [`register_block`](StorageTierMigrator::register_block).
288/// 3. Record access events with [`record_access`](StorageTierMigrator::record_access).
289/// 4. Trigger a migration cycle with [`run_migration_cycle`](StorageTierMigrator::run_migration_cycle).
290/// 5. Inspect results via [`migration_log`](StorageTierMigrator::migration_log) or
291///    [`migrator_stats`](StorageTierMigrator::migrator_stats).
292pub struct StorageTierMigrator {
293    /// Migrator configuration (policies, dry-run flag, batch/max sizes).
294    pub config: MigratorConfig,
295    /// Map from CID → block metadata. Central registry.
296    pub blocks: HashMap<String, BlockMeta>,
297    /// Bounded ring-buffer of recently executed (or dry-run planned) migrations.
298    pub migration_log: VecDeque<MigrationAction>,
299    /// All-time migration execution count.
300    pub total_migrations: u64,
301    /// All-time total bytes migrated.
302    pub total_bytes_migrated: u64,
303}
304
305/// Maximum number of log entries kept in the in-memory ring buffer.
306const LOG_CAPACITY: usize = 10_000;
307
308impl StorageTierMigrator {
309    // -----------------------------------------------------------------------
310    // Construction
311    // -----------------------------------------------------------------------
312
313    /// Create a new migrator from the given configuration.
314    pub fn new(config: MigratorConfig) -> Self {
315        Self {
316            config,
317            blocks: HashMap::new(),
318            migration_log: VecDeque::with_capacity(LOG_CAPACITY),
319            total_migrations: 0,
320            total_bytes_migrated: 0,
321        }
322    }
323
324    // -----------------------------------------------------------------------
325    // Block registry
326    // -----------------------------------------------------------------------
327
328    /// Register a new block or overwrite an existing entry with updated metadata.
329    pub fn register_block(&mut self, meta: BlockMeta) {
330        self.blocks.insert(meta.cid.clone(), meta);
331    }
332
333    /// Remove a block from the registry. Returns `true` if the block existed.
334    pub fn unregister_block(&mut self, cid: &str) -> bool {
335        self.blocks.remove(cid).is_some()
336    }
337
338    /// Record an access event for a block, incrementing its counter and
339    /// updating `last_accessed_ms`. Returns `true` if the block was found.
340    pub fn record_access(&mut self, cid: &str, now_ms: u64) -> bool {
341        if let Some(meta) = self.blocks.get_mut(cid) {
342            meta.access_count = meta.access_count.saturating_add(1);
343            meta.last_accessed_ms = now_ms;
344            true
345        } else {
346            false
347        }
348    }
349
350    // -----------------------------------------------------------------------
351    // Evaluation
352    // -----------------------------------------------------------------------
353
354    /// Evaluate whether a single block needs to be migrated, given the current
355    /// timestamp `now_ms`.
356    ///
357    /// Policies are checked in descending tier priority order. If a block's
358    /// current tier matches a policy and any demotion condition is satisfied,
359    /// a [`MigrationAction`] targeting the next-colder tier is returned.
360    /// Returns `None` if no migration is required.
361    pub fn evaluate_block(&self, meta: &BlockMeta, now_ms: u64) -> Option<MigrationAction> {
362        // Collect policies for the block's current tier, sorted by priority desc.
363        let mut applicable: Vec<&TierPolicy> = self
364            .config
365            .policies
366            .iter()
367            .filter(|p| p.tier == meta.current_tier)
368            .collect();
369
370        // Sort descending by priority (Hot=3 first) for deterministic ordering
371        // when multiple policies match the same tier (unlikely but supported).
372        applicable.sort_by_key(|b| std::cmp::Reverse(b.tier.priority()));
373
374        for policy in applicable {
375            if let Some(reason) = Self::check_demotion(policy, meta, now_ms) {
376                let to_tier = meta.current_tier.next_colder()?;
377                return Some(MigrationAction {
378                    cid: meta.cid.clone(),
379                    from_tier: meta.current_tier,
380                    to_tier,
381                    reason,
382                    scheduled_at: now_ms,
383                });
384            }
385        }
386        None
387    }
388
389    /// Internal: check which demotion condition fires first for a policy.
390    fn check_demotion(
391        policy: &TierPolicy,
392        meta: &BlockMeta,
393        now_ms: u64,
394    ) -> Option<MigrationReason> {
395        // Age check: block hasn't been accessed within `max_age_ms`.
396        if let Some(max_age) = policy.max_age_ms {
397            if meta.last_accessed_ms.saturating_add(max_age) < now_ms {
398                return Some(MigrationReason::TooOld);
399            }
400        }
401
402        // Access-count check: block hasn't been accessed enough.
403        if let Some(min_access) = policy.min_access_count {
404            if meta.access_count < min_access {
405                return Some(MigrationReason::UnderAccessed);
406            }
407        }
408
409        // Size upper-bound: block is too large for this tier.
410        if let Some(max_size) = policy.max_block_size_bytes {
411            if meta.size_bytes > max_size {
412                return Some(MigrationReason::Oversized);
413            }
414        }
415
416        // Size lower-bound: block is too small for this tier.
417        if let Some(min_size) = policy.min_block_size_bytes {
418            if meta.size_bytes < min_size {
419                return Some(MigrationReason::Undersized);
420            }
421        }
422
423        None
424    }
425
426    // -----------------------------------------------------------------------
427    // Planning
428    // -----------------------------------------------------------------------
429
430    /// Evaluate every tracked block and return the list of [`MigrationAction`]s
431    /// that should be executed.
432    ///
433    /// Actions are sorted by `from_tier.priority()` descending (hottest blocks
434    /// first) and capped at [`MigratorConfig::max_migrations_per_run`].
435    pub fn plan_migrations(&self, now_ms: u64) -> Vec<MigrationAction> {
436        let mut actions: Vec<MigrationAction> = self
437            .blocks
438            .values()
439            .filter_map(|meta| self.evaluate_block(meta, now_ms))
440            .collect();
441
442        // Sort by from_tier priority descending.
443        actions.sort_by_key(|b| std::cmp::Reverse(b.from_tier.priority()));
444
445        // Cap at max_migrations_per_run.
446        actions.truncate(self.config.max_migrations_per_run);
447        actions
448    }
449
450    // -----------------------------------------------------------------------
451    // Execution
452    // -----------------------------------------------------------------------
453
454    /// Execute a pre-planned list of migration actions.
455    ///
456    /// In dry-run mode: populates `actions_planned` but leaves block metadata
457    /// untouched. In live mode: blocks are processed in batches of
458    /// [`MigratorConfig::batch_size`]. CIDs that no longer exist in the registry
459    /// are recorded in `failed_cids`.
460    pub fn execute_migrations(
461        &mut self,
462        actions: Vec<MigrationAction>,
463        now_ms: u64,
464    ) -> MigrationResult {
465        let actions_planned = actions.len();
466        let mut result = MigrationResult {
467            actions_planned,
468            actions_executed: 0,
469            bytes_migrated: 0,
470            failed_cids: Vec::new(),
471        };
472
473        if self.config.dry_run {
474            // In dry-run mode append to the log but do NOT mutate block state.
475            for action in actions {
476                self.push_log(action);
477            }
478            return result;
479        }
480
481        let batch_size = self.config.batch_size.max(1);
482        for chunk in actions.chunks(batch_size) {
483            for action in chunk {
484                match self.blocks.get_mut(&action.cid) {
485                    Some(meta) => {
486                        let size = meta.size_bytes;
487                        meta.current_tier = action.to_tier;
488                        result.actions_executed += 1;
489                        result.bytes_migrated = result.bytes_migrated.saturating_add(size);
490                        self.total_migrations = self.total_migrations.saturating_add(1);
491                        self.total_bytes_migrated = self.total_bytes_migrated.saturating_add(size);
492                        // Clone action for log before borrowing issues arise.
493                        let log_entry = MigrationAction {
494                            cid: action.cid.clone(),
495                            from_tier: action.from_tier,
496                            to_tier: action.to_tier,
497                            reason: action.reason,
498                            scheduled_at: now_ms,
499                        };
500                        self.push_log(log_entry);
501                    }
502                    None => {
503                        result.failed_cids.push(action.cid.clone());
504                    }
505                }
506            }
507        }
508
509        result
510    }
511
512    /// Convenience method: plan migrations for `now_ms`, then execute them in
513    /// one atomic call. Returns a combined [`MigrationResult`].
514    pub fn run_migration_cycle(&mut self, now_ms: u64) -> MigrationResult {
515        let actions = self.plan_migrations(now_ms);
516        self.execute_migrations(actions, now_ms)
517    }
518
519    // -----------------------------------------------------------------------
520    // Queries
521    // -----------------------------------------------------------------------
522
523    /// Returns all blocks currently residing in `tier`, sorted by
524    /// `access_count` descending (most-accessed first).
525    pub fn blocks_in_tier(&self, tier: &StorageTier) -> Vec<&BlockMeta> {
526        let mut result: Vec<&BlockMeta> = self
527            .blocks
528            .values()
529            .filter(|m| &m.current_tier == tier)
530            .collect();
531        result.sort_by_key(|b| std::cmp::Reverse(b.access_count));
532        result
533    }
534
535    /// Immutable reference to the internal migration log ring-buffer.
536    pub fn migration_log(&self) -> &VecDeque<MigrationAction> {
537        &self.migration_log
538    }
539
540    /// Snapshot of current migrator statistics.
541    pub fn migrator_stats(&self) -> MigratorStats {
542        let mut hot = 0usize;
543        let mut warm = 0usize;
544        let mut cold = 0usize;
545        let mut archive = 0usize;
546
547        for meta in self.blocks.values() {
548            match meta.current_tier {
549                StorageTier::Hot => hot += 1,
550                StorageTier::Warm => warm += 1,
551                StorageTier::Cold => cold += 1,
552                StorageTier::Archive => archive += 1,
553            }
554        }
555
556        MigratorStats {
557            total_blocks: self.blocks.len(),
558            hot_blocks: hot,
559            warm_blocks: warm,
560            cold_blocks: cold,
561            archive_blocks: archive,
562            total_migrations: self.total_migrations,
563            total_bytes_migrated: self.total_bytes_migrated,
564        }
565    }
566
567    // -----------------------------------------------------------------------
568    // Internal helpers
569    // -----------------------------------------------------------------------
570
571    /// Push a log entry, evicting the oldest if the buffer is full.
572    fn push_log(&mut self, action: MigrationAction) {
573        if self.migration_log.len() >= LOG_CAPACITY {
574            self.migration_log.pop_front();
575        }
576        self.migration_log.push_back(action);
577    }
578}
579
580// ===========================================================================
581// Tests
582// ===========================================================================
583
584#[cfg(test)]
585mod tests {
586    use super::{
587        BlockMeta, MigrationReason, MigratorConfig, StorageTier, StorageTierMigrator, TierPolicy,
588    };
589
590    // -----------------------------------------------------------------------
591    // Helpers
592    // -----------------------------------------------------------------------
593
594    fn make_policy(
595        tier: StorageTier,
596        max_age_ms: Option<u64>,
597        min_access: Option<u64>,
598        max_size: Option<u64>,
599        min_size: Option<u64>,
600    ) -> TierPolicy {
601        TierPolicy {
602            tier,
603            max_age_ms,
604            min_access_count: min_access,
605            max_block_size_bytes: max_size,
606            min_block_size_bytes: min_size,
607        }
608    }
609
610    fn make_block(
611        cid: &str,
612        tier: StorageTier,
613        size: u64,
614        access_count: u64,
615        last_accessed_ms: u64,
616        created_ms: u64,
617    ) -> BlockMeta {
618        BlockMeta {
619            cid: cid.to_string(),
620            size_bytes: size,
621            access_count,
622            last_accessed_ms,
623            created_ms,
624            current_tier: tier,
625        }
626    }
627
628    fn simple_migrator() -> StorageTierMigrator {
629        let policy = make_policy(
630            StorageTier::Hot,
631            Some(3_600_000), // 1 hour
632            Some(5),
633            None,
634            None,
635        );
636        StorageTierMigrator::new(MigratorConfig {
637            policies: vec![policy],
638            ..MigratorConfig::default()
639        })
640    }
641
642    const NOW: u64 = 1_000_000_000;
643
644    // -----------------------------------------------------------------------
645    // StorageTier tests
646    // -----------------------------------------------------------------------
647
648    #[test]
649    fn test_tier_priority_ordering() {
650        assert!(StorageTier::Hot.priority() > StorageTier::Warm.priority());
651        assert!(StorageTier::Warm.priority() > StorageTier::Cold.priority());
652        assert!(StorageTier::Cold.priority() > StorageTier::Archive.priority());
653    }
654
655    #[test]
656    fn test_tier_priority_values() {
657        assert_eq!(StorageTier::Hot.priority(), 3);
658        assert_eq!(StorageTier::Warm.priority(), 2);
659        assert_eq!(StorageTier::Cold.priority(), 1);
660        assert_eq!(StorageTier::Archive.priority(), 0);
661    }
662
663    #[test]
664    fn test_tier_next_colder() {
665        assert_eq!(StorageTier::Hot.next_colder(), Some(StorageTier::Warm));
666        assert_eq!(StorageTier::Warm.next_colder(), Some(StorageTier::Cold));
667        assert_eq!(StorageTier::Cold.next_colder(), Some(StorageTier::Archive));
668        assert_eq!(StorageTier::Archive.next_colder(), None);
669    }
670
671    #[test]
672    fn test_tier_display() {
673        assert_eq!(StorageTier::Hot.to_string(), "hot");
674        assert_eq!(StorageTier::Warm.to_string(), "warm");
675        assert_eq!(StorageTier::Cold.to_string(), "cold");
676        assert_eq!(StorageTier::Archive.to_string(), "archive");
677    }
678
679    #[test]
680    fn test_tier_equality() {
681        assert_eq!(StorageTier::Hot, StorageTier::Hot);
682        assert_ne!(StorageTier::Hot, StorageTier::Cold);
683    }
684
685    // -----------------------------------------------------------------------
686    // Block registry tests
687    // -----------------------------------------------------------------------
688
689    #[test]
690    fn test_register_block() {
691        let mut m = simple_migrator();
692        let block = make_block("cid1", StorageTier::Hot, 4096, 10, NOW, NOW - 1000);
693        m.register_block(block);
694        assert_eq!(m.blocks.len(), 1);
695        assert!(m.blocks.contains_key("cid1"));
696    }
697
698    #[test]
699    fn test_register_block_overwrite() {
700        let mut m = simple_migrator();
701        m.register_block(make_block("cid1", StorageTier::Hot, 4096, 10, NOW, NOW));
702        m.register_block(make_block("cid1", StorageTier::Warm, 8192, 20, NOW, NOW));
703        assert_eq!(m.blocks.len(), 1);
704        let b = m.blocks.get("cid1").expect("block must exist");
705        assert_eq!(b.current_tier, StorageTier::Warm);
706        assert_eq!(b.size_bytes, 8192);
707    }
708
709    #[test]
710    fn test_unregister_existing_block() {
711        let mut m = simple_migrator();
712        m.register_block(make_block("cid1", StorageTier::Hot, 1024, 5, NOW, NOW));
713        assert!(m.unregister_block("cid1"));
714        assert!(m.blocks.is_empty());
715    }
716
717    #[test]
718    fn test_unregister_missing_block() {
719        let mut m = simple_migrator();
720        assert!(!m.unregister_block("nonexistent"));
721    }
722
723    // -----------------------------------------------------------------------
724    // Access recording tests
725    // -----------------------------------------------------------------------
726
727    #[test]
728    fn test_record_access_increments_count() {
729        let mut m = simple_migrator();
730        m.register_block(make_block("cid1", StorageTier::Hot, 512, 0, NOW, NOW));
731        assert!(m.record_access("cid1", NOW + 100));
732        let b = m.blocks.get("cid1").expect("block must exist");
733        assert_eq!(b.access_count, 1);
734        assert_eq!(b.last_accessed_ms, NOW + 100);
735    }
736
737    #[test]
738    fn test_record_access_multiple_times() {
739        let mut m = simple_migrator();
740        m.register_block(make_block("cid1", StorageTier::Hot, 512, 0, NOW, NOW));
741        for i in 1..=5 {
742            m.record_access("cid1", NOW + i * 100);
743        }
744        let b = m.blocks.get("cid1").expect("block must exist");
745        assert_eq!(b.access_count, 5);
746    }
747
748    #[test]
749    fn test_record_access_missing_returns_false() {
750        let mut m = simple_migrator();
751        assert!(!m.record_access("ghost", NOW));
752    }
753
754    // -----------------------------------------------------------------------
755    // evaluate_block tests
756    // -----------------------------------------------------------------------
757
758    #[test]
759    fn test_evaluate_block_no_policy_no_action() {
760        let m = StorageTierMigrator::new(MigratorConfig::default());
761        let block = make_block("c1", StorageTier::Hot, 512, 0, NOW, NOW);
762        assert!(m.evaluate_block(&block, NOW + 1_000_000).is_none());
763    }
764
765    #[test]
766    fn test_evaluate_block_too_old_triggers() {
767        let policy = make_policy(StorageTier::Hot, Some(1_000), None, None, None);
768        let m = StorageTierMigrator::new(MigratorConfig {
769            policies: vec![policy],
770            ..MigratorConfig::default()
771        });
772        // last_accessed_ms + max_age (1000) < now → stale
773        let block = make_block("c1", StorageTier::Hot, 512, 10, NOW - 2_000, NOW - 5_000);
774        let action = m.evaluate_block(&block, NOW).expect("should trigger");
775        assert_eq!(action.reason, MigrationReason::TooOld);
776        assert_eq!(action.from_tier, StorageTier::Hot);
777        assert_eq!(action.to_tier, StorageTier::Warm);
778    }
779
780    #[test]
781    fn test_evaluate_block_under_accessed_triggers() {
782        let policy = make_policy(StorageTier::Hot, None, Some(10), None, None);
783        let m = StorageTierMigrator::new(MigratorConfig {
784            policies: vec![policy],
785            ..MigratorConfig::default()
786        });
787        let block = make_block("c1", StorageTier::Hot, 512, 3, NOW, NOW);
788        let action = m.evaluate_block(&block, NOW).expect("should trigger");
789        assert_eq!(action.reason, MigrationReason::UnderAccessed);
790    }
791
792    #[test]
793    fn test_evaluate_block_oversized_triggers() {
794        let policy = make_policy(StorageTier::Hot, None, None, Some(1024), None);
795        let m = StorageTierMigrator::new(MigratorConfig {
796            policies: vec![policy],
797            ..MigratorConfig::default()
798        });
799        let block = make_block("c1", StorageTier::Hot, 2048, 100, NOW, NOW);
800        let action = m.evaluate_block(&block, NOW).expect("should trigger");
801        assert_eq!(action.reason, MigrationReason::Oversized);
802    }
803
804    #[test]
805    fn test_evaluate_block_undersized_triggers() {
806        let policy = make_policy(StorageTier::Warm, None, None, None, Some(4096));
807        let m = StorageTierMigrator::new(MigratorConfig {
808            policies: vec![policy],
809            ..MigratorConfig::default()
810        });
811        let block = make_block("c1", StorageTier::Warm, 512, 100, NOW, NOW);
812        let action = m.evaluate_block(&block, NOW).expect("should trigger");
813        assert_eq!(action.reason, MigrationReason::Undersized);
814        assert_eq!(action.to_tier, StorageTier::Cold);
815    }
816
817    #[test]
818    fn test_evaluate_block_no_trigger_when_within_policy() {
819        let policy = make_policy(StorageTier::Hot, Some(3_600_000), Some(5), None, None);
820        let m = StorageTierMigrator::new(MigratorConfig {
821            policies: vec![policy],
822            ..MigratorConfig::default()
823        });
824        // Accessed recently and often enough → no demotion.
825        let block = make_block("c1", StorageTier::Hot, 512, 10, NOW - 100, NOW - 200);
826        assert!(m.evaluate_block(&block, NOW).is_none());
827    }
828
829    #[test]
830    fn test_evaluate_block_wrong_tier_policy_skipped() {
831        // Policy is for Cold tier; block is Hot → no match → no migration.
832        let policy = make_policy(StorageTier::Cold, Some(1_000), None, None, None);
833        let m = StorageTierMigrator::new(MigratorConfig {
834            policies: vec![policy],
835            ..MigratorConfig::default()
836        });
837        let block = make_block("c1", StorageTier::Hot, 512, 0, NOW - 5_000, NOW - 5_000);
838        assert!(m.evaluate_block(&block, NOW).is_none());
839    }
840
841    #[test]
842    fn test_evaluate_archive_block_cannot_demote() {
843        // Archive has no next-colder tier, so even if a policy fires, no action
844        // is created (next_colder() returns None → evaluate_block returns None).
845        let policy = make_policy(StorageTier::Archive, Some(1_000), None, None, None);
846        let m = StorageTierMigrator::new(MigratorConfig {
847            policies: vec![policy],
848            ..MigratorConfig::default()
849        });
850        let block = make_block("c1", StorageTier::Archive, 512, 0, NOW - 5_000, NOW - 5_000);
851        assert!(m.evaluate_block(&block, NOW).is_none());
852    }
853
854    // -----------------------------------------------------------------------
855    // plan_migrations tests
856    // -----------------------------------------------------------------------
857
858    #[test]
859    fn test_plan_migrations_empty() {
860        let m = simple_migrator();
861        assert!(m.plan_migrations(NOW).is_empty());
862    }
863
864    #[test]
865    fn test_plan_migrations_detects_stale_blocks() {
866        let mut m = simple_migrator();
867        // last_accessed = NOW - 7200000 ms (2h ago), max_age = 1h → stale
868        m.register_block(make_block(
869            "c1",
870            StorageTier::Hot,
871            512,
872            10,
873            NOW - 7_200_000,
874            NOW - 7_200_000,
875        ));
876        let actions = m.plan_migrations(NOW);
877        assert_eq!(actions.len(), 1);
878        assert_eq!(actions[0].cid, "c1");
879    }
880
881    #[test]
882    fn test_plan_migrations_sorted_by_priority() {
883        let p_hot = make_policy(StorageTier::Hot, Some(1_000), None, None, None);
884        let p_warm = make_policy(StorageTier::Warm, Some(1_000), None, None, None);
885        let mut m = StorageTierMigrator::new(MigratorConfig {
886            policies: vec![p_hot, p_warm],
887            ..MigratorConfig::default()
888        });
889        m.register_block(make_block(
890            "warm1",
891            StorageTier::Warm,
892            512,
893            0,
894            NOW - 5_000,
895            NOW - 5_000,
896        ));
897        m.register_block(make_block(
898            "hot1",
899            StorageTier::Hot,
900            512,
901            0,
902            NOW - 5_000,
903            NOW - 5_000,
904        ));
905        let actions = m.plan_migrations(NOW);
906        assert_eq!(actions.len(), 2);
907        // Hot actions come first (priority=3 > priority=2)
908        assert_eq!(actions[0].from_tier, StorageTier::Hot);
909        assert_eq!(actions[1].from_tier, StorageTier::Warm);
910    }
911
912    #[test]
913    fn test_plan_migrations_respects_max_cap() {
914        let policy = make_policy(StorageTier::Hot, Some(1_000), None, None, None);
915        let mut m = StorageTierMigrator::new(MigratorConfig {
916            policies: vec![policy],
917            max_migrations_per_run: 3,
918            ..MigratorConfig::default()
919        });
920        for i in 0..10 {
921            m.register_block(make_block(
922                &format!("c{i}"),
923                StorageTier::Hot,
924                512,
925                0,
926                NOW - 5_000,
927                NOW - 5_000,
928            ));
929        }
930        let actions = m.plan_migrations(NOW);
931        assert_eq!(actions.len(), 3);
932    }
933
934    // -----------------------------------------------------------------------
935    // execute_migrations tests
936    // -----------------------------------------------------------------------
937
938    #[test]
939    fn test_execute_migrations_updates_tier() {
940        let mut m = simple_migrator();
941        m.register_block(make_block(
942            "c1",
943            StorageTier::Hot,
944            1024,
945            0,
946            NOW - 7_200_000,
947            NOW,
948        ));
949        let actions = m.plan_migrations(NOW);
950        let result = m.execute_migrations(actions, NOW);
951        assert_eq!(result.actions_executed, 1);
952        assert_eq!(result.bytes_migrated, 1024);
953        let b = m.blocks.get("c1").expect("block must exist");
954        assert_eq!(b.current_tier, StorageTier::Warm);
955    }
956
957    #[test]
958    fn test_execute_migrations_dry_run_does_not_mutate() {
959        let policy = make_policy(StorageTier::Hot, Some(1_000), None, None, None);
960        let mut m = StorageTierMigrator::new(MigratorConfig {
961            policies: vec![policy],
962            dry_run: true,
963            ..MigratorConfig::default()
964        });
965        m.register_block(make_block(
966            "c1",
967            StorageTier::Hot,
968            512,
969            0,
970            NOW - 5_000,
971            NOW - 5_000,
972        ));
973        let actions = m.plan_migrations(NOW);
974        let result = m.execute_migrations(actions, NOW);
975        // In dry-run, planned count recorded but tier unchanged.
976        assert!(result.actions_planned >= 1);
977        assert_eq!(result.actions_executed, 0);
978        let b = m.blocks.get("c1").expect("block must exist");
979        assert_eq!(b.current_tier, StorageTier::Hot);
980    }
981
982    #[test]
983    fn test_execute_migrations_missing_block_recorded_as_failed() {
984        use super::MigrationAction;
985        let mut m = simple_migrator();
986        let action = MigrationAction {
987            cid: "ghost".to_string(),
988            from_tier: StorageTier::Hot,
989            to_tier: StorageTier::Warm,
990            reason: MigrationReason::TooOld,
991            scheduled_at: NOW,
992        };
993        let result = m.execute_migrations(vec![action], NOW);
994        assert_eq!(result.failed_cids, vec!["ghost".to_string()]);
995        assert_eq!(result.actions_executed, 0);
996    }
997
998    #[test]
999    fn test_execute_migrations_batching() {
1000        let policy = make_policy(StorageTier::Hot, Some(1_000), None, None, None);
1001        let mut m = StorageTierMigrator::new(MigratorConfig {
1002            policies: vec![policy],
1003            batch_size: 2,
1004            max_migrations_per_run: 1000,
1005            ..MigratorConfig::default()
1006        });
1007        for i in 0..6 {
1008            m.register_block(make_block(
1009                &format!("c{i}"),
1010                StorageTier::Hot,
1011                256,
1012                0,
1013                NOW - 5_000,
1014                NOW - 5_000,
1015            ));
1016        }
1017        let actions = m.plan_migrations(NOW);
1018        let result = m.execute_migrations(actions, NOW);
1019        assert_eq!(result.actions_executed, 6);
1020    }
1021
1022    // -----------------------------------------------------------------------
1023    // run_migration_cycle tests
1024    // -----------------------------------------------------------------------
1025
1026    #[test]
1027    fn test_run_migration_cycle_full_flow() {
1028        let mut m = simple_migrator();
1029        m.register_block(make_block(
1030            "c1",
1031            StorageTier::Hot,
1032            2048,
1033            2,
1034            NOW - 7_200_000,
1035            NOW,
1036        ));
1037        let result = m.run_migration_cycle(NOW);
1038        assert!(result.actions_planned >= 1);
1039        assert!(result.actions_executed >= 1);
1040    }
1041
1042    #[test]
1043    fn test_run_migration_cycle_updates_counters() {
1044        let mut m = simple_migrator();
1045        m.register_block(make_block(
1046            "c1",
1047            StorageTier::Hot,
1048            2048,
1049            2,
1050            NOW - 7_200_000,
1051            NOW,
1052        ));
1053        m.run_migration_cycle(NOW);
1054        assert!(m.total_migrations >= 1);
1055        assert!(m.total_bytes_migrated >= 2048);
1056    }
1057
1058    #[test]
1059    fn test_run_migration_cycle_empty_produces_zero_result() {
1060        let mut m = simple_migrator();
1061        let result = m.run_migration_cycle(NOW);
1062        assert_eq!(result.actions_planned, 0);
1063        assert_eq!(result.actions_executed, 0);
1064        assert_eq!(result.bytes_migrated, 0);
1065    }
1066
1067    // -----------------------------------------------------------------------
1068    // blocks_in_tier tests
1069    // -----------------------------------------------------------------------
1070
1071    #[test]
1072    fn test_blocks_in_tier_empty() {
1073        let m = simple_migrator();
1074        assert!(m.blocks_in_tier(&StorageTier::Hot).is_empty());
1075    }
1076
1077    #[test]
1078    fn test_blocks_in_tier_correct_tier() {
1079        let mut m = simple_migrator();
1080        m.register_block(make_block("hot1", StorageTier::Hot, 512, 5, NOW, NOW));
1081        m.register_block(make_block("warm1", StorageTier::Warm, 512, 5, NOW, NOW));
1082        let hot = m.blocks_in_tier(&StorageTier::Hot);
1083        assert_eq!(hot.len(), 1);
1084        assert_eq!(hot[0].cid, "hot1");
1085    }
1086
1087    #[test]
1088    fn test_blocks_in_tier_sorted_by_access_count_desc() {
1089        let mut m = simple_migrator();
1090        m.register_block(make_block("c1", StorageTier::Warm, 512, 1, NOW, NOW));
1091        m.register_block(make_block("c2", StorageTier::Warm, 512, 50, NOW, NOW));
1092        m.register_block(make_block("c3", StorageTier::Warm, 512, 10, NOW, NOW));
1093        let warm = m.blocks_in_tier(&StorageTier::Warm);
1094        assert_eq!(warm.len(), 3);
1095        assert_eq!(warm[0].access_count, 50);
1096        assert_eq!(warm[1].access_count, 10);
1097        assert_eq!(warm[2].access_count, 1);
1098    }
1099
1100    // -----------------------------------------------------------------------
1101    // migration_log tests
1102    // -----------------------------------------------------------------------
1103
1104    #[test]
1105    fn test_migration_log_appended_on_execute() {
1106        let mut m = simple_migrator();
1107        m.register_block(make_block(
1108            "c1",
1109            StorageTier::Hot,
1110            512,
1111            0,
1112            NOW - 7_200_000,
1113            NOW,
1114        ));
1115        m.run_migration_cycle(NOW);
1116        assert!(!m.migration_log().is_empty());
1117        assert_eq!(m.migration_log()[0].cid, "c1");
1118    }
1119
1120    #[test]
1121    fn test_migration_log_bounded_at_capacity() {
1122        use super::LOG_CAPACITY;
1123        let policy = make_policy(StorageTier::Hot, Some(1), None, None, None);
1124        let mut m = StorageTierMigrator::new(MigratorConfig {
1125            policies: vec![policy],
1126            max_migrations_per_run: LOG_CAPACITY + 100,
1127            ..MigratorConfig::default()
1128        });
1129        // Register LOG_CAPACITY + 100 blocks, each stale.
1130        for i in 0..(LOG_CAPACITY + 100) {
1131            m.register_block(make_block(
1132                &format!("c{i}"),
1133                StorageTier::Hot,
1134                64,
1135                0,
1136                NOW - 5_000,
1137                NOW - 5_000,
1138            ));
1139        }
1140        m.run_migration_cycle(NOW);
1141        assert!(m.migration_log().len() <= LOG_CAPACITY);
1142    }
1143
1144    #[test]
1145    fn test_migration_log_dry_run_appends_but_no_exec() {
1146        let policy = make_policy(StorageTier::Hot, Some(1_000), None, None, None);
1147        let mut m = StorageTierMigrator::new(MigratorConfig {
1148            policies: vec![policy],
1149            dry_run: true,
1150            ..MigratorConfig::default()
1151        });
1152        m.register_block(make_block("c1", StorageTier::Hot, 512, 0, NOW - 5_000, NOW));
1153        let actions = m.plan_migrations(NOW);
1154        m.execute_migrations(actions, NOW);
1155        // In dry-run, log receives entries but block is not mutated.
1156        assert!(!m.migration_log().is_empty());
1157        let b = m.blocks.get("c1").expect("block must exist");
1158        assert_eq!(b.current_tier, StorageTier::Hot);
1159    }
1160
1161    // -----------------------------------------------------------------------
1162    // migrator_stats tests
1163    // -----------------------------------------------------------------------
1164
1165    #[test]
1166    fn test_migrator_stats_initial() {
1167        let m = simple_migrator();
1168        let s = m.migrator_stats();
1169        assert_eq!(s.total_blocks, 0);
1170        assert_eq!(s.total_migrations, 0);
1171        assert_eq!(s.total_bytes_migrated, 0);
1172    }
1173
1174    #[test]
1175    fn test_migrator_stats_tier_counts() {
1176        let mut m = simple_migrator();
1177        m.register_block(make_block("h1", StorageTier::Hot, 256, 0, NOW, NOW));
1178        m.register_block(make_block("h2", StorageTier::Hot, 256, 0, NOW, NOW));
1179        m.register_block(make_block("w1", StorageTier::Warm, 256, 0, NOW, NOW));
1180        m.register_block(make_block("c1", StorageTier::Cold, 256, 0, NOW, NOW));
1181        m.register_block(make_block("a1", StorageTier::Archive, 256, 0, NOW, NOW));
1182        let s = m.migrator_stats();
1183        assert_eq!(s.total_blocks, 5);
1184        assert_eq!(s.hot_blocks, 2);
1185        assert_eq!(s.warm_blocks, 1);
1186        assert_eq!(s.cold_blocks, 1);
1187        assert_eq!(s.archive_blocks, 1);
1188    }
1189
1190    #[test]
1191    fn test_migrator_stats_after_migration() {
1192        let mut m = simple_migrator();
1193        m.register_block(make_block(
1194            "c1",
1195            StorageTier::Hot,
1196            1024,
1197            0,
1198            NOW - 7_200_000,
1199            NOW,
1200        ));
1201        m.run_migration_cycle(NOW);
1202        let s = m.migrator_stats();
1203        assert!(s.total_migrations >= 1);
1204        assert!(s.total_bytes_migrated >= 1024);
1205        assert_eq!(s.hot_blocks, 0);
1206        assert_eq!(s.warm_blocks, 1);
1207    }
1208
1209    // -----------------------------------------------------------------------
1210    // Multi-policy and multi-tier cascade tests
1211    // -----------------------------------------------------------------------
1212
1213    #[test]
1214    fn test_multi_tier_cascade_two_cycles() {
1215        let p_hot = make_policy(StorageTier::Hot, Some(1_000), None, None, None);
1216        let p_warm = make_policy(StorageTier::Warm, Some(1_000), None, None, None);
1217        let mut m = StorageTierMigrator::new(MigratorConfig {
1218            policies: vec![p_hot, p_warm],
1219            ..MigratorConfig::default()
1220        });
1221        m.register_block(make_block(
1222            "c1",
1223            StorageTier::Hot,
1224            512,
1225            0,
1226            NOW - 5_000,
1227            NOW - 5_000,
1228        ));
1229        // First cycle: Hot → Warm
1230        m.run_migration_cycle(NOW);
1231        assert_eq!(m.blocks["c1"].current_tier, StorageTier::Warm);
1232        // Second cycle: Warm → Cold (last_accessed_ms is still old)
1233        m.run_migration_cycle(NOW + 10_000);
1234        assert_eq!(m.blocks["c1"].current_tier, StorageTier::Cold);
1235    }
1236
1237    #[test]
1238    fn test_age_check_boundary_exact() {
1239        // Exactly at the boundary: last_accessed + max_age == now → NOT stale.
1240        let policy = make_policy(StorageTier::Hot, Some(1_000), None, None, None);
1241        let m = StorageTierMigrator::new(MigratorConfig {
1242            policies: vec![policy],
1243            ..MigratorConfig::default()
1244        });
1245        let block = make_block("c1", StorageTier::Hot, 512, 0, NOW - 1_000, NOW - 1_000);
1246        // last_accessed_ms (NOW-1000) + max_age (1000) = NOW, which is NOT < NOW
1247        assert!(m.evaluate_block(&block, NOW).is_none());
1248    }
1249
1250    #[test]
1251    fn test_age_check_boundary_one_over() {
1252        // One ms past the boundary → stale.
1253        let policy = make_policy(StorageTier::Hot, Some(1_000), None, None, None);
1254        let m = StorageTierMigrator::new(MigratorConfig {
1255            policies: vec![policy],
1256            ..MigratorConfig::default()
1257        });
1258        let block = make_block("c1", StorageTier::Hot, 512, 0, NOW - 1_001, NOW - 1_001);
1259        assert!(m.evaluate_block(&block, NOW).is_some());
1260    }
1261
1262    #[test]
1263    fn test_reason_display() {
1264        assert_eq!(MigrationReason::TooOld.to_string(), "too_old");
1265        assert_eq!(MigrationReason::UnderAccessed.to_string(), "under_accessed");
1266        assert_eq!(MigrationReason::Oversized.to_string(), "oversized");
1267        assert_eq!(MigrationReason::Undersized.to_string(), "undersized");
1268        assert_eq!(MigrationReason::PolicyChange.to_string(), "policy_change");
1269        assert_eq!(MigrationReason::Manual.to_string(), "manual");
1270    }
1271
1272    #[test]
1273    fn test_multiple_blocks_partial_migration() {
1274        let policy = make_policy(StorageTier::Hot, Some(1_000), None, None, None);
1275        let mut m = StorageTierMigrator::new(MigratorConfig {
1276            policies: vec![policy],
1277            ..MigratorConfig::default()
1278        });
1279        // One stale, one fresh.
1280        m.register_block(make_block(
1281            "stale",
1282            StorageTier::Hot,
1283            512,
1284            0,
1285            NOW - 5_000,
1286            NOW,
1287        ));
1288        m.register_block(make_block(
1289            "fresh",
1290            StorageTier::Hot,
1291            512,
1292            0,
1293            NOW - 100,
1294            NOW,
1295        ));
1296        let result = m.run_migration_cycle(NOW);
1297        assert_eq!(result.actions_planned, 1);
1298        assert_eq!(result.actions_executed, 1);
1299        assert_eq!(m.blocks["stale"].current_tier, StorageTier::Warm);
1300        assert_eq!(m.blocks["fresh"].current_tier, StorageTier::Hot);
1301    }
1302
1303    #[test]
1304    fn test_default_config_values() {
1305        let c = MigratorConfig::default();
1306        assert!(!c.dry_run);
1307        assert_eq!(c.batch_size, 100);
1308        assert_eq!(c.max_migrations_per_run, 1000);
1309        assert!(c.policies.is_empty());
1310    }
1311
1312    #[test]
1313    fn test_bytes_migrated_accumulates_across_cycles() {
1314        let policy = make_policy(StorageTier::Hot, Some(1_000), None, None, None);
1315        let mut m = StorageTierMigrator::new(MigratorConfig {
1316            policies: vec![policy.clone()],
1317            ..MigratorConfig::default()
1318        });
1319        m.register_block(make_block(
1320            "c1",
1321            StorageTier::Hot,
1322            1024,
1323            0,
1324            NOW - 5_000,
1325            NOW,
1326        ));
1327        m.run_migration_cycle(NOW);
1328        assert_eq!(m.total_bytes_migrated, 1024);
1329    }
1330
1331    #[test]
1332    fn test_zero_batch_size_handled_gracefully() {
1333        // batch_size=0 is clamped to 1 in execute_migrations.
1334        let policy = make_policy(StorageTier::Hot, Some(1_000), None, None, None);
1335        let mut m = StorageTierMigrator::new(MigratorConfig {
1336            policies: vec![policy],
1337            batch_size: 0,
1338            ..MigratorConfig::default()
1339        });
1340        m.register_block(make_block("c1", StorageTier::Hot, 512, 0, NOW - 5_000, NOW));
1341        let result = m.run_migration_cycle(NOW);
1342        assert_eq!(result.actions_executed, 1);
1343    }
1344}