Skip to main content

forge_orchestration/scheduler/
reconcile.rs

1//! Reconciliation control loop: converge desired state onto actual state.
2//!
3//! Borg's value is not the placement *decision* — it is the *control loop* that
4//! keeps reality matching intent: schedule what is missing, persist the binding,
5//! restart what failed, reschedule off lost nodes, and execute autoscaling. The
6//! base Forge runtime had none of this: `Forge::run()` loaded state once, served
7//! HTTP, and saved on shutdown; nothing ever scheduled a workload.
8//!
9//! [`Reconciler`] is that loop. It is deliberately *self-contained* and wires
10//! into the runtime through the **shared [`StateStore`]** rather than by taking
11//! ownership of the runtime's internals:
12//!
13//! - **Desired** state is the set of [`Job`]s persisted under [`keys::JOBS`]
14//!   (exactly what [`crate::runtime::Forge::submit_job`] already writes). The
15//!   reconciler reloads them each tick, so newly submitted jobs are picked up
16//!   with no shared-mutable-state plumbing.
17//! - **Actual** state is the [`Assignment`] map, persisted under
18//!   [`keys::ASSIGNMENTS`]. It is the single source of truth; in-memory node
19//!   capacity is *derived* from it and rebuilt on [`Reconciler::bootstrap`].
20//!
21//! ## Correctness properties
22//!
23//! 1. **No double-scheduling / no capacity leak.** Everything is keyed by the
24//!    deterministic workload id `"{job}/{group}/{ordinal}"`. A replica is either
25//!    in `bound` (placed) or not; the backoff table is keyed by the same id, so a
26//!    failed replica can never be scheduled twice in one pass.
27//! 2. **Capacity is always derivable from persisted truth.** A crash mid-tick
28//!    loses only unpersisted bindings; on restart [`Reconciler::bootstrap`]
29//!    replays the persisted assignments to rebuild node capacity, and any binding
30//!    that never got persisted is simply re-derived as missing and rescheduled.
31//!    There is no path that double-allocates.
32//! 3. **GPU device ids are freed exactly.** [`NodeResources::allocate`] picks GPU
33//!    device ids internally; we capture *which* ids it took via an allocate-time
34//!    snapshot diff and store them on the [`Assignment`], so
35//!    [`NodeResources::release`] frees precisely those GPUs (no leak).
36//! 4. **Lost nodes converge.** Removing a node drops its assignments from
37//!    `bound`; the next pass re-derives them as missing and reschedules them onto
38//!    surviving nodes.
39//! 5. **No lock held across `.await`.** Jobs are cloned into a `Vec` before any
40//!    async autoscaler/store call; node mutation happens in a later synchronous
41//!    phase. The reconciler owns plain `HashMap`s (no `DashMap` guard escapes).
42
43use 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/// Observed lifecycle status of a placed replica.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
61pub enum TaskStatus {
62    /// Scheduled and bound, not yet confirmed running.
63    Pending,
64    /// Confirmed running.
65    Running,
66    /// Failed; will be released and rescheduled.
67    Failed,
68}
69
70/// A committed placement of one replica (the unit of *actual* state).
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct Assignment {
73    /// Deterministic workload id: `"{job}/{group}/{ordinal}"`.
74    pub workload_id: String,
75    /// Owning job id.
76    pub job_id: String,
77    /// Group name within the job.
78    pub group: String,
79    /// Replica ordinal within the group.
80    pub ordinal: u32,
81    /// Node this replica is bound to.
82    pub node: NodeId,
83    /// GPU device ids reserved for this replica (for exact release).
84    pub gpu_ids: Vec<u32>,
85    /// Resources reserved (kept so release is exact even if the job changes).
86    pub resources: ResourceRequirements,
87    /// Observed status.
88    pub status: TaskStatus,
89}
90
91/// Backoff bookkeeping for a replica that could not be placed.
92#[derive(Debug, Clone)]
93struct BackoffState {
94    attempts: u32,
95    next_try: Instant,
96}
97
98/// Source of per-job utilization metrics that drive autoscaling.
99///
100/// Kept as an injectable hook so the reconciler stays decoupled from any
101/// particular telemetry pipeline (and so tests are deterministic). Returns
102/// `(cpu_util, mem_util)` in `0.0..=1.0` for a job, or `None` if unknown (in
103/// which case that job is not autoscaled this pass).
104pub trait MetricsSource: Send + Sync {
105    /// Current `(cpu_util, mem_util)` for `job_id`, if known.
106    fn job_metrics(&self, job_id: &str) -> Option<(f64, f64)>;
107}
108
109/// Summary of what a single reconcile pass did (for logging and tests).
110#[derive(Debug, Clone, Default, PartialEq, Eq)]
111pub struct ReconcileReport {
112    /// Replicas newly scheduled and bound this pass.
113    pub scheduled: usize,
114    /// Replicas released (scaled down, job removed, node lost, or failed).
115    pub released: usize,
116    /// Desired replicas still unplaced (no capacity / backing off).
117    pub pending: usize,
118    /// Groups whose desired count was changed by autoscaling.
119    pub rescaled: usize,
120}
121
122/// Drives convergence of desired [`Job`]s onto actual [`Assignment`]s.
123pub struct Reconciler {
124    store: BoxedStateStore,
125    autoscaler: Arc<Autoscaler>,
126    algorithm: Box<dyn SchedulingAlgorithm>,
127    metrics_source: Option<Arc<dyn MetricsSource>>,
128    /// Registered nodes with live (in-memory) capacity.
129    nodes: HashMap<NodeId, NodeResources>,
130    /// Actual state: workload_id -> assignment.
131    bound: HashMap<String, Assignment>,
132    /// Backoff for replicas that failed to place.
133    backoff: HashMap<String, BackoffState>,
134    /// Loop interval.
135    interval: Duration,
136    /// Maximum backoff delay.
137    max_backoff: Duration,
138}
139
140impl Reconciler {
141    /// Create a reconciler over a shared store and autoscaler. Uses
142    /// [`BinPackScheduler`] for node scoring by default.
143    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    /// Use a custom node-scoring algorithm.
158    pub fn with_algorithm<A: SchedulingAlgorithm + 'static>(mut self, algorithm: A) -> Self {
159        self.algorithm = Box::new(algorithm);
160        self
161    }
162
163    /// Provide a metrics source to enable autoscaling.
164    pub fn with_metrics_source(mut self, source: Arc<dyn MetricsSource>) -> Self {
165        self.metrics_source = Some(source);
166        self
167    }
168
169    /// Set the reconcile loop interval.
170    pub fn with_interval(mut self, interval: Duration) -> Self {
171        self.interval = interval;
172        self
173    }
174
175    /// Register (or replace) a schedulable node with fresh capacity.
176    pub fn register_node(&mut self, node: NodeResources) {
177        self.nodes.insert(node.node_id, node);
178    }
179
180    /// Remove a node (e.g. it left the cluster). Its assignments are dropped from
181    /// actual state so the next pass reschedules them elsewhere.
182    pub fn remove_node(&mut self, node_id: &NodeId) {
183        self.nodes.remove(node_id);
184    }
185
186    /// Number of currently-bound replicas.
187    pub fn bound_count(&self) -> usize {
188        self.bound.len()
189    }
190
191    /// Snapshot of current assignments (for inspection / handlers).
192    pub fn assignments(&self) -> Vec<Assignment> {
193        self.bound.values().cloned().collect()
194    }
195
196    /// Load persisted actual state and rebuild node capacity from it.
197    ///
198    /// Call once after registering nodes and before the loop starts. Registered
199    /// nodes must already have fresh (unallocated) capacity; this replays each
200    /// persisted assignment onto its node so the in-memory counters match the
201    /// persisted truth. Assignments whose node is no longer registered are
202    /// dropped (they will be rescheduled).
203    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                        // Re-derive the actual GPU ids taken on replay so future
216                        // releases stay exact even across restarts.
217                        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        // Persist the cleaned-up view so a node that vanished while we were down
237        // does not leave stale assignments behind.
238        self.persist().await?;
239        info!(restored = count, "reconciler bootstrapped from store");
240        Ok(())
241    }
242
243    /// Run the reconcile loop until a shutdown signal is received.
244    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    /// Execute a single convergence pass. Public so it can be driven manually
269    /// (and unit-tested) without spawning the loop.
270    pub async fn reconcile_once(&mut self) -> Result<ReconcileReport> {
271        let mut report = ReconcileReport::default();
272
273        // ---- Phase 0: load desired state (clone out; no store guard held) ----
274        let mut jobs = self.load_jobs().await?;
275
276        // ---- Phase 1: autoscaling (async; mutates desired counts) ------------
277        // Done before deriving the desired set so a scale decision takes effect
278        // this same pass. Holds no node borrow across the await.
279        report.rescaled = self.autoscale(&mut jobs).await?;
280
281        // ---- Phase 2: derive the desired replica set -------------------------
282        let desired = expand_jobs(&jobs);
283        let desired_ids: std::collections::HashSet<&String> = desired.keys().collect();
284
285        // ---- Phase 3: GC — release bound replicas that are no longer desired
286        // or whose node has vanished. ------------------------------------------
287        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                // Only release capacity if the node still exists; a vanished node
298                // takes its capacity with it.
299                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        // ---- Phase 4: schedule desired replicas that are not bound -----------
308        let now = Instant::now();
309        // Deterministic order so packing is stable and tests are reproducible.
310        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            // Respect backoff.
318            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        // ---- Phase 5: persist actual state (durable truth) -------------------
350        if report.scheduled > 0 || report.released > 0 {
351            self.persist().await?;
352        }
353
354        Ok(report)
355    }
356
357    /// Mark a running replica as failed; it is released and rescheduled next pass.
358    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    // ---- internals --------------------------------------------------------
367
368    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    /// Evaluate autoscaling per autoscalable group; mutate desired counts in
379    /// `jobs` and persist changed jobs. Returns the number of groups rescaled.
380    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                // Only groups with headroom to move are autoscalable.
392                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                // Autoscaler::evaluate enforces hysteresis internally and only
398                // records the cooldown when it actually returns a scaling action.
399                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                // Persist the new desired so it is durable and visible to the
421                // runtime's own view of the job on its next read.
422                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        // Exponential: 1s, 2s, 4s, ... capped at max_backoff.
439        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    /// Place a workload on the best-scoring node that fits, committing the
445    /// allocation. Returns the node and the GPU device ids reserved (captured via
446    /// an allocate-time snapshot diff so release is exact).
447    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
486/// A desired replica derived from a [`Job`].
487struct DesiredReplica {
488    workload: Workload,
489    job_id: String,
490    group: String,
491    ordinal: u32,
492}
493
494/// Expand all jobs into the desired replica set, keyed by deterministic id.
495fn 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
520/// Sum a group's task resources into the scheduler's [`ResourceRequirements`].
521///
522/// Forge `Resources` are in MHz / MB; the scheduler bins in millicores / MB.
523/// The units only need to be self-consistent across nodes and workloads (they
524/// are: `NodeResources` capacity is expressed in the same scale), so we map
525/// MHz -> the scheduler's CPU unit 1:1.
526fn 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        // Per-GPU memory is not modeled on Forge `Resources`; leave at 0 so any
538        // GPU satisfies the count constraint until a richer model is added.
539        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        // Idempotent: a second pass changes nothing.
586        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        // Scale the job down to 1 and persist.
607        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        // The node leaves; a fresh one joins.
629        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        // Both replicas released off the dead node and rescheduled onto survivor.
635        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        // 4 replicas of 1000 cpu, node only fits 2.
644        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        // Never allocated beyond capacity.
656        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        // Each assignment recorded exactly one distinct GPU id.
680        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        // Scale to 0 and confirm GPUs are returned, not leaked.
685        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        // A fresh reconciler with a fresh (empty-capacity) node rebuilds from the
707        // persisted assignments.
708        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        // Desired was persisted back to the job, and the extra replica is bound.
737        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}