Skip to main content

ipfrs_storage/
tier_balancer.rs

1//! StorageTierBalancer — monitors utilization across storage tiers and generates
2//! rebalancing plans that move blocks to meet target utilization ratios.
3//!
4//! The balancer operates over four tier kinds (NVMe → SSD → HDD → Archive),
5//! each assigned a capacity, current usage, and a desired utilization ratio.
6//! When a tier is over its target the balancer selects blocks from that tier
7//! and schedules them for migration to the most-available under-target tier.
8//!
9//! # Example
10//!
11//! ```rust
12//! use ipfrs_storage::tier_balancer::{
13//!     StorageTierBalancer, TierKind, TierStatus,
14//! };
15//!
16//! let mut balancer = StorageTierBalancer::new();
17//!
18//! balancer.add_tier(TierStatus {
19//!     kind: TierKind::Nvme,
20//!     capacity_bytes: 1_000,
21//!     used_bytes: 900,
22//!     target_ratio: 0.7,
23//! });
24//! balancer.add_tier(TierStatus {
25//!     kind: TierKind::Ssd,
26//!     capacity_bytes: 10_000,
27//!     used_bytes: 1_000,
28//!     target_ratio: 0.8,
29//! });
30//!
31//! let candidates = vec![
32//!     ("bafy1".to_string(), 200_u64, TierKind::Nvme),
33//! ];
34//! let tasks = balancer.plan_rebalance(candidates);
35//! assert_eq!(tasks.len(), 1);
36//! assert_eq!(tasks[0].from_tier, TierKind::Nvme);
37//! assert_eq!(tasks[0].to_tier, TierKind::Ssd);
38//! ```
39
40use std::collections::HashMap;
41
42// ---------------------------------------------------------------------------
43// TierKind
44// ---------------------------------------------------------------------------
45
46/// The four storage tier kinds ordered from fastest/most-expensive to
47/// slowest/cheapest.
48#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
49pub enum TierKind {
50    /// NVMe — fastest, most expensive.
51    Nvme,
52    /// Solid-state drive.
53    Ssd,
54    /// Hard-disk drive.
55    Hdd,
56    /// Archive (tape / deep cold storage) — slowest, cheapest.
57    Archive,
58}
59
60// ---------------------------------------------------------------------------
61// TierStatus
62// ---------------------------------------------------------------------------
63
64/// Live utilization snapshot for a single storage tier.
65#[derive(Debug, Clone)]
66pub struct TierStatus {
67    /// Which tier this status belongs to.
68    pub kind: TierKind,
69    /// Total capacity of the tier in bytes.
70    pub capacity_bytes: u64,
71    /// Number of bytes currently in use.
72    pub used_bytes: u64,
73    /// Desired utilization ratio in `[0.0, 1.0]`.
74    pub target_ratio: f64,
75}
76
77impl TierStatus {
78    /// Returns the current utilization ratio (`used / capacity`).
79    /// Returns `0.0` when `capacity_bytes` is zero.
80    pub fn utilization(&self) -> f64 {
81        if self.capacity_bytes == 0 {
82            return 0.0;
83        }
84        self.used_bytes as f64 / self.capacity_bytes as f64
85    }
86
87    /// Returns the number of bytes still available (`capacity - used`),
88    /// saturating at zero.
89    pub fn free_bytes(&self) -> u64 {
90        self.capacity_bytes.saturating_sub(self.used_bytes)
91    }
92
93    /// Returns `true` when the tier is above its target utilization ratio.
94    pub fn is_over_target(&self) -> bool {
95        self.utilization() > self.target_ratio
96    }
97
98    /// Returns how many bytes exceed the target usage level.
99    ///
100    /// When the tier is at or below its target the result is `0`.
101    pub fn excess_bytes(&self) -> u64 {
102        if !self.is_over_target() {
103            return 0;
104        }
105        let target_used = (self.capacity_bytes as f64 * self.target_ratio) as u64;
106        self.used_bytes.saturating_sub(target_used)
107    }
108}
109
110// ---------------------------------------------------------------------------
111// MoveTask
112// ---------------------------------------------------------------------------
113
114/// A scheduled request to migrate one block from one tier to another.
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub struct MoveTask {
117    /// Unique identifier assigned by the balancer.
118    pub task_id: u64,
119    /// Content identifier of the block to be moved.
120    pub cid: String,
121    /// Size of the block in bytes.
122    pub size_bytes: u64,
123    /// Source tier.
124    pub from_tier: TierKind,
125    /// Destination tier.
126    pub to_tier: TierKind,
127    /// Scheduling priority — higher value means the task should run first.
128    pub priority: u32,
129}
130
131// ---------------------------------------------------------------------------
132// BalancerStats
133// ---------------------------------------------------------------------------
134
135/// Aggregate statistics about the current state of the balancer.
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct BalancerStats {
138    /// Total number of tiers registered with the balancer.
139    pub total_tiers: usize,
140    /// Number of tiers that are currently above their target utilization.
141    pub over_target_tiers: usize,
142    /// Number of move tasks waiting in the pending queue.
143    pub total_move_tasks: usize,
144    /// Sum of `size_bytes` across all pending move tasks.
145    pub total_bytes_to_move: u64,
146}
147
148// ---------------------------------------------------------------------------
149// StorageTierBalancer
150// ---------------------------------------------------------------------------
151
152/// Balances data across storage tiers by monitoring utilization and generating
153/// rebalancing plans.
154///
155/// Rebalancing proceeds tier-by-tier: for each over-target tier the balancer
156/// selects candidate blocks (in the order supplied by the caller) until the
157/// estimated excess is covered, creating a [`MoveTask`] for each block.  The
158/// destination is always the under-target tier with the most free bytes.
159pub struct StorageTierBalancer {
160    /// Registered tiers indexed by [`TierKind`].
161    pub tiers: HashMap<TierKind, TierStatus>,
162    /// Outstanding move tasks, sorted by `priority` descending (highest first).
163    pub pending_tasks: Vec<MoveTask>,
164    /// Monotonically increasing counter used to generate unique task IDs.
165    pub next_task_id: u64,
166    /// Total number of tasks that have been completed via [`Self::complete_task`].
167    pub total_completed_tasks: u64,
168}
169
170impl StorageTierBalancer {
171    /// Creates a new, empty balancer.
172    pub fn new() -> Self {
173        Self {
174            tiers: HashMap::new(),
175            pending_tasks: Vec::new(),
176            next_task_id: 1,
177            total_completed_tasks: 0,
178        }
179    }
180
181    /// Registers a tier with the balancer.  Overwrites any existing entry for
182    /// the same [`TierKind`].
183    pub fn add_tier(&mut self, status: TierStatus) {
184        self.tiers.insert(status.kind, status);
185    }
186
187    /// Updates the `used_bytes` counter for an existing tier.
188    ///
189    /// If the tier has not been registered the call is a no-op.
190    pub fn update_usage(&mut self, kind: TierKind, used_bytes: u64) {
191        if let Some(tier) = self.tiers.get_mut(&kind) {
192            tier.used_bytes = used_bytes;
193        }
194    }
195
196    /// Plans a rebalancing run given a list of candidate blocks.
197    ///
198    /// # Parameters
199    ///
200    /// * `candidates` — `(cid, size_bytes, current_tier)` tuples describing
201    ///   blocks eligible for migration.
202    ///
203    /// # Returns
204    ///
205    /// Shared references to the newly created [`MoveTask`]s in their sorted
206    /// (priority-descending) order within [`Self::pending_tasks`].
207    pub fn plan_rebalance(&mut self, candidates: Vec<(String, u64, TierKind)>) -> Vec<&MoveTask> {
208        // Snapshot which tiers are over target and by how much.
209        // We also maintain a *virtual* free-bytes map so that as we schedule
210        // moves we account for the bytes we are about to add to the destination.
211        let mut virtual_free: HashMap<TierKind, u64> = self
212            .tiers
213            .iter()
214            .map(|(k, v)| (*k, v.free_bytes()))
215            .collect();
216
217        // Compute per-tier excess at the start of the plan (static snapshot).
218        let over_target_kinds: Vec<TierKind> = {
219            let mut ks: Vec<TierKind> = self
220                .tiers
221                .values()
222                .filter(|t| t.is_over_target())
223                .map(|t| t.kind)
224                .collect();
225            ks.sort(); // deterministic order (NVMe → Archive)
226            ks
227        };
228
229        let mut new_tasks: Vec<MoveTask> = Vec::new();
230
231        for src_kind in over_target_kinds {
232            let excess = match self.tiers.get(&src_kind) {
233                Some(t) => t.excess_bytes(),
234                None => continue,
235            };
236            if excess == 0 {
237                continue;
238            }
239
240            let mut remaining = excess;
241
242            // Walk candidates that live in this tier.
243            for (cid, size, tier) in &candidates {
244                if *tier != src_kind {
245                    continue;
246                }
247                if remaining == 0 {
248                    break;
249                }
250
251                // Pick the under-target destination with the most virtual free bytes,
252                // excluding the source tier itself.
253                let dest_kind = self
254                    .tiers
255                    .values()
256                    .filter(|t| t.kind != src_kind && !t.is_over_target())
257                    .max_by(|a, b| {
258                        let fa = virtual_free.get(&a.kind).copied().unwrap_or(0);
259                        let fb = virtual_free.get(&b.kind).copied().unwrap_or(0);
260                        fa.cmp(&fb)
261                    })
262                    .map(|t| t.kind);
263
264                let dest_kind = match dest_kind {
265                    Some(d) => d,
266                    None => break, // no room anywhere — skip rest of this source tier
267                };
268
269                let priority = move_priority(src_kind, dest_kind);
270                let task_id = self.next_task_id;
271                self.next_task_id += 1;
272
273                // Update virtual free bytes for destination.
274                let dest_free = virtual_free.entry(dest_kind).or_insert(0);
275                *dest_free = dest_free.saturating_sub(*size);
276
277                remaining = remaining.saturating_sub(*size);
278
279                new_tasks.push(MoveTask {
280                    task_id,
281                    cid: cid.clone(),
282                    size_bytes: *size,
283                    from_tier: src_kind,
284                    to_tier: dest_kind,
285                    priority,
286                });
287            }
288        }
289
290        if new_tasks.is_empty() {
291            return Vec::new();
292        }
293
294        // Record the task IDs we are about to add so we can return references.
295        let new_ids: Vec<u64> = new_tasks.iter().map(|t| t.task_id).collect();
296
297        // Merge into pending_tasks and re-sort by priority descending.
298        self.pending_tasks.extend(new_tasks);
299        self.pending_tasks
300            .sort_by_key(|t| std::cmp::Reverse(t.priority));
301
302        // Return references to the newly added tasks (by task_id).
303        self.pending_tasks
304            .iter()
305            .filter(|t| new_ids.contains(&t.task_id))
306            .collect()
307    }
308
309    /// Marks a task as completed and removes it from the pending queue.
310    ///
311    /// Returns `true` when the task was found and removed, `false` otherwise.
312    pub fn complete_task(&mut self, task_id: u64) -> bool {
313        if let Some(pos) = self.pending_tasks.iter().position(|t| t.task_id == task_id) {
314            self.pending_tasks.remove(pos);
315            self.total_completed_tasks += 1;
316            true
317        } else {
318            false
319        }
320    }
321
322    /// Returns a reference to the [`TierStatus`] for the given kind, if any.
323    pub fn tier_status(&self, kind: TierKind) -> Option<&TierStatus> {
324        self.tiers.get(&kind)
325    }
326
327    /// Returns aggregate statistics about the balancer's current state.
328    pub fn stats(&self) -> BalancerStats {
329        let over_target_tiers = self.tiers.values().filter(|t| t.is_over_target()).count();
330        let total_bytes_to_move = self.pending_tasks.iter().map(|t| t.size_bytes).sum();
331        BalancerStats {
332            total_tiers: self.tiers.len(),
333            over_target_tiers,
334            total_move_tasks: self.pending_tasks.len(),
335            total_bytes_to_move,
336        }
337    }
338}
339
340impl Default for StorageTierBalancer {
341    fn default() -> Self {
342        Self::new()
343    }
344}
345
346// ---------------------------------------------------------------------------
347// Helpers
348// ---------------------------------------------------------------------------
349
350/// Returns the scheduling priority for a move from `src` to `dst`.
351///
352/// Priority rules (higher = schedule first):
353/// - NVMe → SSD : 10
354/// - SSD  → HDD : 5
355/// - HDD  → Archive : 1
356/// - anything else : 1 (treat as lowest)
357fn move_priority(src: TierKind, dst: TierKind) -> u32 {
358    match (src, dst) {
359        (TierKind::Nvme, TierKind::Ssd) => 10,
360        (TierKind::Ssd, TierKind::Hdd) => 5,
361        (TierKind::Hdd, TierKind::Archive) => 1,
362        _ => 1,
363    }
364}
365
366// ---------------------------------------------------------------------------
367// Tests
368// ---------------------------------------------------------------------------
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373
374    fn nvme_status(capacity: u64, used: u64, target: f64) -> TierStatus {
375        TierStatus {
376            kind: TierKind::Nvme,
377            capacity_bytes: capacity,
378            used_bytes: used,
379            target_ratio: target,
380        }
381    }
382
383    fn ssd_status(capacity: u64, used: u64, target: f64) -> TierStatus {
384        TierStatus {
385            kind: TierKind::Ssd,
386            capacity_bytes: capacity,
387            used_bytes: used,
388            target_ratio: target,
389        }
390    }
391
392    fn hdd_status(capacity: u64, used: u64, target: f64) -> TierStatus {
393        TierStatus {
394            kind: TierKind::Hdd,
395            capacity_bytes: capacity,
396            used_bytes: used,
397            target_ratio: target,
398        }
399    }
400
401    fn archive_status(capacity: u64, used: u64, target: f64) -> TierStatus {
402        TierStatus {
403            kind: TierKind::Archive,
404            capacity_bytes: capacity,
405            used_bytes: used,
406            target_ratio: target,
407        }
408    }
409
410    // -------------------------------------------------------------------------
411    // 1. new() starts empty
412    // -------------------------------------------------------------------------
413    #[test]
414    fn test_new_starts_empty() {
415        let b = StorageTierBalancer::new();
416        assert!(b.tiers.is_empty());
417        assert!(b.pending_tasks.is_empty());
418        assert_eq!(b.next_task_id, 1);
419        assert_eq!(b.total_completed_tasks, 0);
420    }
421
422    // -------------------------------------------------------------------------
423    // 2. add_tier stores correctly
424    // -------------------------------------------------------------------------
425    #[test]
426    fn test_add_tier_stores() {
427        let mut b = StorageTierBalancer::new();
428        b.add_tier(nvme_status(1000, 500, 0.8));
429        assert_eq!(b.tiers.len(), 1);
430        let t = b.tier_status(TierKind::Nvme).expect("nvme should exist");
431        assert_eq!(t.capacity_bytes, 1000);
432        assert_eq!(t.used_bytes, 500);
433    }
434
435    // -------------------------------------------------------------------------
436    // 3. add_tier overwrites existing
437    // -------------------------------------------------------------------------
438    #[test]
439    fn test_add_tier_overwrites() {
440        let mut b = StorageTierBalancer::new();
441        b.add_tier(nvme_status(1000, 500, 0.8));
442        b.add_tier(nvme_status(2000, 100, 0.5));
443        assert_eq!(b.tiers.len(), 1);
444        let t = b.tier_status(TierKind::Nvme).expect("nvme should exist");
445        assert_eq!(t.capacity_bytes, 2000);
446        assert_eq!(t.used_bytes, 100);
447    }
448
449    // -------------------------------------------------------------------------
450    // 4. update_usage changes used_bytes
451    // -------------------------------------------------------------------------
452    #[test]
453    fn test_update_usage_changes_used_bytes() {
454        let mut b = StorageTierBalancer::new();
455        b.add_tier(nvme_status(1000, 500, 0.8));
456        b.update_usage(TierKind::Nvme, 700);
457        assert_eq!(b.tier_status(TierKind::Nvme).unwrap().used_bytes, 700);
458    }
459
460    // -------------------------------------------------------------------------
461    // 5. update_usage no-op for unknown tier
462    // -------------------------------------------------------------------------
463    #[test]
464    fn test_update_usage_noop_unknown() {
465        let mut b = StorageTierBalancer::new();
466        // Should not panic
467        b.update_usage(TierKind::Ssd, 9999);
468        assert!(b.tiers.is_empty());
469    }
470
471    // -------------------------------------------------------------------------
472    // 6. utilization() computed correctly
473    // -------------------------------------------------------------------------
474    #[test]
475    fn test_utilization_computed_correctly() {
476        let t = nvme_status(1000, 750, 0.8);
477        assert!((t.utilization() - 0.75).abs() < 1e-9);
478    }
479
480    #[test]
481    fn test_utilization_zero_capacity() {
482        let t = nvme_status(0, 0, 0.8);
483        assert_eq!(t.utilization(), 0.0);
484    }
485
486    // -------------------------------------------------------------------------
487    // 7. free_bytes saturating
488    // -------------------------------------------------------------------------
489    #[test]
490    fn test_free_bytes_saturating() {
491        let t = nvme_status(1000, 1200, 0.8);
492        assert_eq!(t.free_bytes(), 0); // saturates at 0
493        let t2 = nvme_status(1000, 400, 0.8);
494        assert_eq!(t2.free_bytes(), 600);
495    }
496
497    // -------------------------------------------------------------------------
498    // 8. is_over_target true/false
499    // -------------------------------------------------------------------------
500    #[test]
501    fn test_is_over_target_true() {
502        let t = nvme_status(1000, 900, 0.8); // utilization=0.9 > 0.8
503        assert!(t.is_over_target());
504    }
505
506    #[test]
507    fn test_is_over_target_false() {
508        let t = nvme_status(1000, 700, 0.8); // utilization=0.7 < 0.8
509        assert!(!t.is_over_target());
510    }
511
512    #[test]
513    fn test_is_over_target_exactly_at_boundary() {
514        let t = nvme_status(1000, 800, 0.8); // utilization == target_ratio → not over
515        assert!(!t.is_over_target());
516    }
517
518    // -------------------------------------------------------------------------
519    // 9. excess_bytes computed correctly
520    // -------------------------------------------------------------------------
521    #[test]
522    fn test_excess_bytes_over_target() {
523        // capacity=1000, used=900, target=0.7 → target_used=700, excess=200
524        let t = nvme_status(1000, 900, 0.7);
525        assert_eq!(t.excess_bytes(), 200);
526    }
527
528    // -------------------------------------------------------------------------
529    // 10. excess_bytes 0 when not over target
530    // -------------------------------------------------------------------------
531    #[test]
532    fn test_excess_bytes_zero_when_not_over() {
533        let t = nvme_status(1000, 700, 0.8);
534        assert_eq!(t.excess_bytes(), 0);
535    }
536
537    // -------------------------------------------------------------------------
538    // 11. plan_rebalance generates tasks for over-target tier
539    // -------------------------------------------------------------------------
540    #[test]
541    fn test_plan_rebalance_generates_tasks() {
542        let mut b = StorageTierBalancer::new();
543        // NVMe over target (used=900, target=0.7 → excess=200)
544        b.add_tier(nvme_status(1000, 900, 0.7));
545        // SSD under target (lots of room)
546        b.add_tier(ssd_status(10_000, 1_000, 0.8));
547
548        let candidates = vec![("cid1".to_string(), 250_u64, TierKind::Nvme)];
549        let tasks = b.plan_rebalance(candidates);
550        assert_eq!(tasks.len(), 1);
551        assert_eq!(tasks[0].cid, "cid1");
552    }
553
554    // -------------------------------------------------------------------------
555    // 12. plan_rebalance picks under-target destination
556    // -------------------------------------------------------------------------
557    #[test]
558    fn test_plan_rebalance_picks_under_target_destination() {
559        let mut b = StorageTierBalancer::new();
560        b.add_tier(nvme_status(1000, 900, 0.7));
561        // SSD: under target (util ≈ 0.1, target 0.8)
562        b.add_tier(ssd_status(10_000, 1_000, 0.8));
563        // HDD: over target (used=9_500, capacity=10_000, util=0.95 > target=0.9)
564        b.add_tier(hdd_status(10_000, 9_500, 0.9));
565
566        let candidates = vec![("cid1".to_string(), 100_u64, TierKind::Nvme)];
567        let tasks = b.plan_rebalance(candidates);
568        assert_eq!(tasks.len(), 1);
569        // HDD is over target so SSD must be chosen as destination.
570        assert_eq!(tasks[0].to_tier, TierKind::Ssd);
571    }
572
573    // -------------------------------------------------------------------------
574    // 13. plan_rebalance stops when excess covered
575    // -------------------------------------------------------------------------
576    #[test]
577    fn test_plan_rebalance_stops_when_excess_covered() {
578        let mut b = StorageTierBalancer::new();
579        // excess = 900 - 700 = 200 bytes
580        b.add_tier(nvme_status(1000, 900, 0.7));
581        b.add_tier(ssd_status(10_000, 1_000, 0.8));
582
583        // Three candidates; only enough excess for the first one (300 > 200).
584        let candidates = vec![
585            ("cid1".to_string(), 300_u64, TierKind::Nvme),
586            ("cid2".to_string(), 300_u64, TierKind::Nvme),
587            ("cid3".to_string(), 300_u64, TierKind::Nvme),
588        ];
589        let tasks = b.plan_rebalance(candidates);
590        // Once the first candidate covers the 200-byte excess, remaining becomes 0
591        // and the loop breaks — only 1 task generated.
592        assert_eq!(tasks.len(), 1);
593        assert_eq!(tasks[0].cid, "cid1");
594    }
595
596    // -------------------------------------------------------------------------
597    // 14. plan_rebalance skips if no under-target destination
598    // -------------------------------------------------------------------------
599    #[test]
600    fn test_plan_rebalance_skips_no_under_target_destination() {
601        let mut b = StorageTierBalancer::new();
602        b.add_tier(nvme_status(1000, 900, 0.7)); // over target
603                                                 // All other tiers are also over target — nowhere to move data.
604        b.add_tier(ssd_status(1000, 900, 0.8)); // util=0.9 > 0.8 → over target
605
606        let candidates = vec![("cid1".to_string(), 100_u64, TierKind::Nvme)];
607        let tasks = b.plan_rebalance(candidates);
608        assert!(tasks.is_empty());
609    }
610
611    // -------------------------------------------------------------------------
612    // 15. MoveTask priority set correctly (Nvme→Ssd=10)
613    // -------------------------------------------------------------------------
614    #[test]
615    fn test_move_task_priority_nvme_to_ssd() {
616        let mut b = StorageTierBalancer::new();
617        b.add_tier(nvme_status(1000, 900, 0.7));
618        b.add_tier(ssd_status(10_000, 1_000, 0.8));
619
620        let candidates = vec![("cid1".to_string(), 250_u64, TierKind::Nvme)];
621        let tasks = b.plan_rebalance(candidates);
622        assert_eq!(tasks[0].priority, 10);
623    }
624
625    #[test]
626    fn test_move_task_priority_ssd_to_hdd() {
627        let mut b = StorageTierBalancer::new();
628        b.add_tier(ssd_status(1000, 900, 0.7));
629        b.add_tier(hdd_status(100_000, 1_000, 0.8));
630
631        let candidates = vec![("cid1".to_string(), 250_u64, TierKind::Ssd)];
632        let tasks = b.plan_rebalance(candidates);
633        assert_eq!(tasks[0].priority, 5);
634    }
635
636    #[test]
637    fn test_move_task_priority_hdd_to_archive() {
638        let mut b = StorageTierBalancer::new();
639        b.add_tier(hdd_status(1000, 900, 0.7));
640        b.add_tier(archive_status(1_000_000, 1_000, 0.8));
641
642        let candidates = vec![("cid1".to_string(), 250_u64, TierKind::Hdd)];
643        let tasks = b.plan_rebalance(candidates);
644        assert_eq!(tasks[0].priority, 1);
645    }
646
647    // -------------------------------------------------------------------------
648    // 16. complete_task removes from pending
649    // -------------------------------------------------------------------------
650    #[test]
651    fn test_complete_task_removes_from_pending() {
652        let mut b = StorageTierBalancer::new();
653        b.add_tier(nvme_status(1000, 900, 0.7));
654        b.add_tier(ssd_status(10_000, 1_000, 0.8));
655
656        let candidates = vec![("cid1".to_string(), 250_u64, TierKind::Nvme)];
657        let tasks = b.plan_rebalance(candidates);
658        let task_id = tasks[0].task_id;
659
660        assert_eq!(b.pending_tasks.len(), 1);
661        let removed = b.complete_task(task_id);
662        assert!(removed);
663        assert!(b.pending_tasks.is_empty());
664    }
665
666    // -------------------------------------------------------------------------
667    // 17. complete_task false for unknown id
668    // -------------------------------------------------------------------------
669    #[test]
670    fn test_complete_task_false_for_unknown() {
671        let mut b = StorageTierBalancer::new();
672        let result = b.complete_task(9999);
673        assert!(!result);
674    }
675
676    // -------------------------------------------------------------------------
677    // 18. total_completed_tasks increments
678    // -------------------------------------------------------------------------
679    #[test]
680    fn test_total_completed_tasks_increments() {
681        let mut b = StorageTierBalancer::new();
682        b.add_tier(nvme_status(1000, 900, 0.7));
683        b.add_tier(ssd_status(10_000, 1_000, 0.8));
684
685        let candidates = vec![
686            ("cid1".to_string(), 100_u64, TierKind::Nvme),
687            ("cid2".to_string(), 100_u64, TierKind::Nvme),
688        ];
689        let tasks = b.plan_rebalance(candidates);
690        let id1 = tasks[0].task_id;
691        let id2 = tasks[1].task_id;
692
693        b.complete_task(id1);
694        assert_eq!(b.total_completed_tasks, 1);
695        b.complete_task(id2);
696        assert_eq!(b.total_completed_tasks, 2);
697    }
698
699    // -------------------------------------------------------------------------
700    // 19. stats over_target_tiers count
701    // -------------------------------------------------------------------------
702    #[test]
703    fn test_stats_over_target_tiers_count() {
704        let mut b = StorageTierBalancer::new();
705        b.add_tier(nvme_status(1000, 900, 0.7)); // over
706        b.add_tier(ssd_status(1000, 500, 0.8)); // not over
707        b.add_tier(hdd_status(1000, 950, 0.9)); // over
708
709        let stats = b.stats();
710        assert_eq!(stats.total_tiers, 3);
711        assert_eq!(stats.over_target_tiers, 2);
712    }
713
714    // -------------------------------------------------------------------------
715    // 20. stats total_bytes_to_move
716    // -------------------------------------------------------------------------
717    #[test]
718    fn test_stats_total_bytes_to_move() {
719        let mut b = StorageTierBalancer::new();
720        // NVMe over target: excess = 900 - 700 = 200
721        b.add_tier(nvme_status(1000, 900, 0.7));
722        // SSD under target
723        b.add_tier(ssd_status(10_000, 1_000, 0.8));
724
725        let candidates = vec![
726            ("cid1".to_string(), 120_u64, TierKind::Nvme),
727            ("cid2".to_string(), 120_u64, TierKind::Nvme),
728        ];
729        b.plan_rebalance(candidates);
730
731        let stats = b.stats();
732        // Both candidates are scheduled because 120 < 200 excess, and then
733        // 120+120=240 covers the 200-byte excess (second candidate runs because
734        // remaining=80 > 0 after first).
735        assert_eq!(stats.total_bytes_to_move, 240);
736    }
737
738    // -------------------------------------------------------------------------
739    // 21. tier_status returns None for unregistered tier
740    // -------------------------------------------------------------------------
741    #[test]
742    fn test_tier_status_none_for_unregistered() {
743        let b = StorageTierBalancer::new();
744        assert!(b.tier_status(TierKind::Hdd).is_none());
745    }
746
747    // -------------------------------------------------------------------------
748    // 22. pending_tasks sorted by priority descending
749    // -------------------------------------------------------------------------
750    #[test]
751    fn test_pending_tasks_sorted_by_priority_desc() {
752        let mut b = StorageTierBalancer::new();
753        // NVMe over target → priority 10 for NVMe→SSD moves
754        b.add_tier(nvme_status(1000, 950, 0.7));
755        // SSD over target → priority 5 for SSD→HDD moves
756        b.add_tier(ssd_status(1000, 950, 0.7));
757        // HDD under target (large free space so it wins)
758        b.add_tier(hdd_status(1_000_000, 1_000, 0.8));
759
760        let candidates = vec![
761            ("cid_nvme".to_string(), 100_u64, TierKind::Nvme),
762            ("cid_ssd".to_string(), 100_u64, TierKind::Ssd),
763        ];
764        b.plan_rebalance(candidates);
765
766        // pending_tasks must be sorted priority desc: 10 before 5
767        let priorities: Vec<u32> = b.pending_tasks.iter().map(|t| t.priority).collect();
768        for w in priorities.windows(2) {
769            assert!(w[0] >= w[1], "tasks not sorted: {:?}", priorities);
770        }
771    }
772}