Skip to main content

ipfrs_storage/
migration_planner.rs

1//! Storage migration planner: schedules and tracks block movements between tiers.
2//!
3//! This module provides [`StorageMigrationPlanner`] which plans migrations of
4//! blocks between storage tiers (NVMe → SSD → HDD → Archive), computes cost
5//! estimates, resolves dependency ordering, and supports rollback of failed tasks.
6
7use std::collections::HashMap;
8
9// ─────────────────────────────────────────────────────────────────────────────
10// Public types
11// ─────────────────────────────────────────────────────────────────────────────
12
13/// Ordered storage tiers from fastest/most-expensive to slowest/cheapest.
14#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
15pub enum StorageTier {
16    /// NVMe — fastest and most expensive.
17    Nvme = 0,
18    /// Solid-state drive.
19    Ssd = 1,
20    /// Hard-disk drive.
21    Hdd = 2,
22    /// Long-term archival storage — slowest and cheapest.
23    Archive = 3,
24}
25
26/// Whether a migration moves a block toward a faster or slower tier.
27#[derive(Clone, Copy, Debug, PartialEq, Eq)]
28pub enum MigrationDirection {
29    /// Move to a faster (lower-numbered) tier.
30    Promote,
31    /// Move to a slower (higher-numbered) tier, or stay at the same tier.
32    Demote,
33}
34
35/// A single unit of migration work: moving one block from one tier to another.
36#[derive(Clone, Debug)]
37pub struct MigrationTask {
38    /// Unique identifier for this task.
39    pub task_id: u64,
40    /// The block being migrated.
41    pub block_id: u64,
42    /// Content identifier of the block.
43    pub cid: String,
44    /// Tier the block is currently stored on.
45    pub from_tier: StorageTier,
46    /// Tier the block should be moved to.
47    pub to_tier: StorageTier,
48    /// Whether the migration promotes or demotes the block.
49    pub direction: MigrationDirection,
50    /// Block size in bytes.
51    pub size_bytes: u64,
52    /// Estimated migration duration in milliseconds.
53    pub estimated_cost_ms: u64,
54    /// IDs of tasks that must reach [`MigrationStatus::Completed`] before this
55    /// task may start.
56    pub depends_on: Vec<u64>,
57}
58
59impl MigrationTask {
60    /// Returns `true` when the task moves the block toward a faster tier.
61    pub fn is_promotion(&self) -> bool {
62        self.direction == MigrationDirection::Promote
63    }
64}
65
66/// Lifecycle state of a [`MigrationTask`].
67#[derive(Clone, Copy, Debug, PartialEq, Eq)]
68pub enum MigrationStatus {
69    /// Waiting to be started (dependencies may or may not be resolved).
70    Pending,
71    /// Currently executing.
72    InProgress,
73    /// Finished successfully.
74    Completed,
75    /// Finished with an error.
76    Failed,
77    /// Was in [`Failed`](MigrationStatus::Failed) and has been rolled back.
78    RolledBack,
79}
80
81/// A task together with its current execution metadata.
82#[derive(Clone, Debug)]
83pub struct MigrationRecord {
84    /// The task being tracked.
85    pub task: MigrationTask,
86    /// Current lifecycle state.
87    pub status: MigrationStatus,
88    /// Logical clock value at which this task transitioned to
89    /// [`InProgress`](MigrationStatus::InProgress).
90    pub started_at_tick: Option<u64>,
91    /// Logical clock value at which this task reached a terminal state.
92    pub completed_at_tick: Option<u64>,
93}
94
95/// Aggregate counters produced by [`StorageMigrationPlanner::stats`].
96#[derive(Clone, Debug, Default)]
97pub struct PlannerStats {
98    /// Total number of tasks ever planned.
99    pub total_tasks: usize,
100    /// Tasks in [`Pending`](MigrationStatus::Pending) state.
101    pub pending: usize,
102    /// Tasks in [`InProgress`](MigrationStatus::InProgress) state.
103    pub in_progress: usize,
104    /// Tasks in [`Completed`](MigrationStatus::Completed) state.
105    pub completed: usize,
106    /// Tasks in [`Failed`](MigrationStatus::Failed) or
107    /// [`RolledBack`](MigrationStatus::RolledBack) state.
108    pub failed: usize,
109    /// Sum of `size_bytes` for every completed task.
110    pub total_bytes_migrated: u64,
111    /// Number of completed promotion tasks.
112    pub promotions: u64,
113    /// Number of completed demotion tasks.
114    pub demotions: u64,
115}
116
117// ─────────────────────────────────────────────────────────────────────────────
118// Core planner
119// ─────────────────────────────────────────────────────────────────────────────
120
121/// Plans and tracks migrations of blocks across storage tiers.
122///
123/// # Example
124///
125/// ```
126/// use ipfrs_storage::migration_planner::{StorageMigrationPlanner, StorageTier};
127///
128/// let mut planner = StorageMigrationPlanner::new();
129/// let id = planner.plan_migration(1, "bafyexample".into(), StorageTier::Nvme, StorageTier::Hdd, 4_000_000, vec![]);
130/// assert!(planner.start_task(id, 0));
131/// assert!(planner.complete_task(id, 10));
132/// ```
133pub struct StorageMigrationPlanner {
134    /// All records keyed by their `task_id`.
135    pub records: HashMap<u64, MigrationRecord>,
136    /// Counter used to assign the next unique task identifier.
137    pub next_task_id: u64,
138}
139
140impl StorageMigrationPlanner {
141    /// Creates an empty planner with no tasks.
142    pub fn new() -> Self {
143        Self {
144            records: HashMap::new(),
145            next_task_id: 0,
146        }
147    }
148
149    /// Plans a migration and returns the new task's ID.
150    ///
151    /// The direction is determined by comparing `from` and `to`:
152    /// - `from > to` → [`Promote`](MigrationDirection::Promote)
153    /// - `from <= to` → [`Demote`](MigrationDirection::Demote)
154    ///
155    /// The cost estimate uses the formula `size_bytes / 1_000_000 + 10` (ms).
156    pub fn plan_migration(
157        &mut self,
158        block_id: u64,
159        cid: String,
160        from: StorageTier,
161        to: StorageTier,
162        size_bytes: u64,
163        depends_on: Vec<u64>,
164    ) -> u64 {
165        let task_id = self.next_task_id;
166        self.next_task_id += 1;
167
168        let direction = if from > to {
169            MigrationDirection::Promote
170        } else {
171            MigrationDirection::Demote
172        };
173
174        let estimated_cost_ms = size_bytes / 1_000_000 + 10;
175
176        let task = MigrationTask {
177            task_id,
178            block_id,
179            cid,
180            from_tier: from,
181            to_tier: to,
182            direction,
183            size_bytes,
184            estimated_cost_ms,
185            depends_on,
186        };
187
188        let record = MigrationRecord {
189            task,
190            status: MigrationStatus::Pending,
191            started_at_tick: None,
192            completed_at_tick: None,
193        };
194
195        self.records.insert(task_id, record);
196        task_id
197    }
198
199    /// Transitions a task from [`Pending`](MigrationStatus::Pending) to
200    /// [`InProgress`](MigrationStatus::InProgress).
201    ///
202    /// Returns `false` if:
203    /// - the task does not exist,
204    /// - the task is not in `Pending` state, or
205    /// - any dependency is not yet [`Completed`](MigrationStatus::Completed).
206    pub fn start_task(&mut self, task_id: u64, current_tick: u64) -> bool {
207        // Collect dependency ids first to avoid holding an immutable borrow.
208        let depends_on: Vec<u64> = match self.records.get(&task_id) {
209            Some(r) if r.status == MigrationStatus::Pending => r.task.depends_on.clone(),
210            _ => return false,
211        };
212
213        // Verify every dependency has completed.
214        for dep_id in &depends_on {
215            match self.records.get(dep_id) {
216                Some(dep) if dep.status == MigrationStatus::Completed => {}
217                _ => return false,
218            }
219        }
220
221        if let Some(record) = self.records.get_mut(&task_id) {
222            record.status = MigrationStatus::InProgress;
223            record.started_at_tick = Some(current_tick);
224            true
225        } else {
226            false
227        }
228    }
229
230    /// Transitions a task from [`InProgress`](MigrationStatus::InProgress) to
231    /// [`Completed`](MigrationStatus::Completed).
232    ///
233    /// Returns `false` if the task is not found or not `InProgress`.
234    pub fn complete_task(&mut self, task_id: u64, current_tick: u64) -> bool {
235        match self.records.get_mut(&task_id) {
236            Some(r) if r.status == MigrationStatus::InProgress => {
237                r.status = MigrationStatus::Completed;
238                r.completed_at_tick = Some(current_tick);
239                true
240            }
241            _ => false,
242        }
243    }
244
245    /// Transitions a task to [`Failed`](MigrationStatus::Failed).
246    ///
247    /// Accepted from either [`Pending`](MigrationStatus::Pending) or
248    /// [`InProgress`](MigrationStatus::InProgress). Returns `false` if the task
249    /// is not found or already in a terminal state.
250    pub fn fail_task(&mut self, task_id: u64) -> bool {
251        match self.records.get_mut(&task_id) {
252            Some(r)
253                if r.status == MigrationStatus::Pending
254                    || r.status == MigrationStatus::InProgress =>
255            {
256                r.status = MigrationStatus::Failed;
257                true
258            }
259            _ => false,
260        }
261    }
262
263    /// Transitions a task from [`Failed`](MigrationStatus::Failed) to
264    /// [`RolledBack`](MigrationStatus::RolledBack).
265    ///
266    /// Returns `false` if the task is not found or not in `Failed` state.
267    pub fn rollback_task(&mut self, task_id: u64) -> bool {
268        match self.records.get_mut(&task_id) {
269            Some(r) if r.status == MigrationStatus::Failed => {
270                r.status = MigrationStatus::RolledBack;
271                true
272            }
273            _ => false,
274        }
275    }
276
277    /// Returns the IDs of all tasks that are [`Pending`](MigrationStatus::Pending)
278    /// and whose every dependency has [`Completed`](MigrationStatus::Completed).
279    ///
280    /// The list is sorted in ascending order of `task_id`.
281    pub fn ready_tasks(&self) -> Vec<u64> {
282        let mut ready: Vec<u64> = self
283            .records
284            .values()
285            .filter(|r| {
286                if r.status != MigrationStatus::Pending {
287                    return false;
288                }
289                r.task.depends_on.iter().all(|dep_id| {
290                    self.records
291                        .get(dep_id)
292                        .is_some_and(|d| d.status == MigrationStatus::Completed)
293                })
294            })
295            .map(|r| r.task.task_id)
296            .collect();
297
298        ready.sort_unstable();
299        ready
300    }
301
302    /// Returns an immutable reference to the record for `task_id`, or `None`.
303    pub fn get_record(&self, task_id: u64) -> Option<&MigrationRecord> {
304        self.records.get(&task_id)
305    }
306
307    /// Computes aggregate statistics over all tracked records.
308    pub fn stats(&self) -> PlannerStats {
309        let mut stats = PlannerStats {
310            total_tasks: self.records.len(),
311            ..Default::default()
312        };
313
314        for record in self.records.values() {
315            match record.status {
316                MigrationStatus::Pending => stats.pending += 1,
317                MigrationStatus::InProgress => stats.in_progress += 1,
318                MigrationStatus::Completed => {
319                    stats.completed += 1;
320                    stats.total_bytes_migrated += record.task.size_bytes;
321                    match record.task.direction {
322                        MigrationDirection::Promote => stats.promotions += 1,
323                        MigrationDirection::Demote => stats.demotions += 1,
324                    }
325                }
326                MigrationStatus::Failed | MigrationStatus::RolledBack => stats.failed += 1,
327            }
328        }
329
330        stats
331    }
332}
333
334impl Default for StorageMigrationPlanner {
335    fn default() -> Self {
336        Self::new()
337    }
338}
339
340// ─────────────────────────────────────────────────────────────────────────────
341// Tests
342// ─────────────────────────────────────────────────────────────────────────────
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347
348    // ── Helper ───────────────────────────────────────────────────────────────
349
350    fn make_planner() -> StorageMigrationPlanner {
351        StorageMigrationPlanner::new()
352    }
353
354    fn add_simple(
355        p: &mut StorageMigrationPlanner,
356        from: StorageTier,
357        to: StorageTier,
358        size: u64,
359    ) -> u64 {
360        p.plan_migration(1, "bafytest".into(), from, to, size, vec![])
361    }
362
363    // ── plan_migration ───────────────────────────────────────────────────────
364
365    #[test]
366    fn plan_migration_returns_sequential_ids() {
367        let mut p = make_planner();
368        let id0 = add_simple(&mut p, StorageTier::Nvme, StorageTier::Hdd, 0);
369        let id1 = add_simple(&mut p, StorageTier::Nvme, StorageTier::Hdd, 0);
370        assert_eq!(id0, 0);
371        assert_eq!(id1, 1);
372    }
373
374    #[test]
375    fn plan_migration_demote_direction() {
376        let mut p = make_planner();
377        let id = add_simple(&mut p, StorageTier::Nvme, StorageTier::Hdd, 0);
378        let record = p.get_record(id).expect("record must exist");
379        assert_eq!(record.task.direction, MigrationDirection::Demote);
380        assert!(!record.task.is_promotion());
381    }
382
383    #[test]
384    fn plan_migration_promote_direction() {
385        let mut p = make_planner();
386        let id = add_simple(&mut p, StorageTier::Archive, StorageTier::Ssd, 0);
387        let record = p.get_record(id).expect("record must exist");
388        assert_eq!(record.task.direction, MigrationDirection::Promote);
389        assert!(record.task.is_promotion());
390    }
391
392    #[test]
393    fn plan_migration_same_tier_is_demote() {
394        let mut p = make_planner();
395        let id = add_simple(&mut p, StorageTier::Ssd, StorageTier::Ssd, 0);
396        let record = p.get_record(id).expect("record must exist");
397        assert_eq!(record.task.direction, MigrationDirection::Demote);
398    }
399
400    #[test]
401    fn plan_migration_cost_estimate_small() {
402        let mut p = make_planner();
403        // 500_000 bytes → 500_000 / 1_000_000 = 0, + 10 = 10
404        let id = add_simple(&mut p, StorageTier::Nvme, StorageTier::Archive, 500_000);
405        let record = p.get_record(id).unwrap();
406        assert_eq!(record.task.estimated_cost_ms, 10);
407    }
408
409    #[test]
410    fn plan_migration_cost_estimate_large() {
411        let mut p = make_planner();
412        // 5_000_000 bytes → 5 + 10 = 15
413        let id = add_simple(&mut p, StorageTier::Ssd, StorageTier::Hdd, 5_000_000);
414        let record = p.get_record(id).unwrap();
415        assert_eq!(record.task.estimated_cost_ms, 15);
416    }
417
418    #[test]
419    fn plan_migration_status_is_pending() {
420        let mut p = make_planner();
421        let id = add_simple(&mut p, StorageTier::Nvme, StorageTier::Hdd, 0);
422        let record = p.get_record(id).unwrap();
423        assert_eq!(record.status, MigrationStatus::Pending);
424        assert!(record.started_at_tick.is_none());
425        assert!(record.completed_at_tick.is_none());
426    }
427
428    #[test]
429    fn plan_migration_stores_block_fields() {
430        let mut p = make_planner();
431        let id = p.plan_migration(
432            42,
433            "bafycid".into(),
434            StorageTier::Hdd,
435            StorageTier::Nvme,
436            1_000,
437            vec![],
438        );
439        let record = p.get_record(id).unwrap();
440        assert_eq!(record.task.block_id, 42);
441        assert_eq!(record.task.cid, "bafycid");
442        assert_eq!(record.task.from_tier, StorageTier::Hdd);
443        assert_eq!(record.task.to_tier, StorageTier::Nvme);
444        assert_eq!(record.task.size_bytes, 1_000);
445    }
446
447    // ── start_task ───────────────────────────────────────────────────────────
448
449    #[test]
450    fn start_task_sets_in_progress() {
451        let mut p = make_planner();
452        let id = add_simple(&mut p, StorageTier::Ssd, StorageTier::Hdd, 0);
453        assert!(p.start_task(id, 5));
454        let record = p.get_record(id).unwrap();
455        assert_eq!(record.status, MigrationStatus::InProgress);
456        assert_eq!(record.started_at_tick, Some(5));
457    }
458
459    #[test]
460    fn start_task_unknown_id_returns_false() {
461        let mut p = make_planner();
462        assert!(!p.start_task(999, 0));
463    }
464
465    #[test]
466    fn start_task_already_in_progress_returns_false() {
467        let mut p = make_planner();
468        let id = add_simple(&mut p, StorageTier::Ssd, StorageTier::Hdd, 0);
469        assert!(p.start_task(id, 1));
470        assert!(!p.start_task(id, 2));
471    }
472
473    #[test]
474    fn start_task_blocked_by_pending_dependency() {
475        let mut p = make_planner();
476        let dep = add_simple(&mut p, StorageTier::Nvme, StorageTier::Ssd, 0);
477        let child = p.plan_migration(
478            2,
479            "baf".into(),
480            StorageTier::Ssd,
481            StorageTier::Hdd,
482            0,
483            vec![dep],
484        );
485        assert!(!p.start_task(child, 0));
486    }
487
488    #[test]
489    fn start_task_blocked_by_in_progress_dependency() {
490        let mut p = make_planner();
491        let dep = add_simple(&mut p, StorageTier::Nvme, StorageTier::Ssd, 0);
492        let child = p.plan_migration(
493            2,
494            "baf".into(),
495            StorageTier::Ssd,
496            StorageTier::Hdd,
497            0,
498            vec![dep],
499        );
500        p.start_task(dep, 0);
501        assert!(!p.start_task(child, 1));
502    }
503
504    #[test]
505    fn start_task_unblocked_when_dependency_completes() {
506        let mut p = make_planner();
507        let dep = add_simple(&mut p, StorageTier::Nvme, StorageTier::Ssd, 0);
508        let child = p.plan_migration(
509            2,
510            "baf".into(),
511            StorageTier::Ssd,
512            StorageTier::Hdd,
513            0,
514            vec![dep],
515        );
516        p.start_task(dep, 0);
517        p.complete_task(dep, 1);
518        assert!(p.start_task(child, 2));
519    }
520
521    // ── complete_task ────────────────────────────────────────────────────────
522
523    #[test]
524    fn complete_task_sets_completed() {
525        let mut p = make_planner();
526        let id = add_simple(&mut p, StorageTier::Ssd, StorageTier::Archive, 0);
527        p.start_task(id, 0);
528        assert!(p.complete_task(id, 10));
529        let record = p.get_record(id).unwrap();
530        assert_eq!(record.status, MigrationStatus::Completed);
531        assert_eq!(record.completed_at_tick, Some(10));
532    }
533
534    #[test]
535    fn complete_task_not_in_progress_returns_false() {
536        let mut p = make_planner();
537        let id = add_simple(&mut p, StorageTier::Ssd, StorageTier::Archive, 0);
538        assert!(!p.complete_task(id, 1));
539    }
540
541    #[test]
542    fn complete_task_unknown_id_returns_false() {
543        let mut p = make_planner();
544        assert!(!p.complete_task(999, 0));
545    }
546
547    // ── fail_task ────────────────────────────────────────────────────────────
548
549    #[test]
550    fn fail_task_from_pending() {
551        let mut p = make_planner();
552        let id = add_simple(&mut p, StorageTier::Nvme, StorageTier::Hdd, 0);
553        assert!(p.fail_task(id));
554        assert_eq!(p.get_record(id).unwrap().status, MigrationStatus::Failed);
555    }
556
557    #[test]
558    fn fail_task_from_in_progress() {
559        let mut p = make_planner();
560        let id = add_simple(&mut p, StorageTier::Nvme, StorageTier::Hdd, 0);
561        p.start_task(id, 0);
562        assert!(p.fail_task(id));
563        assert_eq!(p.get_record(id).unwrap().status, MigrationStatus::Failed);
564    }
565
566    #[test]
567    fn fail_task_from_completed_returns_false() {
568        let mut p = make_planner();
569        let id = add_simple(&mut p, StorageTier::Nvme, StorageTier::Hdd, 0);
570        p.start_task(id, 0);
571        p.complete_task(id, 1);
572        assert!(!p.fail_task(id));
573    }
574
575    #[test]
576    fn fail_task_unknown_id_returns_false() {
577        let mut p = make_planner();
578        assert!(!p.fail_task(999));
579    }
580
581    // ── rollback_task ────────────────────────────────────────────────────────
582
583    #[test]
584    fn rollback_task_from_failed_succeeds() {
585        let mut p = make_planner();
586        let id = add_simple(&mut p, StorageTier::Nvme, StorageTier::Hdd, 0);
587        p.fail_task(id);
588        assert!(p.rollback_task(id));
589        assert_eq!(
590            p.get_record(id).unwrap().status,
591            MigrationStatus::RolledBack
592        );
593    }
594
595    #[test]
596    fn rollback_task_from_pending_returns_false() {
597        let mut p = make_planner();
598        let id = add_simple(&mut p, StorageTier::Nvme, StorageTier::Hdd, 0);
599        assert!(!p.rollback_task(id));
600    }
601
602    #[test]
603    fn rollback_task_from_completed_returns_false() {
604        let mut p = make_planner();
605        let id = add_simple(&mut p, StorageTier::Nvme, StorageTier::Hdd, 0);
606        p.start_task(id, 0);
607        p.complete_task(id, 1);
608        assert!(!p.rollback_task(id));
609    }
610
611    #[test]
612    fn rollback_task_idempotency_returns_false() {
613        // After RolledBack, a second rollback attempt must fail.
614        let mut p = make_planner();
615        let id = add_simple(&mut p, StorageTier::Nvme, StorageTier::Hdd, 0);
616        p.fail_task(id);
617        p.rollback_task(id);
618        assert!(!p.rollback_task(id));
619    }
620
621    // ── ready_tasks ──────────────────────────────────────────────────────────
622
623    #[test]
624    fn ready_tasks_no_deps_all_pending() {
625        let mut p = make_planner();
626        let id0 = add_simple(&mut p, StorageTier::Nvme, StorageTier::Hdd, 0);
627        let id1 = add_simple(&mut p, StorageTier::Ssd, StorageTier::Archive, 0);
628        let ready = p.ready_tasks();
629        assert_eq!(ready, vec![id0, id1]);
630    }
631
632    #[test]
633    fn ready_tasks_sorted_ascending() {
634        let mut p = make_planner();
635        for _ in 0..5 {
636            add_simple(&mut p, StorageTier::Nvme, StorageTier::Hdd, 0);
637        }
638        let ready = p.ready_tasks();
639        let mut sorted = ready.clone();
640        sorted.sort_unstable();
641        assert_eq!(ready, sorted);
642    }
643
644    #[test]
645    fn ready_tasks_excludes_in_progress() {
646        let mut p = make_planner();
647        let id0 = add_simple(&mut p, StorageTier::Nvme, StorageTier::Hdd, 0);
648        let id1 = add_simple(&mut p, StorageTier::Ssd, StorageTier::Archive, 0);
649        p.start_task(id0, 0);
650        let ready = p.ready_tasks();
651        assert_eq!(ready, vec![id1]);
652    }
653
654    #[test]
655    fn ready_tasks_excludes_completed() {
656        let mut p = make_planner();
657        let id0 = add_simple(&mut p, StorageTier::Nvme, StorageTier::Hdd, 0);
658        p.start_task(id0, 0);
659        p.complete_task(id0, 1);
660        assert!(p.ready_tasks().is_empty());
661    }
662
663    #[test]
664    fn ready_tasks_with_dependency_chain() {
665        let mut p = make_planner();
666        let a = add_simple(&mut p, StorageTier::Nvme, StorageTier::Ssd, 0);
667        let b = p.plan_migration(
668            2,
669            "b".into(),
670            StorageTier::Ssd,
671            StorageTier::Hdd,
672            0,
673            vec![a],
674        );
675        let c = p.plan_migration(
676            3,
677            "c".into(),
678            StorageTier::Hdd,
679            StorageTier::Archive,
680            0,
681            vec![b],
682        );
683
684        // Only `a` is ready initially.
685        assert_eq!(p.ready_tasks(), vec![a]);
686
687        p.start_task(a, 0);
688        p.complete_task(a, 1);
689        // Now `b` is ready.
690        assert_eq!(p.ready_tasks(), vec![b]);
691
692        p.start_task(b, 2);
693        p.complete_task(b, 3);
694        // Now `c` is ready.
695        assert_eq!(p.ready_tasks(), vec![c]);
696    }
697
698    // ── stats ────────────────────────────────────────────────────────────────
699
700    #[test]
701    fn stats_counts_by_status() {
702        let mut p = make_planner();
703        let a = add_simple(&mut p, StorageTier::Nvme, StorageTier::Hdd, 1_000);
704        let b = add_simple(&mut p, StorageTier::Ssd, StorageTier::Archive, 2_000);
705        let c = add_simple(&mut p, StorageTier::Hdd, StorageTier::Nvme, 3_000);
706        let d = add_simple(&mut p, StorageTier::Archive, StorageTier::Ssd, 500);
707
708        p.start_task(a, 0);
709        p.complete_task(a, 1);
710        p.start_task(b, 2);
711        p.fail_task(b);
712        // c stays Pending; d stays Pending
713
714        let s = p.stats();
715        assert_eq!(s.total_tasks, 4);
716        assert_eq!(s.completed, 1);
717        assert_eq!(s.failed, 1);
718        assert_eq!(s.pending, 2);
719        assert_eq!(s.in_progress, 0);
720        let _ = c;
721        let _ = d;
722    }
723
724    #[test]
725    fn stats_total_bytes_migrated_accumulates() {
726        let mut p = make_planner();
727        let a = add_simple(&mut p, StorageTier::Nvme, StorageTier::Hdd, 1_000_000);
728        let b = add_simple(&mut p, StorageTier::Ssd, StorageTier::Archive, 2_000_000);
729
730        p.start_task(a, 0);
731        p.complete_task(a, 1);
732        p.start_task(b, 2);
733        p.complete_task(b, 3);
734
735        assert_eq!(p.stats().total_bytes_migrated, 3_000_000);
736    }
737
738    #[test]
739    fn stats_bytes_not_counted_for_failed() {
740        let mut p = make_planner();
741        let a = add_simple(&mut p, StorageTier::Nvme, StorageTier::Hdd, 5_000_000);
742        p.start_task(a, 0);
743        p.fail_task(a);
744
745        assert_eq!(p.stats().total_bytes_migrated, 0);
746    }
747
748    #[test]
749    fn stats_promotions_and_demotions() {
750        let mut p = make_planner();
751        // Demote
752        let d0 = add_simple(&mut p, StorageTier::Nvme, StorageTier::Hdd, 0);
753        let d1 = add_simple(&mut p, StorageTier::Ssd, StorageTier::Archive, 0);
754        // Promote
755        let p0 = add_simple(&mut p, StorageTier::Hdd, StorageTier::Ssd, 0);
756
757        for id in [d0, d1, p0] {
758            p.start_task(id, 0);
759            p.complete_task(id, 1);
760        }
761
762        let s = p.stats();
763        assert_eq!(s.demotions, 2);
764        assert_eq!(s.promotions, 1);
765    }
766
767    #[test]
768    fn stats_rolledback_counted_as_failed() {
769        let mut p = make_planner();
770        let id = add_simple(&mut p, StorageTier::Nvme, StorageTier::Hdd, 0);
771        p.fail_task(id);
772        p.rollback_task(id);
773
774        let s = p.stats();
775        assert_eq!(s.failed, 1);
776        assert_eq!(s.completed, 0);
777    }
778
779    #[test]
780    fn stats_empty_planner() {
781        let p = make_planner();
782        let s = p.stats();
783        assert_eq!(s.total_tasks, 0);
784        assert_eq!(s.pending, 0);
785        assert_eq!(s.total_bytes_migrated, 0);
786    }
787
788    // ── get_record ───────────────────────────────────────────────────────────
789
790    #[test]
791    fn get_record_missing_returns_none() {
792        let p = make_planner();
793        assert!(p.get_record(0).is_none());
794    }
795
796    // ── edge cases ───────────────────────────────────────────────────────────
797
798    #[test]
799    fn multiple_deps_all_must_complete() {
800        let mut p = make_planner();
801        let a = add_simple(&mut p, StorageTier::Nvme, StorageTier::Ssd, 0);
802        let b = add_simple(&mut p, StorageTier::Nvme, StorageTier::Hdd, 0);
803        let c = p.plan_migration(
804            10,
805            "c".into(),
806            StorageTier::Ssd,
807            StorageTier::Archive,
808            0,
809            vec![a, b],
810        );
811
812        // Both a and b pending: c not ready.
813        assert!(!p.ready_tasks().contains(&c));
814
815        p.start_task(a, 0);
816        p.complete_task(a, 1);
817        // Only a done: c still not ready.
818        assert!(!p.ready_tasks().contains(&c));
819
820        p.start_task(b, 2);
821        p.complete_task(b, 3);
822        // Both done: c ready.
823        assert!(p.ready_tasks().contains(&c));
824    }
825
826    #[test]
827    fn default_impl_is_empty() {
828        let p = StorageMigrationPlanner::default();
829        assert_eq!(p.stats().total_tasks, 0);
830    }
831}