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}