Skip to main content

ipfrs_storage/
block_migration_planner.rs

1//! Block migration planner: schedules and tracks data movement between storage nodes/tiers.
2//!
3//! This module provides [`BlockMigrationPlanner`] which registers blocks,
4//! creates and executes migration plans, schedules migrations by policy,
5//! runs batch migrations, and generates defragmentation plans to balance data
6//! across nodes.
7
8use std::collections::{HashMap, VecDeque};
9
10// ─────────────────────────────────────────────────────────────────────────────
11// Type aliases
12// ─────────────────────────────────────────────────────────────────────────────
13
14/// 32-byte content-addressed block identifier (e.g. SHA-256 digest).
15pub type BmpBlockId = [u8; 32];
16
17/// Opaque numeric identifier for a migration plan.
18pub type BmpPlanId = u64;
19
20/// Alias for [`BlockMigrationPlanner`] — used in pub re-exports.
21pub type BmpBlockMigrationPlanner = BlockMigrationPlanner;
22
23// ─────────────────────────────────────────────────────────────────────────────
24// Utility helpers (no external crates)
25// ─────────────────────────────────────────────────────────────────────────────
26
27/// Xorshift64 PRNG — not cryptographically secure, but fast and dependency-free.
28#[inline]
29fn xorshift64(state: &mut u64) -> u64 {
30    let mut x = *state;
31    x ^= x << 13;
32    x ^= x >> 7;
33    x ^= x << 17;
34    *state = x;
35    x
36}
37
38/// FNV-1a 64-bit hash — deterministic digest of arbitrary bytes.
39#[inline]
40fn fnv1a_64(data: &[u8]) -> u64 {
41    let mut h: u64 = 14_695_981_039_346_656_037;
42    for &b in data {
43        h ^= b as u64;
44        h = h.wrapping_mul(1_099_511_628_211);
45    }
46    h
47}
48
49/// Monotonic Unix-epoch timestamp approximation (nanoseconds from epoch zero
50/// expressed as a plain u64 counter seeded from the system clock seconds
51/// value; guaranteed non-zero and strictly increasing within one process).
52fn now_ts() -> u64 {
53    use std::time::{SystemTime, UNIX_EPOCH};
54    SystemTime::now()
55        .duration_since(UNIX_EPOCH)
56        .map(|d| d.as_nanos() as u64)
57        .unwrap_or(1)
58}
59
60// ─────────────────────────────────────────────────────────────────────────────
61// Enumerations
62// ─────────────────────────────────────────────────────────────────────────────
63
64/// Lifecycle state of a [`BmpMigrationPlan`].
65#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
66pub enum BmpPlanStatus {
67    /// Waiting to be executed.
68    Pending,
69    /// Currently being executed.
70    InProgress,
71    /// Execution finished successfully.
72    Completed,
73    /// Execution finished with at least one failure.
74    Failed,
75    /// Plan was cancelled before execution began.
76    Cancelled,
77}
78
79impl std::fmt::Display for BmpPlanStatus {
80    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81        match self {
82            Self::Pending => write!(f, "Pending"),
83            Self::InProgress => write!(f, "InProgress"),
84            Self::Completed => write!(f, "Completed"),
85            Self::Failed => write!(f, "Failed"),
86            Self::Cancelled => write!(f, "Cancelled"),
87        }
88    }
89}
90
91/// Policy for ordering migration candidates when scheduling.
92#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
93pub enum BmpPriorityPolicy {
94    /// Process plans in the order they were created (FIFO).
95    FifoQueue,
96    /// Migrate blocks with the highest `access_frequency` first.
97    HighFrequencyFirst,
98    /// Migrate the largest blocks first (maximise bytes moved per slot).
99    LargestFirst,
100    /// Migrate the smallest blocks first (minimise latency per block).
101    SmallestFirst,
102    /// Prefer blocks where the estimated migration cost is lowest (bytes × tier distance).
103    CostOptimized,
104}
105
106impl std::fmt::Display for BmpPriorityPolicy {
107    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108        match self {
109            Self::FifoQueue => write!(f, "FifoQueue"),
110            Self::HighFrequencyFirst => write!(f, "HighFrequencyFirst"),
111            Self::LargestFirst => write!(f, "LargestFirst"),
112            Self::SmallestFirst => write!(f, "SmallestFirst"),
113            Self::CostOptimized => write!(f, "CostOptimized"),
114        }
115    }
116}
117
118// ─────────────────────────────────────────────────────────────────────────────
119// Data structures
120// ─────────────────────────────────────────────────────────────────────────────
121
122/// Metadata tracked for each registered block.
123#[derive(Clone, Debug)]
124pub struct BmpBlockMeta {
125    /// 32-byte content-addressed identifier.
126    pub id: BmpBlockId,
127    /// Compressed or raw byte size of the block.
128    pub size_bytes: u64,
129    /// Logical node or volume currently holding the block.
130    pub src_node: String,
131    /// Intended destination node (set once a plan is associated).
132    pub dst_node: Option<String>,
133    /// Storage tier index (0 = fastest / hottest).
134    pub tier: u8,
135    /// Exponentially-smoothed access frequency (accesses per unit time).
136    pub access_frequency: f64,
137    /// Timestamp (nanoseconds) of the most recent access.
138    pub last_accessed: u64,
139    /// When `true` the block must not be migrated.
140    pub is_pinned: bool,
141}
142
143impl BmpBlockMeta {
144    /// Create a new block record.
145    pub fn new(
146        id: BmpBlockId,
147        size_bytes: u64,
148        src_node: impl Into<String>,
149        tier: u8,
150        access_frequency: f64,
151    ) -> Self {
152        Self {
153            id,
154            size_bytes,
155            src_node: src_node.into(),
156            dst_node: None,
157            tier,
158            access_frequency,
159            last_accessed: now_ts(),
160            is_pinned: false,
161        }
162    }
163}
164
165/// A migration plan grouping one or more blocks for movement to a destination node.
166#[derive(Clone, Debug)]
167pub struct BmpMigrationPlan {
168    /// Unique plan identifier.
169    pub id: BmpPlanId,
170    /// Blocks covered by this plan.
171    pub block_ids: Vec<BmpBlockId>,
172    /// Node from which the blocks originate.
173    pub src_node: String,
174    /// Node to which the blocks should be moved.
175    pub dst_node: String,
176    /// Higher value means higher urgency.
177    pub priority: u32,
178    /// Total byte size of all blocks in the plan.
179    pub estimated_bytes: u64,
180    /// Current lifecycle state.
181    pub status: BmpPlanStatus,
182    /// Creation timestamp (nanoseconds).
183    pub created_at: u64,
184}
185
186/// Per-block execution record appended to the execution log.
187#[derive(Clone, Debug)]
188pub struct BmpMigrationRecord {
189    /// Execution timestamp (nanoseconds).
190    pub ts: u64,
191    /// Plan that triggered this record.
192    pub plan_id: BmpPlanId,
193    /// Block that was (or failed to be) migrated.
194    pub block_id: BmpBlockId,
195    /// Bytes actually transferred.
196    pub bytes_moved: u64,
197    /// `true` if the block was moved without error.
198    pub success: bool,
199    /// Error description when `success` is `false`.
200    pub error: Option<String>,
201}
202
203/// Planner behaviour configuration.
204#[derive(Clone, Debug)]
205pub struct BmpPlannerConfig {
206    /// Maximum number of plans that can be in `InProgress` simultaneously.
207    pub max_concurrent_migrations: usize,
208    /// Maximum aggregate bytes per second across all active migrations (0 = unlimited).
209    pub bandwidth_limit_kbps: u64,
210    /// Default policy used by [`BlockMigrationPlanner::schedule_migrations`].
211    pub priority_policy: BmpPriorityPolicy,
212    /// When `true`, plans are validated and logged but no data is actually moved.
213    pub dry_run: bool,
214}
215
216impl Default for BmpPlannerConfig {
217    fn default() -> Self {
218        Self {
219            max_concurrent_migrations: 4,
220            bandwidth_limit_kbps: 0,
221            priority_policy: BmpPriorityPolicy::FifoQueue,
222            dry_run: false,
223        }
224    }
225}
226
227/// Aggregate statistics returned by [`BlockMigrationPlanner::migration_stats`].
228#[derive(Clone, Debug, Default)]
229pub struct BmpPlannerStats {
230    /// Total blocks registered.
231    pub total_blocks: usize,
232    /// Blocks currently marked as pinned.
233    pub pinned_blocks: usize,
234    /// Total plans ever created.
235    pub total_plans: usize,
236    /// Plans in `Pending` state.
237    pub pending_plans: usize,
238    /// Plans in `InProgress` state.
239    pub in_progress_plans: usize,
240    /// Plans in `Completed` state.
241    pub completed_plans: usize,
242    /// Plans in `Failed` state.
243    pub failed_plans: usize,
244    /// Plans in `Cancelled` state.
245    pub cancelled_plans: usize,
246    /// Total execution log entries.
247    pub total_records: usize,
248    /// Total bytes successfully moved across all completed records.
249    pub total_bytes_moved: u64,
250    /// Number of records where `success == false`.
251    pub total_failures: usize,
252}
253
254/// Result of executing a single plan.
255#[derive(Clone, Debug)]
256pub struct BmpExecutionResult {
257    /// The plan that was executed.
258    pub plan_id: BmpPlanId,
259    /// Final plan status after execution.
260    pub status: BmpPlanStatus,
261    /// Blocks that were successfully migrated.
262    pub succeeded_blocks: Vec<BmpBlockId>,
263    /// Blocks that failed to migrate, with their error strings.
264    pub failed_blocks: Vec<(BmpBlockId, String)>,
265    /// Total bytes moved.
266    pub bytes_moved: u64,
267}
268
269/// Aggregated result of a [`BlockMigrationPlanner::run_batch_migration`] call.
270#[derive(Clone, Debug, Default)]
271pub struct BmpBatchResult {
272    /// How many plans were executed in this batch.
273    pub plans_executed: usize,
274    /// Plans that completed with all blocks successful.
275    pub plans_completed: usize,
276    /// Plans that had at least one failed block.
277    pub plans_failed: usize,
278    /// Total bytes moved across all plans in the batch.
279    pub total_bytes_moved: u64,
280    /// Per-plan execution results.
281    pub results: Vec<BmpExecutionResult>,
282}
283
284// ─────────────────────────────────────────────────────────────────────────────
285// Error type
286// ─────────────────────────────────────────────────────────────────────────────
287
288/// Errors returned by [`BlockMigrationPlanner`] operations.
289#[derive(Debug, thiserror::Error)]
290pub enum BmpError {
291    #[error("block {0} is not registered")]
292    BlockNotFound(String),
293    #[error("plan {0} does not exist")]
294    PlanNotFound(BmpPlanId),
295    #[error("plan {0} is not in Pending state (current: {1})")]
296    PlanNotPending(BmpPlanId, BmpPlanStatus),
297    #[error("plan has no blocks")]
298    EmptyPlan,
299    #[error("block {0} is pinned and cannot be migrated")]
300    BlockPinned(String),
301    #[error("destination node is empty")]
302    EmptyDstNode,
303    #[error("no eligible blocks found for scheduling")]
304    NoEligibleBlocks,
305}
306
307fn block_id_hex(id: &BmpBlockId) -> String {
308    let mut s = String::with_capacity(64);
309    for b in id {
310        s.push_str(&format!("{:02x}", b));
311    }
312    s
313}
314
315// ─────────────────────────────────────────────────────────────────────────────
316// Core planner
317// ─────────────────────────────────────────────────────────────────────────────
318
319/// Block migration planner: registers blocks, creates plans, executes them,
320/// and provides scheduling and defragmentation helpers.
321pub struct BlockMigrationPlanner {
322    /// All known blocks, keyed by their 32-byte identifier.
323    blocks: HashMap<BmpBlockId, BmpBlockMeta>,
324    /// All plans, keyed by plan id.
325    plans: HashMap<BmpPlanId, BmpMigrationPlan>,
326    /// Bounded execution log (cap 1000 most-recent records).
327    log: VecDeque<BmpMigrationRecord>,
328    /// Behaviour configuration.
329    config: BmpPlannerConfig,
330    /// Monotonically-increasing plan id counter.
331    next_plan_id: BmpPlanId,
332    /// PRNG state (xorshift64 seeded from a mix of time and FNV).
333    rng_state: u64,
334}
335
336impl BlockMigrationPlanner {
337    // ── Construction ──────────────────────────────────────────────────────────
338
339    /// Create a planner with default configuration.
340    pub fn new() -> Self {
341        Self::with_config(BmpPlannerConfig::default())
342    }
343
344    /// Create a planner with the supplied configuration.
345    pub fn with_config(config: BmpPlannerConfig) -> Self {
346        let seed_data = b"block_migration_planner_v1";
347        let ts = now_ts();
348        let base = fnv1a_64(seed_data);
349        let mut rng_state = base ^ ts;
350        if rng_state == 0 {
351            rng_state = 0xdeadbeef_cafebabe;
352        }
353        Self {
354            blocks: HashMap::new(),
355            plans: HashMap::new(),
356            log: VecDeque::new(),
357            config,
358            next_plan_id: 1,
359            rng_state,
360        }
361    }
362
363    /// Return a reference to the current configuration.
364    pub fn config(&self) -> &BmpPlannerConfig {
365        &self.config
366    }
367
368    /// Replace the configuration.
369    pub fn set_config(&mut self, config: BmpPlannerConfig) {
370        self.config = config;
371    }
372
373    // ── Block registration ────────────────────────────────────────────────────
374
375    /// Register a new block (or overwrite if it already exists).
376    pub fn register_block(
377        &mut self,
378        id: BmpBlockId,
379        size_bytes: u64,
380        src_node: impl Into<String>,
381        tier: u8,
382        access_frequency: f64,
383    ) {
384        let meta = BmpBlockMeta::new(id, size_bytes, src_node, tier, access_frequency);
385        self.blocks.insert(id, meta);
386    }
387
388    /// Record an access event for a block, bumping `last_accessed` and applying
389    /// a simple exponential-moving-average update to `access_frequency`.
390    pub fn update_access(&mut self, id: &BmpBlockId) -> Result<(), BmpError> {
391        let meta = self
392            .blocks
393            .get_mut(id)
394            .ok_or_else(|| BmpError::BlockNotFound(block_id_hex(id)))?;
395        meta.last_accessed = now_ts();
396        // EMA with α = 0.1
397        meta.access_frequency = meta.access_frequency * 0.9 + 1.0 * 0.1;
398        Ok(())
399    }
400
401    /// Pin a block so it will not be selected for automatic migration.
402    pub fn pin(&mut self, id: &BmpBlockId) -> Result<(), BmpError> {
403        let meta = self
404            .blocks
405            .get_mut(id)
406            .ok_or_else(|| BmpError::BlockNotFound(block_id_hex(id)))?;
407        meta.is_pinned = true;
408        Ok(())
409    }
410
411    /// Unpin a previously pinned block.
412    pub fn unpin(&mut self, id: &BmpBlockId) -> Result<(), BmpError> {
413        let meta = self
414            .blocks
415            .get_mut(id)
416            .ok_or_else(|| BmpError::BlockNotFound(block_id_hex(id)))?;
417        meta.is_pinned = false;
418        Ok(())
419    }
420
421    /// Return a reference to a block's metadata, or `None` if not registered.
422    pub fn block_meta(&self, id: &BmpBlockId) -> Option<&BmpBlockMeta> {
423        self.blocks.get(id)
424    }
425
426    /// Number of currently registered blocks.
427    pub fn block_count(&self) -> usize {
428        self.blocks.len()
429    }
430
431    // ── Plan management ───────────────────────────────────────────────────────
432
433    /// Create a migration plan for the given blocks.
434    ///
435    /// The `src_node` is inferred from the first block's current location; if
436    /// the list spans multiple source nodes the first one wins (callers should
437    /// group by node before creating plans when that matters).
438    ///
439    /// Returns `Err` if any block is pinned or if any block is not registered.
440    pub fn create_plan(
441        &mut self,
442        block_ids: Vec<BmpBlockId>,
443        dst_node: impl Into<String>,
444        priority: u32,
445    ) -> Result<BmpPlanId, BmpError> {
446        if block_ids.is_empty() {
447            return Err(BmpError::EmptyPlan);
448        }
449        let dst_node = dst_node.into();
450        if dst_node.is_empty() {
451            return Err(BmpError::EmptyDstNode);
452        }
453
454        // Validate all blocks exist and are not pinned.
455        for bid in &block_ids {
456            let meta = self
457                .blocks
458                .get(bid)
459                .ok_or_else(|| BmpError::BlockNotFound(block_id_hex(bid)))?;
460            if meta.is_pinned {
461                return Err(BmpError::BlockPinned(block_id_hex(bid)));
462            }
463        }
464
465        // Infer src_node and tally estimated bytes.
466        let src_node = self
467            .blocks
468            .get(&block_ids[0])
469            .map(|m| m.src_node.clone())
470            .unwrap_or_default();
471
472        let estimated_bytes: u64 = block_ids
473            .iter()
474            .filter_map(|bid| self.blocks.get(bid).map(|m| m.size_bytes))
475            .sum();
476
477        let plan_id = self.next_plan_id;
478        self.next_plan_id += 1;
479
480        // Record intended dst_node on each block.
481        for bid in &block_ids {
482            if let Some(meta) = self.blocks.get_mut(bid) {
483                meta.dst_node = Some(dst_node.clone());
484            }
485        }
486
487        let plan = BmpMigrationPlan {
488            id: plan_id,
489            block_ids,
490            src_node,
491            dst_node,
492            priority,
493            estimated_bytes,
494            status: BmpPlanStatus::Pending,
495            created_at: now_ts(),
496        };
497        self.plans.insert(plan_id, plan);
498        Ok(plan_id)
499    }
500
501    /// Cancel a pending plan.  Plans that are already `InProgress`, `Completed`,
502    /// `Failed`, or `Cancelled` cannot be cancelled again.
503    pub fn cancel_plan(&mut self, plan_id: BmpPlanId) -> Result<(), BmpError> {
504        let plan = self
505            .plans
506            .get_mut(&plan_id)
507            .ok_or(BmpError::PlanNotFound(plan_id))?;
508        if plan.status != BmpPlanStatus::Pending {
509            return Err(BmpError::PlanNotPending(plan_id, plan.status));
510        }
511        plan.status = BmpPlanStatus::Cancelled;
512        Ok(())
513    }
514
515    /// Execute a migration plan.
516    ///
517    /// Each block is migrated in sequence; a simulated success/failure decision
518    /// is made via xorshift64 (roughly 90 % success when `!dry_run`).
519    /// In `dry_run` mode all blocks succeed without moving.
520    pub fn execute_plan(&mut self, plan_id: BmpPlanId) -> Result<BmpExecutionResult, BmpError> {
521        // Verify the plan exists and is pending.
522        {
523            let plan = self
524                .plans
525                .get(&plan_id)
526                .ok_or(BmpError::PlanNotFound(plan_id))?;
527            if plan.status != BmpPlanStatus::Pending {
528                return Err(BmpError::PlanNotPending(plan_id, plan.status));
529            }
530        }
531
532        // Mark in-progress.
533        if let Some(plan) = self.plans.get_mut(&plan_id) {
534            plan.status = BmpPlanStatus::InProgress;
535        }
536
537        let block_ids: Vec<BmpBlockId> = self
538            .plans
539            .get(&plan_id)
540            .map(|p| p.block_ids.clone())
541            .unwrap_or_default();
542
543        let dst_node: String = self
544            .plans
545            .get(&plan_id)
546            .map(|p| p.dst_node.clone())
547            .unwrap_or_default();
548
549        let dry_run = self.config.dry_run;
550
551        let mut succeeded_blocks: Vec<BmpBlockId> = Vec::new();
552        let mut failed_blocks: Vec<(BmpBlockId, String)> = Vec::new();
553        let mut bytes_moved: u64 = 0;
554
555        for bid in &block_ids {
556            let size = self.blocks.get(bid).map(|m| m.size_bytes).unwrap_or(0);
557
558            let (success, error_msg): (bool, Option<String>) = if dry_run {
559                (true, None)
560            } else {
561                // xorshift64: success if high nibble is not 0xF (~93.75% success).
562                let roll = xorshift64(&mut self.rng_state);
563                if (roll >> 60) != 0xF {
564                    (true, None)
565                } else {
566                    (
567                        false,
568                        Some(format!("simulated I/O error (roll=0x{:016x})", roll)),
569                    )
570                }
571            };
572
573            if success {
574                bytes_moved += size;
575                succeeded_blocks.push(*bid);
576                // Update block metadata: move it to dst_node.
577                if let Some(meta) = self.blocks.get_mut(bid) {
578                    meta.src_node = dst_node.clone();
579                    meta.dst_node = None;
580                }
581            } else {
582                failed_blocks.push((*bid, error_msg.clone().unwrap_or_default()));
583            }
584
585            let record = BmpMigrationRecord {
586                ts: now_ts(),
587                plan_id,
588                block_id: *bid,
589                bytes_moved: if success { size } else { 0 },
590                success,
591                error: error_msg,
592            };
593            self.append_log(record);
594        }
595
596        let final_status = if failed_blocks.is_empty() {
597            BmpPlanStatus::Completed
598        } else {
599            BmpPlanStatus::Failed
600        };
601
602        if let Some(plan) = self.plans.get_mut(&plan_id) {
603            plan.status = final_status;
604        }
605
606        Ok(BmpExecutionResult {
607            plan_id,
608            status: final_status,
609            succeeded_blocks,
610            failed_blocks,
611            bytes_moved,
612        })
613    }
614
615    // ── Scheduling ────────────────────────────────────────────────────────────
616
617    /// Automatically select blocks that need migration and create plans for them.
618    ///
619    /// "Needing migration" means the block has a `dst_node` already set, or
620    /// is unpinned and in a non-zero tier (tier > 0 → candidate for promotion).
621    /// Up to `max_plans` plans are created, each containing a single block,
622    /// ordered according to `policy`.
623    pub fn schedule_migrations(
624        &mut self,
625        policy: BmpPriorityPolicy,
626        max_plans: usize,
627    ) -> Result<Vec<BmpPlanId>, BmpError> {
628        if max_plans == 0 {
629            return Ok(Vec::new());
630        }
631
632        // Collect candidate block ids.
633        let mut candidates: Vec<BmpBlockId> = self
634            .blocks
635            .values()
636            .filter(|m| !m.is_pinned && m.dst_node.is_some())
637            .map(|m| m.id)
638            .collect();
639
640        if candidates.is_empty() {
641            // Fall back: pick unpinned blocks on tier > 0.
642            candidates = self
643                .blocks
644                .values()
645                .filter(|m| !m.is_pinned && m.tier > 0)
646                .map(|m| m.id)
647                .collect();
648        }
649
650        if candidates.is_empty() {
651            return Err(BmpError::NoEligibleBlocks);
652        }
653
654        // Sort by policy.
655        self.sort_candidates_by_policy(&mut candidates, policy);
656
657        candidates.truncate(max_plans);
658
659        let mut plan_ids = Vec::with_capacity(candidates.len());
660        for bid in candidates {
661            let dst = self
662                .blocks
663                .get(&bid)
664                .and_then(|m| m.dst_node.clone())
665                .unwrap_or_else(|| "node-0".to_string());
666
667            match self.create_plan(vec![bid], dst, 0) {
668                Ok(pid) => plan_ids.push(pid),
669                Err(_) => continue,
670            }
671        }
672        Ok(plan_ids)
673    }
674
675    /// Sort a candidate list in-place according to `policy`.
676    fn sort_candidates_by_policy(&self, ids: &mut [BmpBlockId], policy: BmpPriorityPolicy) {
677        match policy {
678            BmpPriorityPolicy::FifoQueue => {
679                // Sort by `last_accessed` ascending (oldest first).
680                ids.sort_by(|a, b| {
681                    let ta = self.blocks.get(a).map_or(0, |m| m.last_accessed);
682                    let tb = self.blocks.get(b).map_or(0, |m| m.last_accessed);
683                    ta.cmp(&tb)
684                });
685            }
686            BmpPriorityPolicy::HighFrequencyFirst => {
687                ids.sort_by(|a, b| {
688                    let fa = self.blocks.get(a).map_or(0.0, |m| m.access_frequency);
689                    let fb = self.blocks.get(b).map_or(0.0, |m| m.access_frequency);
690                    fb.partial_cmp(&fa).unwrap_or(std::cmp::Ordering::Equal)
691                });
692            }
693            BmpPriorityPolicy::LargestFirst => {
694                ids.sort_by(|a, b| {
695                    let sa = self.blocks.get(a).map_or(0, |m| m.size_bytes);
696                    let sb = self.blocks.get(b).map_or(0, |m| m.size_bytes);
697                    sb.cmp(&sa)
698                });
699            }
700            BmpPriorityPolicy::SmallestFirst => {
701                ids.sort_by(|a, b| {
702                    let sa = self.blocks.get(a).map_or(0, |m| m.size_bytes);
703                    let sb = self.blocks.get(b).map_or(0, |m| m.size_bytes);
704                    sa.cmp(&sb)
705                });
706            }
707            BmpPriorityPolicy::CostOptimized => {
708                // Proxy cost = size_bytes * tier (lower = cheaper to migrate).
709                ids.sort_by(|a, b| {
710                    let ca = self
711                        .blocks
712                        .get(a)
713                        .map_or(0, |m| m.size_bytes.saturating_mul(m.tier as u64));
714                    let cb = self
715                        .blocks
716                        .get(b)
717                        .map_or(0, |m| m.size_bytes.saturating_mul(m.tier as u64));
718                    ca.cmp(&cb)
719                });
720            }
721        }
722    }
723
724    /// Create up to `batch_size` plans using `policy` and execute them all,
725    /// respecting `max_concurrent_migrations`.
726    pub fn run_batch_migration(
727        &mut self,
728        policy: BmpPriorityPolicy,
729        batch_size: usize,
730    ) -> BmpBatchResult {
731        let max = self.config.max_concurrent_migrations.min(batch_size);
732
733        let plan_ids = match self.schedule_migrations(policy, max) {
734            Ok(ids) => ids,
735            Err(_) => return BmpBatchResult::default(),
736        };
737
738        let mut batch = BmpBatchResult {
739            plans_executed: plan_ids.len(),
740            ..BmpBatchResult::default()
741        };
742
743        for pid in plan_ids {
744            match self.execute_plan(pid) {
745                Ok(result) => {
746                    batch.total_bytes_moved += result.bytes_moved;
747                    if result.status == BmpPlanStatus::Completed {
748                        batch.plans_completed += 1;
749                    } else {
750                        batch.plans_failed += 1;
751                    }
752                    batch.results.push(result);
753                }
754                Err(_) => {
755                    batch.plans_failed += 1;
756                }
757            }
758        }
759        batch
760    }
761
762    // ── Defragmentation ───────────────────────────────────────────────────────
763
764    /// Generate plans to balance blocks evenly across the supplied nodes.
765    ///
766    /// Blocks that are already on the least-loaded node (or are pinned) are
767    /// skipped.  The target is `total_blocks / nodes.len()` per node.
768    /// Returns `Vec<BmpPlanId>` of all newly created plans.
769    pub fn defragment_plan(&mut self, nodes: &[String]) -> Vec<BmpPlanId> {
770        if nodes.is_empty() {
771            return Vec::new();
772        }
773
774        // Count blocks per node.
775        let mut node_counts: HashMap<String, usize> =
776            nodes.iter().map(|n| (n.clone(), 0)).collect();
777        for meta in self.blocks.values() {
778            if let Some(cnt) = node_counts.get_mut(&meta.src_node) {
779                *cnt += 1;
780            }
781        }
782
783        let total: usize = node_counts.values().sum();
784        let target = total.div_ceil(nodes.len());
785
786        // Identify over-full and under-full nodes.
787        let mut over_nodes: Vec<String> = node_counts
788            .iter()
789            .filter(|(_, &cnt)| cnt > target)
790            .map(|(n, _)| n.clone())
791            .collect();
792        let mut under_nodes: Vec<String> = node_counts
793            .iter()
794            .filter(|(_, &cnt)| cnt < target)
795            .map(|(n, _)| n.clone())
796            .collect();
797
798        if over_nodes.is_empty() || under_nodes.is_empty() {
799            return Vec::new();
800        }
801
802        // Sort deterministically so behaviour is reproducible.
803        over_nodes.sort();
804        under_nodes.sort();
805
806        // Build a list of (block_id, src_node, intended_dst_node) moves.
807        struct Move {
808            bid: BmpBlockId,
809            dst: String,
810        }
811        let mut moves: Vec<Move> = Vec::new();
812
813        // Remaining capacity on each under-loaded node.
814        let mut capacity: HashMap<String, usize> = under_nodes
815            .iter()
816            .map(|n| {
817                let cnt = node_counts.get(n).copied().unwrap_or(0);
818                (n.clone(), target.saturating_sub(cnt))
819            })
820            .collect();
821
822        for over in &over_nodes {
823            let excess = node_counts
824                .get(over)
825                .map_or(0, |&cnt| cnt.saturating_sub(target));
826            if excess == 0 {
827                continue;
828            }
829            // Collect movable blocks from this node.
830            let candidates: Vec<BmpBlockId> = self
831                .blocks
832                .values()
833                .filter(|m| &m.src_node == over && !m.is_pinned && m.dst_node.is_none())
834                .map(|m| m.id)
835                .collect();
836
837            let mut moved = 0usize;
838            'outer: for bid in candidates {
839                if moved >= excess {
840                    break;
841                }
842                // Find an under node with remaining capacity.
843                for under in &under_nodes {
844                    let cap = capacity.get_mut(under);
845                    if let Some(c) = cap {
846                        if *c > 0 {
847                            *c -= 1;
848                            moves.push(Move {
849                                bid,
850                                dst: under.clone(),
851                            });
852                            moved += 1;
853                            continue 'outer;
854                        }
855                    }
856                }
857                break; // No space on any under node.
858            }
859        }
860
861        // Create one plan per move.
862        let mut plan_ids = Vec::with_capacity(moves.len());
863        for m in moves {
864            // Tag the block with its intended dst so create_plan does not reject it.
865            if let Some(meta) = self.blocks.get_mut(&m.bid) {
866                meta.dst_node = Some(m.dst.clone());
867            }
868            match self.create_plan(vec![m.bid], m.dst, 0) {
869                Ok(pid) => plan_ids.push(pid),
870                Err(_) => continue,
871            }
872        }
873        plan_ids
874    }
875
876    // ── Statistics ────────────────────────────────────────────────────────────
877
878    /// Compute and return aggregate statistics.
879    pub fn migration_stats(&self) -> BmpPlannerStats {
880        let pinned_blocks = self.blocks.values().filter(|m| m.is_pinned).count();
881
882        let mut stats = BmpPlannerStats {
883            total_blocks: self.blocks.len(),
884            pinned_blocks,
885            total_plans: self.plans.len(),
886            total_records: self.log.len(),
887            ..Default::default()
888        };
889
890        for plan in self.plans.values() {
891            match plan.status {
892                BmpPlanStatus::Pending => stats.pending_plans += 1,
893                BmpPlanStatus::InProgress => stats.in_progress_plans += 1,
894                BmpPlanStatus::Completed => stats.completed_plans += 1,
895                BmpPlanStatus::Failed => stats.failed_plans += 1,
896                BmpPlanStatus::Cancelled => stats.cancelled_plans += 1,
897            }
898        }
899
900        for rec in &self.log {
901            if rec.success {
902                stats.total_bytes_moved += rec.bytes_moved;
903            } else {
904                stats.total_failures += 1;
905            }
906        }
907
908        stats
909    }
910
911    /// Return the number of plans in the planner.
912    pub fn plan_count(&self) -> usize {
913        self.plans.len()
914    }
915
916    /// Return a reference to a plan, or `None`.
917    pub fn plan(&self, plan_id: BmpPlanId) -> Option<&BmpMigrationPlan> {
918        self.plans.get(&plan_id)
919    }
920
921    /// Iterate over all plans.
922    pub fn plans_iter(&self) -> impl Iterator<Item = &BmpMigrationPlan> {
923        self.plans.values()
924    }
925
926    /// Return the most recent `n` log records.
927    pub fn recent_records(&self, n: usize) -> Vec<&BmpMigrationRecord> {
928        self.log.iter().rev().take(n).collect()
929    }
930
931    /// Total number of records in the execution log.
932    pub fn log_len(&self) -> usize {
933        self.log.len()
934    }
935
936    // ── Internal helpers ──────────────────────────────────────────────────────
937
938    /// Append a record to the execution log, evicting the oldest when full.
939    fn append_log(&mut self, record: BmpMigrationRecord) {
940        const MAX_LOG: usize = 1000;
941        if self.log.len() >= MAX_LOG {
942            self.log.pop_front();
943        }
944        self.log.push_back(record);
945    }
946}
947
948impl Default for BlockMigrationPlanner {
949    fn default() -> Self {
950        Self::new()
951    }
952}
953
954// ─────────────────────────────────────────────────────────────────────────────
955// Additional helper: make a BmpBlockId from a byte slice (pads / truncates).
956// ─────────────────────────────────────────────────────────────────────────────
957
958/// Build a `BmpBlockId` deterministically from arbitrary bytes using FNV-1a.
959///
960/// The 32-byte array is filled by splitting the 64-bit FNV hash into 4× u64
961/// words (each XOR'd with a rotation of the previous word for diffusion).
962pub fn bmp_id_from_bytes(data: &[u8]) -> BmpBlockId {
963    let mut id = [0u8; 32];
964    let h0 = fnv1a_64(data);
965    let h1 = fnv1a_64(&h0.to_le_bytes());
966    let h2 = fnv1a_64(&h1.to_le_bytes());
967    let h3 = fnv1a_64(&h2.to_le_bytes());
968    id[0..8].copy_from_slice(&h0.to_le_bytes());
969    id[8..16].copy_from_slice(&h1.to_le_bytes());
970    id[16..24].copy_from_slice(&h2.to_le_bytes());
971    id[24..32].copy_from_slice(&h3.to_le_bytes());
972    id
973}
974
975// ─────────────────────────────────────────────────────────────────────────────
976// Tests
977// ─────────────────────────────────────────────────────────────────────────────
978
979#[cfg(test)]
980mod tests {
981    use super::*;
982
983    // ── Helpers ───────────────────────────────────────────────────────────────
984
985    fn make_id(seed: u8) -> BmpBlockId {
986        bmp_id_from_bytes(&[seed; 16])
987    }
988
989    fn make_planner() -> BlockMigrationPlanner {
990        BlockMigrationPlanner::new()
991    }
992
993    fn register_n(planner: &mut BlockMigrationPlanner, n: usize, node: &str) -> Vec<BmpBlockId> {
994        let mut ids = Vec::with_capacity(n);
995        for i in 0..n {
996            let id = bmp_id_from_bytes(format!("block-{}", i).as_bytes());
997            planner.register_block(id, (i as u64 + 1) * 512, node, 1, 1.0);
998            ids.push(id);
999        }
1000        ids
1001    }
1002
1003    // ── xorshift64 / fnv1a_64 ─────────────────────────────────────────────────
1004
1005    #[test]
1006    fn test_xorshift64_nonzero() {
1007        let mut state: u64 = 12345678;
1008        for _ in 0..100 {
1009            let v = xorshift64(&mut state);
1010            assert_ne!(v, 0);
1011        }
1012    }
1013
1014    #[test]
1015    fn test_xorshift64_changes_state() {
1016        let mut state: u64 = 1;
1017        let a = xorshift64(&mut state);
1018        let b = xorshift64(&mut state);
1019        assert_ne!(a, b);
1020    }
1021
1022    #[test]
1023    fn test_fnv1a_empty() {
1024        let h = fnv1a_64(&[]);
1025        // FNV offset basis
1026        assert_eq!(h, 14_695_981_039_346_656_037_u64);
1027    }
1028
1029    #[test]
1030    fn test_fnv1a_deterministic() {
1031        assert_eq!(fnv1a_64(b"hello"), fnv1a_64(b"hello"));
1032    }
1033
1034    #[test]
1035    fn test_fnv1a_differs_on_different_input() {
1036        assert_ne!(fnv1a_64(b"foo"), fnv1a_64(b"bar"));
1037    }
1038
1039    // ── bmp_id_from_bytes ─────────────────────────────────────────────────────
1040
1041    #[test]
1042    fn test_bmp_id_from_bytes_length() {
1043        let id = bmp_id_from_bytes(b"test");
1044        assert_eq!(id.len(), 32);
1045    }
1046
1047    #[test]
1048    fn test_bmp_id_from_bytes_deterministic() {
1049        assert_eq!(bmp_id_from_bytes(b"abc"), bmp_id_from_bytes(b"abc"));
1050    }
1051
1052    #[test]
1053    fn test_bmp_id_from_bytes_differs() {
1054        assert_ne!(bmp_id_from_bytes(b"a"), bmp_id_from_bytes(b"b"));
1055    }
1056
1057    // ── BlockMigrationPlanner construction ────────────────────────────────────
1058
1059    #[test]
1060    fn test_new_planner_empty() {
1061        let p = make_planner();
1062        assert_eq!(p.block_count(), 0);
1063        assert_eq!(p.plan_count(), 0);
1064        assert_eq!(p.log_len(), 0);
1065    }
1066
1067    #[test]
1068    fn test_with_config_dry_run() {
1069        let cfg = BmpPlannerConfig {
1070            dry_run: true,
1071            ..Default::default()
1072        };
1073        let p = BlockMigrationPlanner::with_config(cfg);
1074        assert!(p.config().dry_run);
1075    }
1076
1077    #[test]
1078    fn test_set_config() {
1079        let mut p = make_planner();
1080        let cfg = BmpPlannerConfig {
1081            bandwidth_limit_kbps: 1024,
1082            ..Default::default()
1083        };
1084        p.set_config(cfg);
1085        assert_eq!(p.config().bandwidth_limit_kbps, 1024);
1086    }
1087
1088    // ── register_block ────────────────────────────────────────────────────────
1089
1090    #[test]
1091    fn test_register_block_increases_count() {
1092        let mut p = make_planner();
1093        let id = make_id(1);
1094        p.register_block(id, 1024, "node-0", 0, 1.0);
1095        assert_eq!(p.block_count(), 1);
1096    }
1097
1098    #[test]
1099    fn test_register_block_metadata() {
1100        let mut p = make_planner();
1101        let id = make_id(2);
1102        p.register_block(id, 2048, "node-1", 2, 3.5);
1103        let meta = p.block_meta(&id).expect("block should exist");
1104        assert_eq!(meta.size_bytes, 2048);
1105        assert_eq!(meta.src_node, "node-1");
1106        assert_eq!(meta.tier, 2);
1107        assert!((meta.access_frequency - 3.5).abs() < 1e-9);
1108        assert!(!meta.is_pinned);
1109    }
1110
1111    #[test]
1112    fn test_register_block_overwrite() {
1113        let mut p = make_planner();
1114        let id = make_id(3);
1115        p.register_block(id, 100, "node-a", 0, 0.5);
1116        p.register_block(id, 200, "node-b", 1, 2.0);
1117        assert_eq!(p.block_count(), 1); // still one block
1118        let meta = p.block_meta(&id).expect("exists");
1119        assert_eq!(meta.size_bytes, 200);
1120        assert_eq!(meta.src_node, "node-b");
1121    }
1122
1123    #[test]
1124    fn test_register_multiple_blocks() {
1125        let mut p = make_planner();
1126        for i in 0..10u8 {
1127            p.register_block(make_id(i), 512, "node-0", 0, 1.0);
1128        }
1129        assert_eq!(p.block_count(), 10);
1130    }
1131
1132    // ── update_access ─────────────────────────────────────────────────────────
1133
1134    #[test]
1135    fn test_update_access_ok() {
1136        let mut p = make_planner();
1137        let id = make_id(10);
1138        p.register_block(id, 512, "node-0", 0, 1.0);
1139        let old_freq = p.block_meta(&id).map_or(0.0, |m| m.access_frequency);
1140        p.update_access(&id).expect("ok");
1141        let new_freq = p.block_meta(&id).map_or(0.0, |m| m.access_frequency);
1142        // EMA means new_freq = old * 0.9 + 0.1; should differ from initial 1.0
1143        assert!(new_freq > 0.0);
1144        let _ = old_freq; // suppress unused warning
1145    }
1146
1147    #[test]
1148    fn test_update_access_unknown_block_errors() {
1149        let mut p = make_planner();
1150        let id = make_id(11);
1151        assert!(p.update_access(&id).is_err());
1152    }
1153
1154    #[test]
1155    fn test_update_access_bumps_last_accessed() {
1156        let mut p = make_planner();
1157        let id = make_id(12);
1158        p.register_block(id, 512, "node-0", 0, 1.0);
1159        let t0 = p.block_meta(&id).map_or(0, |m| m.last_accessed);
1160        std::thread::sleep(std::time::Duration::from_millis(1));
1161        p.update_access(&id).expect("ok");
1162        let t1 = p.block_meta(&id).map_or(0, |m| m.last_accessed);
1163        assert!(t1 >= t0);
1164    }
1165
1166    // ── pin / unpin ───────────────────────────────────────────────────────────
1167
1168    #[test]
1169    fn test_pin_block() {
1170        let mut p = make_planner();
1171        let id = make_id(20);
1172        p.register_block(id, 512, "node-0", 0, 1.0);
1173        p.pin(&id).expect("ok");
1174        assert!(p.block_meta(&id).is_some_and(|m| m.is_pinned));
1175    }
1176
1177    #[test]
1178    fn test_unpin_block() {
1179        let mut p = make_planner();
1180        let id = make_id(21);
1181        p.register_block(id, 512, "node-0", 0, 1.0);
1182        p.pin(&id).expect("ok");
1183        p.unpin(&id).expect("ok");
1184        assert!(!p.block_meta(&id).is_none_or(|m| m.is_pinned));
1185    }
1186
1187    #[test]
1188    fn test_pin_unknown_block_errors() {
1189        let mut p = make_planner();
1190        assert!(p.pin(&make_id(22)).is_err());
1191    }
1192
1193    #[test]
1194    fn test_unpin_unknown_block_errors() {
1195        let mut p = make_planner();
1196        assert!(p.unpin(&make_id(23)).is_err());
1197    }
1198
1199    // ── create_plan ───────────────────────────────────────────────────────────
1200
1201    #[test]
1202    fn test_create_plan_basic() {
1203        let mut p = make_planner();
1204        let id = make_id(30);
1205        p.register_block(id, 1024, "node-0", 0, 1.0);
1206        let pid = p.create_plan(vec![id], "node-1", 5).expect("ok");
1207        assert_eq!(pid, 1);
1208        let plan = p.plan(pid).expect("plan exists");
1209        assert_eq!(plan.status, BmpPlanStatus::Pending);
1210        assert_eq!(plan.estimated_bytes, 1024);
1211        assert_eq!(plan.dst_node, "node-1");
1212        assert_eq!(plan.priority, 5);
1213    }
1214
1215    #[test]
1216    fn test_create_plan_empty_blocks_errors() {
1217        let mut p = make_planner();
1218        assert!(p.create_plan(vec![], "node-1", 0).is_err());
1219    }
1220
1221    #[test]
1222    fn test_create_plan_empty_dst_errors() {
1223        let mut p = make_planner();
1224        let id = make_id(31);
1225        p.register_block(id, 512, "node-0", 0, 1.0);
1226        assert!(p.create_plan(vec![id], "", 0).is_err());
1227    }
1228
1229    #[test]
1230    fn test_create_plan_unknown_block_errors() {
1231        let mut p = make_planner();
1232        let id = make_id(32);
1233        assert!(p.create_plan(vec![id], "node-1", 0).is_err());
1234    }
1235
1236    #[test]
1237    fn test_create_plan_pinned_block_errors() {
1238        let mut p = make_planner();
1239        let id = make_id(33);
1240        p.register_block(id, 512, "node-0", 0, 1.0);
1241        p.pin(&id).expect("ok");
1242        assert!(p.create_plan(vec![id], "node-1", 0).is_err());
1243    }
1244
1245    #[test]
1246    fn test_create_plan_increments_id() {
1247        let mut p = make_planner();
1248        for i in 0..5u8 {
1249            let id = make_id(40 + i);
1250            p.register_block(id, 512, "node-0", 0, 1.0);
1251            let pid = p.create_plan(vec![id], "node-1", 0).expect("ok");
1252            assert_eq!(pid, i as u64 + 1);
1253        }
1254    }
1255
1256    #[test]
1257    fn test_create_plan_multi_block_bytes() {
1258        let mut p = make_planner();
1259        let ids: Vec<_> = (0..3u8)
1260            .map(|i| {
1261                let id = make_id(50 + i);
1262                p.register_block(id, 1000, "node-0", 0, 1.0);
1263                id
1264            })
1265            .collect();
1266        let pid = p.create_plan(ids, "node-1", 0).expect("ok");
1267        let plan = p.plan(pid).expect("plan");
1268        assert_eq!(plan.estimated_bytes, 3000);
1269    }
1270
1271    // ── cancel_plan ───────────────────────────────────────────────────────────
1272
1273    #[test]
1274    fn test_cancel_pending_plan() {
1275        let mut p = make_planner();
1276        let id = make_id(60);
1277        p.register_block(id, 512, "node-0", 0, 1.0);
1278        let pid = p.create_plan(vec![id], "node-1", 0).expect("ok");
1279        p.cancel_plan(pid).expect("ok");
1280        assert_eq!(
1281            p.plan(pid).map(|pl| pl.status),
1282            Some(BmpPlanStatus::Cancelled)
1283        );
1284    }
1285
1286    #[test]
1287    fn test_cancel_nonexistent_plan_errors() {
1288        let mut p = make_planner();
1289        assert!(p.cancel_plan(999).is_err());
1290    }
1291
1292    #[test]
1293    fn test_cancel_already_cancelled_errors() {
1294        let mut p = make_planner();
1295        let id = make_id(61);
1296        p.register_block(id, 512, "node-0", 0, 1.0);
1297        let pid = p.create_plan(vec![id], "node-1", 0).expect("ok");
1298        p.cancel_plan(pid).expect("ok");
1299        assert!(p.cancel_plan(pid).is_err());
1300    }
1301
1302    // ── execute_plan ──────────────────────────────────────────────────────────
1303
1304    #[test]
1305    fn test_execute_plan_dry_run_always_succeeds() {
1306        let cfg = BmpPlannerConfig {
1307            dry_run: true,
1308            ..Default::default()
1309        };
1310        let mut p = BlockMigrationPlanner::with_config(cfg);
1311        let id = make_id(70);
1312        p.register_block(id, 512, "node-0", 0, 1.0);
1313        let pid = p.create_plan(vec![id], "node-1", 0).expect("ok");
1314        let result = p.execute_plan(pid).expect("ok");
1315        assert_eq!(result.status, BmpPlanStatus::Completed);
1316        assert_eq!(result.succeeded_blocks.len(), 1);
1317        assert!(result.failed_blocks.is_empty());
1318        assert_eq!(result.bytes_moved, 512);
1319    }
1320
1321    #[test]
1322    fn test_execute_plan_updates_log() {
1323        let cfg = BmpPlannerConfig {
1324            dry_run: true,
1325            ..Default::default()
1326        };
1327        let mut p = BlockMigrationPlanner::with_config(cfg);
1328        let id = make_id(71);
1329        p.register_block(id, 256, "node-0", 0, 1.0);
1330        let pid = p.create_plan(vec![id], "node-1", 0).expect("ok");
1331        p.execute_plan(pid).expect("ok");
1332        assert_eq!(p.log_len(), 1);
1333    }
1334
1335    #[test]
1336    fn test_execute_plan_updates_block_src_node() {
1337        let cfg = BmpPlannerConfig {
1338            dry_run: true,
1339            ..Default::default()
1340        };
1341        let mut p = BlockMigrationPlanner::with_config(cfg);
1342        let id = make_id(72);
1343        p.register_block(id, 512, "node-0", 0, 1.0);
1344        let pid = p.create_plan(vec![id], "node-2", 0).expect("ok");
1345        p.execute_plan(pid).expect("ok");
1346        let meta = p.block_meta(&id).expect("exists");
1347        assert_eq!(meta.src_node, "node-2");
1348    }
1349
1350    #[test]
1351    fn test_execute_plan_not_found_errors() {
1352        let mut p = make_planner();
1353        assert!(p.execute_plan(42).is_err());
1354    }
1355
1356    #[test]
1357    fn test_execute_plan_not_pending_errors() {
1358        let cfg = BmpPlannerConfig {
1359            dry_run: true,
1360            ..Default::default()
1361        };
1362        let mut p = BlockMigrationPlanner::with_config(cfg);
1363        let id = make_id(73);
1364        p.register_block(id, 512, "node-0", 0, 1.0);
1365        let pid = p.create_plan(vec![id], "node-1", 0).expect("ok");
1366        p.execute_plan(pid).expect("ok");
1367        // Already completed — cannot execute again.
1368        assert!(p.execute_plan(pid).is_err());
1369    }
1370
1371    #[test]
1372    fn test_execute_plan_multi_block_dry_run() {
1373        let cfg = BmpPlannerConfig {
1374            dry_run: true,
1375            ..Default::default()
1376        };
1377        let mut p = BlockMigrationPlanner::with_config(cfg);
1378        let ids: Vec<_> = (0..5u8)
1379            .map(|i| {
1380                let id = make_id(80 + i);
1381                p.register_block(id, 100, "node-0", 0, 1.0);
1382                id
1383            })
1384            .collect();
1385        let pid = p.create_plan(ids.clone(), "node-1", 0).expect("ok");
1386        let res = p.execute_plan(pid).expect("ok");
1387        assert_eq!(res.succeeded_blocks.len(), 5);
1388        assert_eq!(res.bytes_moved, 500);
1389    }
1390
1391    // ── schedule_migrations ───────────────────────────────────────────────────
1392
1393    #[test]
1394    fn test_schedule_migrations_no_eligible_blocks_errors() {
1395        let mut p = make_planner();
1396        // Register tier-0 blocks with no dst_node → not eligible.
1397        let id = make_id(90);
1398        p.register_block(id, 512, "node-0", 0, 1.0);
1399        let result = p.schedule_migrations(BmpPriorityPolicy::FifoQueue, 5);
1400        assert!(result.is_err());
1401    }
1402
1403    #[test]
1404    fn test_schedule_migrations_with_dst_node_set() {
1405        let mut p = make_planner();
1406        let id = make_id(91);
1407        p.register_block(id, 512, "node-0", 1, 1.0);
1408        // Set dst_node directly.
1409        if let Some(meta) = p.blocks.get_mut(&id) {
1410            meta.dst_node = Some("node-1".to_string());
1411        }
1412        let ids = p
1413            .schedule_migrations(BmpPriorityPolicy::FifoQueue, 5)
1414            .expect("ok");
1415        assert_eq!(ids.len(), 1);
1416    }
1417
1418    #[test]
1419    fn test_schedule_migrations_respects_max_plans() {
1420        let mut p = make_planner();
1421        for i in 0..10u8 {
1422            let id = make_id(100 + i);
1423            p.register_block(id, 512, "node-0", 1, 1.0);
1424            if let Some(meta) = p.blocks.get_mut(&id) {
1425                meta.dst_node = Some("node-1".to_string());
1426            }
1427        }
1428        let ids = p
1429            .schedule_migrations(BmpPriorityPolicy::FifoQueue, 3)
1430            .expect("ok");
1431        assert_eq!(ids.len(), 3);
1432    }
1433
1434    #[test]
1435    fn test_schedule_migrations_skips_pinned() {
1436        let mut p = make_planner();
1437        for i in 0..3u8 {
1438            let id = make_id(110 + i);
1439            p.register_block(id, 512, "node-0", 1, 1.0);
1440            if let Some(meta) = p.blocks.get_mut(&id) {
1441                meta.dst_node = Some("node-1".to_string());
1442            }
1443            if i == 0 {
1444                p.pin(&id).expect("ok");
1445            }
1446        }
1447        let ids = p
1448            .schedule_migrations(BmpPriorityPolicy::FifoQueue, 10)
1449            .expect("ok");
1450        // Only 2 unpinned blocks should become plans.
1451        assert_eq!(ids.len(), 2);
1452    }
1453
1454    #[test]
1455    fn test_schedule_migrations_zero_max_returns_empty() {
1456        let mut p = make_planner();
1457        let id = make_id(120);
1458        p.register_block(id, 512, "node-0", 1, 1.0);
1459        if let Some(meta) = p.blocks.get_mut(&id) {
1460            meta.dst_node = Some("node-1".to_string());
1461        }
1462        let ids = p
1463            .schedule_migrations(BmpPriorityPolicy::FifoQueue, 0)
1464            .expect("ok");
1465        assert!(ids.is_empty());
1466    }
1467
1468    #[test]
1469    fn test_schedule_largest_first_ordering() {
1470        let mut p = make_planner();
1471        let sizes = [100u64, 500, 200, 800, 50];
1472        let mut block_ids = Vec::new();
1473        for (i, &sz) in sizes.iter().enumerate() {
1474            let id = make_id(130 + i as u8);
1475            p.register_block(id, sz, "node-0", 1, 1.0);
1476            if let Some(meta) = p.blocks.get_mut(&id) {
1477                meta.dst_node = Some("node-1".to_string());
1478            }
1479            block_ids.push(id);
1480        }
1481        let pids = p
1482            .schedule_migrations(BmpPriorityPolicy::LargestFirst, 5)
1483            .expect("ok");
1484        // Collect estimated bytes for the first plan to verify ordering.
1485        let first_bytes = p.plan(pids[0]).map_or(0, |pl| pl.estimated_bytes);
1486        let last_bytes = p
1487            .plan(*pids.last().unwrap())
1488            .map_or(0, |pl| pl.estimated_bytes);
1489        assert!(
1490            first_bytes >= last_bytes,
1491            "largest first violated: {} < {}",
1492            first_bytes,
1493            last_bytes
1494        );
1495    }
1496
1497    #[test]
1498    fn test_schedule_smallest_first_ordering() {
1499        let mut p = make_planner();
1500        let sizes = [100u64, 500, 200, 800, 50];
1501        for (i, &sz) in sizes.iter().enumerate() {
1502            let id = make_id(140 + i as u8);
1503            p.register_block(id, sz, "node-0", 1, 1.0);
1504            if let Some(meta) = p.blocks.get_mut(&id) {
1505                meta.dst_node = Some("node-1".to_string());
1506            }
1507        }
1508        let pids = p
1509            .schedule_migrations(BmpPriorityPolicy::SmallestFirst, 5)
1510            .expect("ok");
1511        let first_bytes = p.plan(pids[0]).map_or(0, |pl| pl.estimated_bytes);
1512        let last_bytes = p
1513            .plan(*pids.last().unwrap())
1514            .map_or(0, |pl| pl.estimated_bytes);
1515        assert!(
1516            first_bytes <= last_bytes,
1517            "smallest first violated: {} > {}",
1518            first_bytes,
1519            last_bytes
1520        );
1521    }
1522
1523    #[test]
1524    fn test_schedule_high_frequency_first() {
1525        let mut p = make_planner();
1526        let freqs = [0.1f64, 5.0, 2.0];
1527        let mut bid_map: Vec<(BmpBlockId, f64)> = Vec::new();
1528        for (i, &f) in freqs.iter().enumerate() {
1529            let id = make_id(150 + i as u8);
1530            p.register_block(id, 512, "node-0", 1, f);
1531            if let Some(meta) = p.blocks.get_mut(&id) {
1532                meta.dst_node = Some("node-1".to_string());
1533            }
1534            bid_map.push((id, f));
1535        }
1536        let pids = p
1537            .schedule_migrations(BmpPriorityPolicy::HighFrequencyFirst, 3)
1538            .expect("ok");
1539        // The first plan should contain the block with the highest frequency (5.0).
1540        let first_plan_bid = p.plan(pids[0]).and_then(|pl| pl.block_ids.first().copied());
1541        if let Some(bid) = first_plan_bid {
1542            let freq = p.block_meta(&bid).map_or(0.0, |m| m.access_frequency);
1543            // Highest frequency among scheduled should be 5.0.
1544            assert!(
1545                (freq - 5.0).abs() < 1e-6 || freq >= 2.0,
1546                "unexpected freq {}",
1547                freq
1548            );
1549        }
1550    }
1551
1552    // ── run_batch_migration ───────────────────────────────────────────────────
1553
1554    #[test]
1555    fn test_run_batch_migration_dry_run() {
1556        let cfg = BmpPlannerConfig {
1557            dry_run: true,
1558            max_concurrent_migrations: 10,
1559            ..Default::default()
1560        };
1561        let mut p = BlockMigrationPlanner::with_config(cfg);
1562        let _ids = register_n(&mut p, 5, "node-0");
1563        for id in &_ids {
1564            if let Some(meta) = p.blocks.get_mut(id) {
1565                meta.dst_node = Some("node-1".to_string());
1566                meta.tier = 1;
1567            }
1568        }
1569        let batch = p.run_batch_migration(BmpPriorityPolicy::FifoQueue, 5);
1570        assert!(batch.plans_executed > 0);
1571        assert_eq!(batch.plans_failed, 0);
1572    }
1573
1574    #[test]
1575    fn test_run_batch_migration_empty_result_on_no_candidates() {
1576        let mut p = make_planner();
1577        // Register only tier-0 blocks (no dst_node).
1578        let id = make_id(160);
1579        p.register_block(id, 512, "node-0", 0, 1.0);
1580        let batch = p.run_batch_migration(BmpPriorityPolicy::FifoQueue, 5);
1581        assert_eq!(batch.plans_executed, 0);
1582    }
1583
1584    #[test]
1585    fn test_run_batch_migration_respects_max_concurrent() {
1586        let cfg = BmpPlannerConfig {
1587            dry_run: true,
1588            max_concurrent_migrations: 2,
1589            ..Default::default()
1590        };
1591        let mut p = BlockMigrationPlanner::with_config(cfg);
1592        let _ids = register_n(&mut p, 10, "node-0");
1593        for id in &_ids {
1594            if let Some(meta) = p.blocks.get_mut(id) {
1595                meta.dst_node = Some("node-1".to_string());
1596                meta.tier = 1;
1597            }
1598        }
1599        let batch = p.run_batch_migration(BmpPriorityPolicy::FifoQueue, 10);
1600        assert!(batch.plans_executed <= 2);
1601    }
1602
1603    #[test]
1604    fn test_run_batch_total_bytes_moved() {
1605        let cfg = BmpPlannerConfig {
1606            dry_run: true,
1607            max_concurrent_migrations: 5,
1608            ..Default::default()
1609        };
1610        let mut p = BlockMigrationPlanner::with_config(cfg);
1611        for i in 0..5u8 {
1612            let id = make_id(170 + i);
1613            p.register_block(id, 1000, "node-0", 1, 1.0);
1614            if let Some(meta) = p.blocks.get_mut(&id) {
1615                meta.dst_node = Some("node-1".to_string());
1616            }
1617        }
1618        let batch = p.run_batch_migration(BmpPriorityPolicy::FifoQueue, 5);
1619        assert_eq!(
1620            batch.total_bytes_moved,
1621            (batch.plans_completed as u64) * 1000
1622        );
1623    }
1624
1625    // ── defragment_plan ───────────────────────────────────────────────────────
1626
1627    #[test]
1628    fn test_defragment_plan_empty_nodes() {
1629        let mut p = make_planner();
1630        let result = p.defragment_plan(&[]);
1631        assert!(result.is_empty());
1632    }
1633
1634    #[test]
1635    fn test_defragment_plan_already_balanced() {
1636        let mut p = make_planner();
1637        let nodes = vec!["node-0".to_string(), "node-1".to_string()];
1638        for i in 0..4u8 {
1639            let node = if i < 2 { "node-0" } else { "node-1" };
1640            let id = make_id(180 + i);
1641            p.register_block(id, 512, node, 0, 1.0);
1642        }
1643        let plans = p.defragment_plan(&nodes);
1644        // Already balanced (2 each) — no plans needed.
1645        assert!(plans.is_empty());
1646    }
1647
1648    #[test]
1649    fn test_defragment_plan_imbalanced() {
1650        let mut p = make_planner();
1651        let nodes = vec!["node-0".to_string(), "node-1".to_string()];
1652        // 4 blocks on node-0, 0 on node-1.
1653        for i in 0..4u8 {
1654            let id = make_id(190 + i);
1655            p.register_block(id, 512, "node-0", 0, 1.0);
1656        }
1657        let plans = p.defragment_plan(&nodes);
1658        assert!(!plans.is_empty());
1659    }
1660
1661    #[test]
1662    fn test_defragment_plan_skips_pinned() {
1663        let mut p = make_planner();
1664        let nodes = vec!["node-0".to_string(), "node-1".to_string()];
1665        for i in 0..4u8 {
1666            let id = make_id(200 + i);
1667            p.register_block(id, 512, "node-0", 0, 1.0);
1668            if i < 2 {
1669                p.pin(&id).expect("ok");
1670            }
1671        }
1672        let plans = p.defragment_plan(&nodes);
1673        // Only 2 movable blocks; moving up to 2 plans.
1674        assert!(plans.len() <= 2);
1675    }
1676
1677    #[test]
1678    fn test_defragment_plan_single_node_no_plans() {
1679        let mut p = make_planner();
1680        let nodes = vec!["node-0".to_string()];
1681        for i in 0..3u8 {
1682            let id = make_id(210 + i);
1683            p.register_block(id, 512, "node-0", 0, 1.0);
1684        }
1685        // With a single node, over == under logic finds neither.
1686        let plans = p.defragment_plan(&nodes);
1687        assert!(plans.is_empty());
1688    }
1689
1690    // ── migration_stats ───────────────────────────────────────────────────────
1691
1692    #[test]
1693    fn test_migration_stats_empty() {
1694        let p = make_planner();
1695        let stats = p.migration_stats();
1696        assert_eq!(stats.total_blocks, 0);
1697        assert_eq!(stats.total_plans, 0);
1698        assert_eq!(stats.total_records, 0);
1699        assert_eq!(stats.total_bytes_moved, 0);
1700    }
1701
1702    #[test]
1703    fn test_migration_stats_after_register() {
1704        let mut p = make_planner();
1705        for i in 0..5u8 {
1706            p.register_block(make_id(220 + i), 512, "node-0", 0, 1.0);
1707        }
1708        let stats = p.migration_stats();
1709        assert_eq!(stats.total_blocks, 5);
1710    }
1711
1712    #[test]
1713    fn test_migration_stats_pinned_count() {
1714        let mut p = make_planner();
1715        for i in 0..4u8 {
1716            let id = make_id(230 + i);
1717            p.register_block(id, 512, "node-0", 0, 1.0);
1718            if i < 2 {
1719                p.pin(&id).expect("ok");
1720            }
1721        }
1722        let stats = p.migration_stats();
1723        assert_eq!(stats.pinned_blocks, 2);
1724    }
1725
1726    #[test]
1727    fn test_migration_stats_plan_counts() {
1728        let cfg = BmpPlannerConfig {
1729            dry_run: true,
1730            ..Default::default()
1731        };
1732        let mut p = BlockMigrationPlanner::with_config(cfg);
1733        for i in 0..3u8 {
1734            let id = make_id(240 + i);
1735            p.register_block(id, 512, "node-0", 0, 1.0);
1736            p.create_plan(vec![id], "node-1", 0).expect("ok");
1737        }
1738        let stats = p.migration_stats();
1739        assert_eq!(stats.total_plans, 3);
1740        assert_eq!(stats.pending_plans, 3);
1741    }
1742
1743    #[test]
1744    fn test_migration_stats_bytes_moved() {
1745        let cfg = BmpPlannerConfig {
1746            dry_run: true,
1747            ..Default::default()
1748        };
1749        let mut p = BlockMigrationPlanner::with_config(cfg);
1750        let id = make_id(250);
1751        p.register_block(id, 4096, "node-0", 0, 1.0);
1752        let pid = p.create_plan(vec![id], "node-1", 0).expect("ok");
1753        p.execute_plan(pid).expect("ok");
1754        let stats = p.migration_stats();
1755        assert_eq!(stats.total_bytes_moved, 4096);
1756    }
1757
1758    #[test]
1759    fn test_migration_stats_cancelled_count() {
1760        let mut p = make_planner();
1761        for i in 200..203u8 {
1762            let id = make_id(i);
1763            p.register_block(id, 512, "node-0", 0, 1.0);
1764            let pid = p.create_plan(vec![id], "node-1", 0).expect("ok");
1765            p.cancel_plan(pid).expect("ok");
1766        }
1767        let stats = p.migration_stats();
1768        assert_eq!(stats.cancelled_plans, 3);
1769    }
1770
1771    // ── execution log / recent_records ───────────────────────────────────────
1772
1773    #[test]
1774    fn test_log_bounded_at_1000() {
1775        let cfg = BmpPlannerConfig {
1776            dry_run: true,
1777            ..Default::default()
1778        };
1779        let mut p = BlockMigrationPlanner::with_config(cfg);
1780        // Create 1100 single-block plans and execute them all.
1781        for i in 0..1100u16 {
1782            let id = bmp_id_from_bytes(&i.to_le_bytes());
1783            // Re-register (overwrite) to avoid needing unique keys.
1784            p.register_block(id, 1, "node-0", 0, 1.0);
1785            if let Ok(pid) = p.create_plan(vec![id], "node-1", 0) {
1786                let _ = p.execute_plan(pid);
1787            }
1788        }
1789        assert_eq!(p.log_len(), 1000);
1790    }
1791
1792    #[test]
1793    fn test_recent_records_returns_n() {
1794        let cfg = BmpPlannerConfig {
1795            dry_run: true,
1796            ..Default::default()
1797        };
1798        let mut p = BlockMigrationPlanner::with_config(cfg);
1799        for i in 0..10u8 {
1800            let id = make_id(i);
1801            p.register_block(id, 512, "node-0", 0, 1.0);
1802            if let Ok(pid) = p.create_plan(vec![id], "node-1", 0) {
1803                let _ = p.execute_plan(pid);
1804            }
1805        }
1806        assert_eq!(p.recent_records(5).len(), 5);
1807    }
1808
1809    #[test]
1810    fn test_plans_iter() {
1811        let mut p = make_planner();
1812        for i in 0..4u8 {
1813            let id = make_id(i);
1814            p.register_block(id, 512, "node-0", 0, 1.0);
1815            p.create_plan(vec![id], "node-1", 0).expect("ok");
1816        }
1817        assert_eq!(p.plans_iter().count(), 4);
1818    }
1819
1820    // ── BmpPlanStatus Display ─────────────────────────────────────────────────
1821
1822    #[test]
1823    fn test_plan_status_display() {
1824        assert_eq!(BmpPlanStatus::Pending.to_string(), "Pending");
1825        assert_eq!(BmpPlanStatus::InProgress.to_string(), "InProgress");
1826        assert_eq!(BmpPlanStatus::Completed.to_string(), "Completed");
1827        assert_eq!(BmpPlanStatus::Failed.to_string(), "Failed");
1828        assert_eq!(BmpPlanStatus::Cancelled.to_string(), "Cancelled");
1829    }
1830
1831    // ── BmpPriorityPolicy Display ─────────────────────────────────────────────
1832
1833    #[test]
1834    fn test_priority_policy_display() {
1835        assert_eq!(BmpPriorityPolicy::FifoQueue.to_string(), "FifoQueue");
1836        assert_eq!(
1837            BmpPriorityPolicy::HighFrequencyFirst.to_string(),
1838            "HighFrequencyFirst"
1839        );
1840        assert_eq!(BmpPriorityPolicy::LargestFirst.to_string(), "LargestFirst");
1841        assert_eq!(
1842            BmpPriorityPolicy::SmallestFirst.to_string(),
1843            "SmallestFirst"
1844        );
1845        assert_eq!(
1846            BmpPriorityPolicy::CostOptimized.to_string(),
1847            "CostOptimized"
1848        );
1849    }
1850
1851    // ── Default impls ─────────────────────────────────────────────────────────
1852
1853    #[test]
1854    fn test_planner_default() {
1855        let p = BlockMigrationPlanner::default();
1856        assert_eq!(p.block_count(), 0);
1857    }
1858
1859    #[test]
1860    fn test_config_default() {
1861        let cfg = BmpPlannerConfig::default();
1862        assert_eq!(cfg.max_concurrent_migrations, 4);
1863        assert_eq!(cfg.bandwidth_limit_kbps, 0);
1864        assert!(!cfg.dry_run);
1865    }
1866
1867    #[test]
1868    fn test_batch_result_default() {
1869        let b = BmpBatchResult::default();
1870        assert_eq!(b.plans_executed, 0);
1871        assert_eq!(b.total_bytes_moved, 0);
1872    }
1873
1874    #[test]
1875    fn test_planner_stats_default() {
1876        let s = BmpPlannerStats::default();
1877        assert_eq!(s.total_blocks, 0);
1878        assert_eq!(s.total_failures, 0);
1879    }
1880
1881    // ── cost-optimized scheduling ─────────────────────────────────────────────
1882
1883    #[test]
1884    fn test_schedule_cost_optimized() {
1885        let mut p = make_planner();
1886        // tier 3, size 100 => cost 300; tier 1, size 500 => cost 500
1887        let id_a = make_id(20);
1888        let id_b = make_id(21);
1889        p.register_block(id_a, 100, "node-0", 3, 1.0);
1890        p.register_block(id_b, 500, "node-0", 1, 1.0);
1891        for id in [id_a, id_b] {
1892            if let Some(m) = p.blocks.get_mut(&id) {
1893                m.dst_node = Some("node-1".to_string());
1894            }
1895        }
1896        let pids = p
1897            .schedule_migrations(BmpPriorityPolicy::CostOptimized, 2)
1898            .expect("ok");
1899        // First plan should be the cheaper one (id_a, cost 300).
1900        let first_plan = p.plan(pids[0]).expect("plan");
1901        let first_bid = first_plan.block_ids[0];
1902        let first_meta = p.block_meta(&first_bid).expect("meta");
1903        let first_cost = first_meta.size_bytes * first_meta.tier as u64;
1904        // Should be <= the other block's cost.
1905        assert!(
1906            first_cost <= 500,
1907            "cost optimized ordering failed: {} > 500",
1908            first_cost
1909        );
1910    }
1911}