1#[derive(Clone, Debug, PartialEq)]
31pub enum MoveStatus {
32 Pending,
34 InProgress {
36 started_at_secs: u64,
38 },
39 Completed {
41 finished_at_secs: u64,
43 },
44 Failed {
46 reason: String,
48 },
49}
50
51#[derive(Debug, Clone)]
57pub struct ShardLoad {
58 pub shard_id: String,
60 pub vector_count: u64,
62 pub capacity: u64,
64}
65
66impl ShardLoad {
67 pub fn load_factor(&self) -> f64 {
72 self.vector_count as f64 / self.capacity.max(1) as f64
73 }
74
75 pub fn is_overloaded(&self, threshold: f64) -> bool {
77 self.load_factor() > threshold
78 }
79
80 pub fn is_underloaded(&self, threshold: f64) -> bool {
82 self.load_factor() < threshold
83 }
84
85 pub fn available_capacity(&self) -> u64 {
89 self.capacity.saturating_sub(self.vector_count)
90 }
91}
92
93#[derive(Debug, Clone)]
100pub struct MoveTask {
101 pub task_id: u64,
103 pub from_shard: String,
105 pub to_shard: String,
107 pub vector_count: u64,
109 pub status: MoveStatus,
111}
112
113#[derive(Debug, Clone)]
119pub struct RebalancePlan {
120 pub tasks: Vec<MoveTask>,
122 pub estimated_moves: u64,
124}
125
126impl RebalancePlan {
127 pub fn pending_tasks(&self) -> usize {
129 self.tasks
130 .iter()
131 .filter(|t| t.status == MoveStatus::Pending)
132 .count()
133 }
134
135 pub fn completed_tasks(&self) -> usize {
137 self.tasks
138 .iter()
139 .filter(|t| matches!(t.status, MoveStatus::Completed { .. }))
140 .count()
141 }
142
143 pub fn is_complete(&self) -> bool {
146 self.tasks.iter().all(|t| {
147 matches!(
148 t.status,
149 MoveStatus::Completed { .. } | MoveStatus::Failed { .. }
150 )
151 })
152 }
153}
154
155#[derive(Debug, Clone)]
161pub struct RebalancerConfig {
162 pub overload_threshold: f64,
164 pub underload_threshold: f64,
166 pub max_moves_per_task: u64,
169}
170
171impl Default for RebalancerConfig {
172 fn default() -> Self {
173 Self {
174 overload_threshold: 0.85,
175 underload_threshold: 0.40,
176 max_moves_per_task: 10_000,
177 }
178 }
179}
180
181#[derive(Debug)]
205pub struct EmbeddingIndexRebalancer {
206 pub config: RebalancerConfig,
208 pub plans: Vec<RebalancePlan>,
210 pub next_task_id: u64,
212}
213
214impl EmbeddingIndexRebalancer {
215 pub fn new(config: RebalancerConfig) -> Self {
217 Self {
218 config,
219 plans: Vec::new(),
220 next_task_id: 0,
221 }
222 }
223
224 pub fn plan_rebalance(&mut self, shards: &[ShardLoad]) -> RebalancePlan {
240 let overloaded: Vec<&ShardLoad> = shards
241 .iter()
242 .filter(|s| s.is_overloaded(self.config.overload_threshold))
243 .collect();
244
245 let mut underloaded: Vec<ShardLoad> = shards
248 .iter()
249 .filter(|s| s.is_underloaded(self.config.underload_threshold))
250 .cloned()
251 .collect();
252 underloaded.sort_by_key(|b| std::cmp::Reverse(b.available_capacity()));
253
254 let mut tasks: Vec<MoveTask> = Vec::new();
255
256 'overloaded: for src in &overloaded {
257 let excess = {
259 let e = (src.load_factor() - self.config.underload_threshold) * src.capacity as f64;
260 e.ceil() as u64
261 };
262
263 let mut remaining = excess;
264
265 for dst in underloaded.iter_mut() {
267 if remaining == 0 {
268 break;
269 }
270
271 let available = dst.available_capacity();
272 if available == 0 {
273 continue;
274 }
275
276 let to_move = remaining.min(available);
278 remaining = remaining.saturating_sub(to_move);
279
280 dst.vector_count = dst.vector_count.saturating_add(to_move).min(dst.capacity);
283
284 let cap = self.config.max_moves_per_task;
286 let mut left = to_move;
287 while left > 0 {
288 let chunk = left.min(cap);
289 left = left.saturating_sub(chunk);
290
291 tasks.push(MoveTask {
292 task_id: self.next_task_id,
293 from_shard: src.shard_id.clone(),
294 to_shard: dst.shard_id.clone(),
295 vector_count: chunk,
296 status: MoveStatus::Pending,
297 });
298 self.next_task_id += 1;
299 }
300
301 if remaining == 0 {
302 continue 'overloaded;
303 }
304 }
305 }
308
309 let estimated_moves: u64 = tasks.iter().map(|t| t.vector_count).sum();
310
311 let plan = RebalancePlan {
312 tasks,
313 estimated_moves,
314 };
315
316 self.plans.push(plan.clone());
317 plan
318 }
319
320 pub fn update_task_status(&mut self, task_id: u64, status: MoveStatus) -> bool {
325 for plan in self.plans.iter_mut() {
326 for task in plan.tasks.iter_mut() {
327 if task.task_id == task_id {
328 task.status = status;
329 return true;
330 }
331 }
332 }
333 false
334 }
335
336 pub fn active_plan(&self) -> Option<&RebalancePlan> {
338 self.plans
339 .last()
340 .and_then(|p| if p.is_complete() { None } else { Some(p) })
341 }
342
343 pub fn completed_plans(&self) -> usize {
346 self.plans.iter().filter(|p| p.is_complete()).count()
347 }
348
349 pub fn stats(&self) -> (usize, u64) {
351 let total_tasks: usize = self.plans.iter().map(|p| p.tasks.len()).sum();
352 let total_moves: u64 = self.plans.iter().map(|p| p.estimated_moves).sum();
353 (total_tasks, total_moves)
354 }
355}
356
357#[cfg(test)]
362mod tests {
363 use super::*;
364
365 fn default_rebalancer() -> EmbeddingIndexRebalancer {
366 EmbeddingIndexRebalancer::new(RebalancerConfig::default())
367 }
368
369 #[test]
371 fn test_new_with_config() {
372 let cfg = RebalancerConfig {
373 overload_threshold: 0.9,
374 underload_threshold: 0.3,
375 max_moves_per_task: 5_000,
376 };
377 let rb = EmbeddingIndexRebalancer::new(cfg.clone());
378 assert_eq!(rb.config.overload_threshold, cfg.overload_threshold);
379 assert_eq!(rb.config.underload_threshold, cfg.underload_threshold);
380 assert_eq!(rb.config.max_moves_per_task, cfg.max_moves_per_task);
381 assert!(rb.plans.is_empty());
382 assert_eq!(rb.next_task_id, 0);
383 }
384
385 #[test]
387 fn test_load_factor() {
388 let s = ShardLoad {
389 shard_id: "s0".into(),
390 vector_count: 500,
391 capacity: 1_000,
392 };
393 assert!((s.load_factor() - 0.5).abs() < f64::EPSILON);
394 }
395
396 #[test]
398 fn test_load_factor_zero_capacity() {
399 let s = ShardLoad {
400 shard_id: "s0".into(),
401 vector_count: 0,
402 capacity: 0,
403 };
404 assert_eq!(s.load_factor(), 0.0);
405 }
406
407 #[test]
409 fn test_is_overloaded() {
410 let s = ShardLoad {
411 shard_id: "s0".into(),
412 vector_count: 900,
413 capacity: 1_000,
414 };
415 assert!(s.is_overloaded(0.85));
416 assert!(!s.is_overloaded(0.95));
417 }
418
419 #[test]
421 fn test_is_underloaded() {
422 let s = ShardLoad {
423 shard_id: "s0".into(),
424 vector_count: 300,
425 capacity: 1_000,
426 };
427 assert!(s.is_underloaded(0.40));
428 assert!(!s.is_underloaded(0.20));
429 }
430
431 #[test]
433 fn test_available_capacity_saturating() {
434 let s = ShardLoad {
435 shard_id: "s0".into(),
436 vector_count: 1_200,
437 capacity: 1_000,
438 };
439 assert_eq!(s.available_capacity(), 0);
440
441 let s2 = ShardLoad {
442 shard_id: "s1".into(),
443 vector_count: 400,
444 capacity: 1_000,
445 };
446 assert_eq!(s2.available_capacity(), 600);
447 }
448
449 #[test]
451 fn test_plan_no_action_needed() {
452 let mut rb = default_rebalancer();
453 let shards = vec![
454 ShardLoad {
455 shard_id: "s0".into(),
456 vector_count: 600,
457 capacity: 1_000,
458 },
459 ShardLoad {
460 shard_id: "s1".into(),
461 vector_count: 650,
462 capacity: 1_000,
463 },
464 ];
465 let plan = rb.plan_rebalance(&shards);
466 assert!(plan.tasks.is_empty());
467 assert_eq!(plan.estimated_moves, 0);
468 }
469
470 #[test]
472 fn test_plan_one_overloaded_one_underloaded() {
473 let mut rb = default_rebalancer();
474 let shards = vec![
475 ShardLoad {
476 shard_id: "over".into(),
477 vector_count: 9_000,
478 capacity: 10_000,
479 }, ShardLoad {
481 shard_id: "under".into(),
482 vector_count: 1_000,
483 capacity: 10_000,
484 }, ];
486 let plan = rb.plan_rebalance(&shards);
487 assert!(!plan.tasks.is_empty());
488 assert_eq!(plan.tasks[0].from_shard, "over");
489 assert_eq!(plan.tasks[0].to_shard, "under");
490 assert!(plan.estimated_moves > 0);
491 }
492
493 #[test]
495 fn test_plan_excess_splits_into_multiple_tasks() {
496 let cfg = RebalancerConfig {
497 overload_threshold: 0.5,
498 underload_threshold: 0.1,
499 max_moves_per_task: 1_000,
500 };
501 let mut rb = EmbeddingIndexRebalancer::new(cfg);
502 let shards = vec![
503 ShardLoad {
504 shard_id: "src".into(),
505 vector_count: 8_000,
506 capacity: 10_000,
507 }, ShardLoad {
509 shard_id: "dst".into(),
510 vector_count: 500,
511 capacity: 10_000,
512 }, ];
514 let plan = rb.plan_rebalance(&shards);
515 assert!(plan.tasks.len() >= 2, "expected multiple tasks");
517 for t in &plan.tasks {
518 assert!(t.vector_count <= 1_000, "task exceeds max_moves_per_task");
519 }
520 }
521
522 #[test]
524 fn test_task_ids_monotonically_increasing() {
525 let mut rb = default_rebalancer();
526 let shards = vec![
527 ShardLoad {
528 shard_id: "over".into(),
529 vector_count: 9_000,
530 capacity: 10_000,
531 },
532 ShardLoad {
533 shard_id: "under".into(),
534 vector_count: 1_000,
535 capacity: 10_000,
536 },
537 ];
538 let plan1 = rb.plan_rebalance(&shards);
539 let plan2 = rb.plan_rebalance(&shards);
540
541 let ids1: Vec<u64> = plan1.tasks.iter().map(|t| t.task_id).collect();
542 let ids2: Vec<u64> = plan2.tasks.iter().map(|t| t.task_id).collect();
543
544 for w in ids1.windows(2) {
546 assert!(w[0] < w[1]);
547 }
548 for w in ids2.windows(2) {
549 assert!(w[0] < w[1]);
550 }
551
552 if let (Some(&last1), Some(&first2)) = (ids1.last(), ids2.first()) {
555 assert!(first2 > last1);
556 }
557 }
558
559 #[test]
561 fn test_estimated_moves_correct() {
562 let mut rb = default_rebalancer();
563 let shards = vec![
564 ShardLoad {
565 shard_id: "over".into(),
566 vector_count: 9_000,
567 capacity: 10_000,
568 },
569 ShardLoad {
570 shard_id: "under".into(),
571 vector_count: 1_000,
572 capacity: 10_000,
573 },
574 ];
575 let plan = rb.plan_rebalance(&shards);
576 let sum: u64 = plan.tasks.iter().map(|t| t.vector_count).sum();
577 assert_eq!(plan.estimated_moves, sum);
578 }
579
580 #[test]
582 fn test_plan_history_pushed() {
583 let mut rb = default_rebalancer();
584 let shards = vec![ShardLoad {
585 shard_id: "s0".into(),
586 vector_count: 600,
587 capacity: 1_000,
588 }];
589 rb.plan_rebalance(&shards);
590 rb.plan_rebalance(&shards);
591 assert_eq!(rb.plans.len(), 2);
592 }
593
594 #[test]
596 fn test_update_task_status_found() {
597 let mut rb = default_rebalancer();
598 let shards = vec![
599 ShardLoad {
600 shard_id: "over".into(),
601 vector_count: 9_000,
602 capacity: 10_000,
603 },
604 ShardLoad {
605 shard_id: "under".into(),
606 vector_count: 1_000,
607 capacity: 10_000,
608 },
609 ];
610 let plan = rb.plan_rebalance(&shards);
611 let tid = plan.tasks[0].task_id;
612 let found = rb.update_task_status(
613 tid,
614 MoveStatus::Completed {
615 finished_at_secs: 1_000,
616 },
617 );
618 assert!(found);
619 let updated = rb
621 .plans
622 .iter()
623 .flat_map(|p| p.tasks.iter())
624 .find(|t| t.task_id == tid)
625 .expect("task must exist");
626 assert!(matches!(
627 updated.status,
628 MoveStatus::Completed {
629 finished_at_secs: 1_000
630 }
631 ));
632 }
633
634 #[test]
636 fn test_update_task_status_not_found() {
637 let mut rb = default_rebalancer();
638 let found = rb.update_task_status(9999, MoveStatus::Pending);
639 assert!(!found);
640 }
641
642 #[test]
644 fn test_is_complete_all_completed() {
645 let tasks = vec![
646 MoveTask {
647 task_id: 0,
648 from_shard: "a".into(),
649 to_shard: "b".into(),
650 vector_count: 100,
651 status: MoveStatus::Completed {
652 finished_at_secs: 1,
653 },
654 },
655 MoveTask {
656 task_id: 1,
657 from_shard: "a".into(),
658 to_shard: "b".into(),
659 vector_count: 200,
660 status: MoveStatus::Failed {
661 reason: "timeout".into(),
662 },
663 },
664 ];
665 let plan = RebalancePlan {
666 estimated_moves: 300,
667 tasks,
668 };
669 assert!(plan.is_complete());
670 }
671
672 #[test]
674 fn test_is_complete_with_pending() {
675 let tasks = vec![MoveTask {
676 task_id: 0,
677 from_shard: "a".into(),
678 to_shard: "b".into(),
679 vector_count: 100,
680 status: MoveStatus::Pending,
681 }];
682 let plan = RebalancePlan {
683 estimated_moves: 100,
684 tasks,
685 };
686 assert!(!plan.is_complete());
687 }
688
689 #[test]
691 fn test_pending_and_completed_counts() {
692 let tasks = vec![
693 MoveTask {
694 task_id: 0,
695 from_shard: "a".into(),
696 to_shard: "b".into(),
697 vector_count: 100,
698 status: MoveStatus::Pending,
699 },
700 MoveTask {
701 task_id: 1,
702 from_shard: "a".into(),
703 to_shard: "b".into(),
704 vector_count: 100,
705 status: MoveStatus::Pending,
706 },
707 MoveTask {
708 task_id: 2,
709 from_shard: "a".into(),
710 to_shard: "b".into(),
711 vector_count: 100,
712 status: MoveStatus::Completed {
713 finished_at_secs: 42,
714 },
715 },
716 ];
717 let plan = RebalancePlan {
718 estimated_moves: 300,
719 tasks,
720 };
721 assert_eq!(plan.pending_tasks(), 2);
722 assert_eq!(plan.completed_tasks(), 1);
723 }
724
725 #[test]
727 fn test_active_plan_none_when_complete() {
728 let mut rb = default_rebalancer();
729 let shards = vec![
730 ShardLoad {
731 shard_id: "over".into(),
732 vector_count: 9_000,
733 capacity: 10_000,
734 },
735 ShardLoad {
736 shard_id: "under".into(),
737 vector_count: 1_000,
738 capacity: 10_000,
739 },
740 ];
741 let plan = rb.plan_rebalance(&shards);
742
743 for t in &plan.tasks {
745 rb.update_task_status(
746 t.task_id,
747 MoveStatus::Completed {
748 finished_at_secs: 100,
749 },
750 );
751 }
752
753 assert!(rb.active_plan().is_none());
754 }
755
756 #[test]
758 fn test_active_plan_some_when_pending() {
759 let mut rb = default_rebalancer();
760 let shards = vec![
761 ShardLoad {
762 shard_id: "over".into(),
763 vector_count: 9_000,
764 capacity: 10_000,
765 },
766 ShardLoad {
767 shard_id: "under".into(),
768 vector_count: 1_000,
769 capacity: 10_000,
770 },
771 ];
772 rb.plan_rebalance(&shards);
773 assert!(rb.active_plan().is_some());
774 }
775
776 #[test]
778 fn test_completed_plans_count() {
779 let mut rb = default_rebalancer();
780 let shards_balanced = vec![ShardLoad {
782 shard_id: "s0".into(),
783 vector_count: 600,
784 capacity: 1_000,
785 }];
786 rb.plan_rebalance(&shards_balanced);
787 rb.plan_rebalance(&shards_balanced);
788 assert_eq!(rb.completed_plans(), 2);
790 }
791
792 #[test]
794 fn test_stats() {
795 let mut rb = default_rebalancer();
796 let shards = vec![
797 ShardLoad {
798 shard_id: "over".into(),
799 vector_count: 9_000,
800 capacity: 10_000,
801 },
802 ShardLoad {
803 shard_id: "under".into(),
804 vector_count: 1_000,
805 capacity: 10_000,
806 },
807 ];
808 rb.plan_rebalance(&shards);
809 rb.plan_rebalance(&shards);
810
811 let (total_tasks, total_moves) = rb.stats();
812 let expected_tasks: usize = rb.plans.iter().map(|p| p.tasks.len()).sum();
813 let expected_moves: u64 = rb.plans.iter().map(|p| p.estimated_moves).sum();
814 assert_eq!(total_tasks, expected_tasks);
815 assert_eq!(total_moves, expected_moves);
816 }
817}