1use std::collections::HashMap;
44use std::sync::Arc;
45use std::time::{Duration, Instant};
46
47use serde::{Deserialize, Serialize};
48use tokio::sync::broadcast;
49use tracing::{debug, info, warn};
50
51use super::algorithms::{BinPackScheduler, SchedulingAlgorithm};
52use super::{NodeResources, ResourceRequirements, Workload};
53use crate::autoscaler::{Autoscaler, MetricsSnapshot, ScalingDecision};
54use crate::error::Result;
55use crate::job::{Job, TaskGroup};
56use crate::storage::{keys, store_get_json, store_set_json, BoxedStateStore};
57use crate::types::NodeId;
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
61pub enum TaskStatus {
62 Pending,
64 Running,
66 Failed,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct Assignment {
73 pub workload_id: String,
75 pub job_id: String,
77 pub group: String,
79 pub ordinal: u32,
81 pub node: NodeId,
83 pub gpu_ids: Vec<u32>,
85 pub resources: ResourceRequirements,
87 pub status: TaskStatus,
89}
90
91#[derive(Debug, Clone)]
93struct BackoffState {
94 attempts: u32,
95 next_try: Instant,
96}
97
98pub trait MetricsSource: Send + Sync {
105 fn job_metrics(&self, job_id: &str) -> Option<(f64, f64)>;
107}
108
109#[derive(Debug, Clone, Default, PartialEq, Eq)]
111pub struct ReconcileReport {
112 pub scheduled: usize,
114 pub released: usize,
116 pub pending: usize,
118 pub rescaled: usize,
120}
121
122pub struct Reconciler {
124 store: BoxedStateStore,
125 autoscaler: Arc<Autoscaler>,
126 algorithm: Box<dyn SchedulingAlgorithm>,
127 metrics_source: Option<Arc<dyn MetricsSource>>,
128 nodes: HashMap<NodeId, NodeResources>,
130 bound: HashMap<String, Assignment>,
132 backoff: HashMap<String, BackoffState>,
134 interval: Duration,
136 max_backoff: Duration,
138}
139
140impl Reconciler {
141 pub fn new(store: BoxedStateStore, autoscaler: Arc<Autoscaler>) -> Self {
144 Self {
145 store,
146 autoscaler,
147 algorithm: Box::new(BinPackScheduler::new()),
148 metrics_source: None,
149 nodes: HashMap::new(),
150 bound: HashMap::new(),
151 backoff: HashMap::new(),
152 interval: Duration::from_secs(5),
153 max_backoff: Duration::from_secs(300),
154 }
155 }
156
157 pub fn with_algorithm<A: SchedulingAlgorithm + 'static>(mut self, algorithm: A) -> Self {
159 self.algorithm = Box::new(algorithm);
160 self
161 }
162
163 pub fn with_metrics_source(mut self, source: Arc<dyn MetricsSource>) -> Self {
165 self.metrics_source = Some(source);
166 self
167 }
168
169 pub fn with_interval(mut self, interval: Duration) -> Self {
171 self.interval = interval;
172 self
173 }
174
175 pub fn register_node(&mut self, node: NodeResources) {
177 self.nodes.insert(node.node_id, node);
178 }
179
180 pub fn remove_node(&mut self, node_id: &NodeId) {
183 self.nodes.remove(node_id);
184 }
185
186 pub fn bound_count(&self) -> usize {
188 self.bound.len()
189 }
190
191 pub fn assignments(&self) -> Vec<Assignment> {
193 self.bound.values().cloned().collect()
194 }
195
196 pub async fn bootstrap(&mut self) -> Result<()> {
204 let persisted: HashMap<String, Assignment> =
205 store_get_json(self.store.as_ref(), keys::ASSIGNMENTS)
206 .await?
207 .unwrap_or_default();
208
209 let mut rebuilt = HashMap::new();
210 for (id, mut a) in persisted {
211 match self.nodes.get_mut(&a.node) {
212 Some(node) => {
213 let before: Vec<u32> = node.gpus_allocated.clone();
214 if node.allocate(&a.resources) {
215 a.gpu_ids = node
218 .gpus_allocated
219 .iter()
220 .filter(|d| !before.contains(d))
221 .copied()
222 .collect();
223 rebuilt.insert(id, a);
224 } else {
225 warn!(workload = %id, "assignment no longer fits on replay; dropping");
226 }
227 }
228 None => {
229 debug!(workload = %id, "assignment's node is gone; dropping for reschedule");
230 }
231 }
232 }
233
234 let count = rebuilt.len();
235 self.bound = rebuilt;
236 self.persist().await?;
239 info!(restored = count, "reconciler bootstrapped from store");
240 Ok(())
241 }
242
243 pub async fn run(mut self, mut shutdown_rx: broadcast::Receiver<()>) -> Result<()> {
245 let mut ticker = tokio::time::interval(self.interval);
246 info!(interval_secs = self.interval.as_secs(), "reconcile loop started");
247 loop {
248 tokio::select! {
249 _ = ticker.tick() => {
250 match self.reconcile_once().await {
251 Ok(report) => {
252 if report != ReconcileReport::default() {
253 debug!(?report, "reconcile pass");
254 }
255 }
256 Err(e) => warn!(error = %e, "reconcile pass failed"),
257 }
258 }
259 _ = shutdown_rx.recv() => {
260 info!("reconcile loop stopping; persisting final state");
261 let _ = self.persist().await;
262 return Ok(());
263 }
264 }
265 }
266 }
267
268 pub async fn reconcile_once(&mut self) -> Result<ReconcileReport> {
271 let mut report = ReconcileReport::default();
272
273 let mut jobs = self.load_jobs().await?;
275
276 report.rescaled = self.autoscale(&mut jobs).await?;
280
281 let desired = expand_jobs(&jobs);
283 let desired_ids: std::collections::HashSet<&String> = desired.keys().collect();
284
285 let to_release: Vec<String> = self
288 .bound
289 .keys()
290 .filter(|id| {
291 !desired_ids.contains(*id) || !self.nodes.contains_key(&self.bound[*id].node)
292 })
293 .cloned()
294 .collect();
295 for id in to_release {
296 if let Some(a) = self.bound.remove(&id) {
297 if self.nodes.contains_key(&a.node) {
300 Self::do_release(&mut self.nodes, &a);
301 }
302 self.backoff.remove(&id);
303 report.released += 1;
304 }
305 }
306
307 let now = Instant::now();
309 let mut pending_ids: Vec<&String> = desired
311 .keys()
312 .filter(|id| !self.bound.contains_key(*id))
313 .collect();
314 pending_ids.sort();
315
316 for id in pending_ids {
317 if let Some(b) = self.backoff.get(id) {
319 if now < b.next_try {
320 report.pending += 1;
321 continue;
322 }
323 }
324
325 let spec = &desired[id];
326 match Self::try_place(&mut self.nodes, self.algorithm.as_ref(), &spec.workload) {
327 Some((node_id, gpu_ids)) => {
328 let assignment = Assignment {
329 workload_id: spec.workload.id.clone(),
330 job_id: spec.job_id.clone(),
331 group: spec.group.clone(),
332 ordinal: spec.ordinal,
333 node: node_id,
334 gpu_ids,
335 resources: spec.workload.resources.clone(),
336 status: TaskStatus::Pending,
337 };
338 self.bound.insert(id.clone(), assignment);
339 self.backoff.remove(id);
340 report.scheduled += 1;
341 }
342 None => {
343 self.bump_backoff(id, now);
344 report.pending += 1;
345 }
346 }
347 }
348
349 if report.scheduled > 0 || report.released > 0 {
351 self.persist().await?;
352 }
353
354 Ok(report)
355 }
356
357 pub fn mark_failed(&mut self, workload_id: &str) {
359 if let Some(a) = self.bound.remove(workload_id) {
360 if self.nodes.contains_key(&a.node) {
361 Self::do_release(&mut self.nodes, &a);
362 }
363 }
364 }
365
366 async fn load_jobs(&self) -> Result<Vec<Job>> {
369 let mut jobs = Vec::new();
370 for key in self.store.list_prefix(keys::JOBS).await? {
371 if let Some(job) = store_get_json::<Job>(self.store.as_ref(), &key).await? {
372 jobs.push(job);
373 }
374 }
375 Ok(jobs)
376 }
377
378 async fn autoscale(&self, jobs: &mut [Job]) -> Result<usize> {
381 let Some(source) = self.metrics_source.clone() else {
382 return Ok(0);
383 };
384 let mut rescaled = 0;
385 for job in jobs.iter_mut() {
386 let Some((cpu, mem)) = source.job_metrics(&job.id) else {
387 continue;
388 };
389 let mut changed = false;
390 for group in &mut job.groups {
391 if group.scaling.min >= group.scaling.max {
393 continue;
394 }
395 let key = format!("{}/{}", job.id, group.name);
396 let snap = MetricsSnapshot::new(cpu, mem, group.scaling.desired);
397 let decision = self.autoscaler.evaluate(&key, snap).await;
400 let new_desired = match decision {
401 ScalingDecision::ScaleUp(n) => {
402 (group.scaling.desired + n).min(group.scaling.max)
403 }
404 ScalingDecision::ScaleDown(n) => group
405 .scaling
406 .desired
407 .saturating_sub(n)
408 .max(group.scaling.min),
409 ScalingDecision::ScaleTo(c) => c.clamp(group.scaling.min, group.scaling.max),
410 ScalingDecision::NoChange => group.scaling.desired,
411 };
412 if new_desired != group.scaling.desired {
413 info!(job = %job.id, group = %group.name, from = group.scaling.desired, to = new_desired, "autoscale");
414 group.scaling.desired = new_desired;
415 changed = true;
416 rescaled += 1;
417 }
418 }
419 if changed {
420 store_set_json(self.store.as_ref(), &keys::job(&job.id), job).await?;
423 }
424 }
425 Ok(rescaled)
426 }
427
428 async fn persist(&self) -> Result<()> {
429 store_set_json(self.store.as_ref(), keys::ASSIGNMENTS, &self.bound).await
430 }
431
432 fn bump_backoff(&mut self, id: &str, now: Instant) {
433 let entry = self
434 .backoff
435 .entry(id.to_string())
436 .or_insert(BackoffState { attempts: 0, next_try: now });
437 entry.attempts = entry.attempts.saturating_add(1);
438 let secs = 1u64.checked_shl(entry.attempts.min(20)).unwrap_or(u64::MAX);
440 let delay = Duration::from_secs(secs).min(self.max_backoff);
441 entry.next_try = now + delay;
442 }
443
444 fn try_place(
448 nodes: &mut HashMap<NodeId, NodeResources>,
449 algorithm: &dyn SchedulingAlgorithm,
450 workload: &Workload,
451 ) -> Option<(NodeId, Vec<u32>)> {
452 let req = &workload.resources;
453 let best_id = nodes
454 .values()
455 .filter(|n| n.can_fit(req))
456 .max_by(|a, b| {
457 algorithm
458 .score(workload, a)
459 .partial_cmp(&algorithm.score(workload, b))
460 .unwrap_or(std::cmp::Ordering::Equal)
461 })
462 .map(|n| n.node_id)?;
463
464 let node = nodes.get_mut(&best_id)?;
465 let before: Vec<u32> = node.gpus_allocated.clone();
466 if node.allocate(req) {
467 let gpu_ids = node
468 .gpus_allocated
469 .iter()
470 .filter(|d| !before.contains(d))
471 .copied()
472 .collect();
473 Some((best_id, gpu_ids))
474 } else {
475 None
476 }
477 }
478
479 fn do_release(nodes: &mut HashMap<NodeId, NodeResources>, a: &Assignment) {
480 if let Some(node) = nodes.get_mut(&a.node) {
481 node.release(&a.resources, &a.gpu_ids);
482 }
483 }
484}
485
486struct DesiredReplica {
488 workload: Workload,
489 job_id: String,
490 group: String,
491 ordinal: u32,
492}
493
494fn expand_jobs(jobs: &[Job]) -> HashMap<String, DesiredReplica> {
496 let mut out = HashMap::new();
497 for job in jobs {
498 for group in &job.groups {
499 let resources = group_resources(group);
500 for ordinal in 0..group.scaling.desired {
501 let id = format!("{}/{}/{}", job.id, group.name, ordinal);
502 let workload = Workload::new(id.clone(), format!("{}-{}", job.name, group.name))
503 .with_resources(resources.clone())
504 .with_priority(job.priority as i32);
505 out.insert(
506 id,
507 DesiredReplica {
508 workload,
509 job_id: job.id.clone(),
510 group: group.name.clone(),
511 ordinal,
512 },
513 );
514 }
515 }
516 }
517 out
518}
519
520fn group_resources(group: &TaskGroup) -> ResourceRequirements {
527 let mut cpu: u64 = 0;
528 let mut memory: u64 = 0;
529 let mut gpu: u32 = 0;
530 for task in &group.tasks {
531 cpu += task.resources.cpu as u64;
532 memory += task.resources.memory as u64;
533 gpu += task.resources.gpu.unwrap_or(0);
534 }
535 let mut req = ResourceRequirements::new().cpu(cpu).memory(memory);
536 if gpu > 0 {
537 req = req.gpu(gpu, 0);
540 }
541 req
542}
543
544#[cfg(test)]
545mod tests {
546 use super::*;
547 use crate::autoscaler::AutoscalerConfig;
548 use crate::job::{Job, Task};
549 use crate::storage::MemoryStore;
550 use crate::types::{GpuResources, NodeId};
551
552 fn store() -> BoxedStateStore {
553 Arc::new(MemoryStore::new())
554 }
555
556 fn autoscaler() -> Arc<Autoscaler> {
557 Arc::new(Autoscaler::new(AutoscalerConfig::default().hysteresis_secs(0)).unwrap())
558 }
559
560 async fn submit(store: &BoxedStateStore, job: &Job) {
561 store_set_json(store.as_ref(), &keys::job(&job.id), job)
562 .await
563 .unwrap();
564 }
565
566 fn job_with_desired(name: &str, group: &str, cpu: u32, mem: u32, desired: u32) -> Job {
567 let mut job = Job::new(name).with_group(group, Task::new("t").resources(cpu, mem));
568 job.groups[0].scaling = crate::job::ScalingConfig::new(1, 10).with_desired(desired);
569 job
570 }
571
572 #[tokio::test]
573 async fn schedules_all_desired_replicas() {
574 let store = store();
575 let job = job_with_desired("svc", "api", 1000, 1024, 3);
576 submit(&store, &job).await;
577
578 let mut rec = Reconciler::new(store, autoscaler());
579 rec.register_node(NodeResources::new(NodeId::new(), 8000, 8192));
580
581 let report = rec.reconcile_once().await.unwrap();
582 assert_eq!(report.scheduled, 3);
583 assert_eq!(rec.bound_count(), 3);
584
585 let report2 = rec.reconcile_once().await.unwrap();
587 assert_eq!(report2.scheduled, 0);
588 assert_eq!(report2.released, 0);
589 assert_eq!(rec.bound_count(), 3);
590 }
591
592 #[tokio::test]
593 async fn scale_down_releases_and_restores_capacity() {
594 let store = store();
595 let mut job = job_with_desired("svc", "api", 1000, 1024, 3);
596 submit(&store, &job).await;
597
598 let mut rec = Reconciler::new(store.clone(), autoscaler());
599 let node_id = NodeId::new();
600 rec.register_node(NodeResources::new(node_id, 8000, 8192));
601 rec.reconcile_once().await.unwrap();
602 assert_eq!(rec.bound_count(), 3);
603 let used_at_3 = rec.nodes[&node_id].cpu_allocated;
604 assert_eq!(used_at_3, 3000);
605
606 job.groups[0].scaling.desired = 1;
608 submit(&store, &job).await;
609
610 let report = rec.reconcile_once().await.unwrap();
611 assert_eq!(report.released, 2);
612 assert_eq!(rec.bound_count(), 1);
613 assert_eq!(rec.nodes[&node_id].cpu_allocated, 1000);
614 }
615
616 #[tokio::test]
617 async fn node_loss_reschedules_onto_survivor() {
618 let store = store();
619 let job = job_with_desired("svc", "api", 1000, 1024, 2);
620 submit(&store, &job).await;
621
622 let mut rec = Reconciler::new(store, autoscaler());
623 let dead = NodeId::new();
624 rec.register_node(NodeResources::new(dead, 8000, 8192));
625 rec.reconcile_once().await.unwrap();
626 assert!(rec.assignments().iter().all(|a| a.node == dead));
627
628 let survivor = NodeId::new();
630 rec.remove_node(&dead);
631 rec.register_node(NodeResources::new(survivor, 8000, 8192));
632
633 let report = rec.reconcile_once().await.unwrap();
634 assert_eq!(rec.bound_count(), 2);
636 assert!(rec.assignments().iter().all(|a| a.node == survivor));
637 assert!(report.scheduled >= 2);
638 }
639
640 #[tokio::test]
641 async fn insufficient_capacity_backs_off_without_overallocating() {
642 let store = store();
643 let job = job_with_desired("svc", "api", 1000, 1024, 4);
645 submit(&store, &job).await;
646
647 let mut rec = Reconciler::new(store, autoscaler());
648 let node_id = NodeId::new();
649 rec.register_node(NodeResources::new(node_id, 2000, 8192));
650
651 let report = rec.reconcile_once().await.unwrap();
652 assert_eq!(report.scheduled, 2);
653 assert_eq!(report.pending, 2);
654 assert_eq!(rec.bound_count(), 2);
655 assert!(rec.nodes[&node_id].cpu_allocated <= 2000);
657 }
658
659 #[tokio::test]
660 async fn gpu_ids_are_freed_exactly_on_release() {
661 let store = store();
662 let mut job = Job::new("train").with_group(
663 "worker",
664 Task::new("w").with_resources(crate::job::Resources::new(500, 1024).with_gpu(1)),
665 );
666 job.groups[0].scaling = crate::job::ScalingConfig::new(1, 10).with_desired(2);
667 submit(&store, &job).await;
668
669 let mut rec = Reconciler::new(store.clone(), autoscaler());
670 let node_id = NodeId::new();
671 let node = NodeResources::new(node_id, 8000, 8192)
672 .with_gpu(GpuResources::new(0, "A100", 40960))
673 .with_gpu(GpuResources::new(1, "A100", 40960));
674 rec.register_node(node);
675
676 rec.reconcile_once().await.unwrap();
677 assert_eq!(rec.bound_count(), 2);
678 assert_eq!(rec.nodes[&node_id].gpus_allocated.len(), 2);
679 let mut ids: Vec<u32> = rec.assignments().iter().flat_map(|a| a.gpu_ids.clone()).collect();
681 ids.sort();
682 assert_eq!(ids, vec![0, 1]);
683
684 job.groups[0].scaling.desired = 0;
686 submit(&store, &job).await;
687 rec.reconcile_once().await.unwrap();
688 assert_eq!(rec.bound_count(), 0);
689 assert_eq!(rec.nodes[&node_id].gpus_allocated.len(), 0);
690 }
691
692 #[tokio::test]
693 async fn bootstrap_rebuilds_actual_state_and_capacity() {
694 let store = store();
695 let job = job_with_desired("svc", "api", 1000, 1024, 3);
696 submit(&store, &job).await;
697
698 let node_id = NodeId::new();
699 {
700 let mut rec = Reconciler::new(store.clone(), autoscaler());
701 rec.register_node(NodeResources::new(node_id, 8000, 8192));
702 rec.reconcile_once().await.unwrap();
703 assert_eq!(rec.bound_count(), 3);
704 }
705
706 let mut rec2 = Reconciler::new(store, autoscaler());
709 rec2.register_node(NodeResources::new(node_id, 8000, 8192));
710 rec2.bootstrap().await.unwrap();
711 assert_eq!(rec2.bound_count(), 3);
712 assert_eq!(rec2.nodes[&node_id].cpu_allocated, 3000);
713 }
714
715 struct FixedMetrics(f64, f64);
716 impl MetricsSource for FixedMetrics {
717 fn job_metrics(&self, _job_id: &str) -> Option<(f64, f64)> {
718 Some((self.0, self.1))
719 }
720 }
721
722 #[tokio::test]
723 async fn autoscaling_increases_desired_and_persists() {
724 let store = store();
725 let job = job_with_desired("svc", "api", 500, 512, 2);
726 let job_id = job.id.clone();
727 submit(&store, &job).await;
728
729 let mut rec = Reconciler::new(store.clone(), autoscaler())
730 .with_metrics_source(Arc::new(FixedMetrics(0.95, 0.95)));
731 rec.register_node(NodeResources::new(NodeId::new(), 16000, 32768));
732
733 let report = rec.reconcile_once().await.unwrap();
734 assert_eq!(report.rescaled, 1, "high utilization should scale up");
735
736 let reloaded: Job = store_get_json(store.as_ref(), &keys::job(&job_id))
738 .await
739 .unwrap()
740 .unwrap();
741 assert_eq!(reloaded.groups[0].scaling.desired, 3);
742 assert_eq!(rec.bound_count(), 3);
743 }
744}