use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use tokio::sync::broadcast;
use tracing::{debug, info, warn};
use super::algorithms::{BinPackScheduler, SchedulingAlgorithm};
use super::gang::{GangReservation, GangScheduler};
use super::sim::SimCell;
use super::{NodeResources, ResourceRequirements, Workload};
use crate::autoscaler::{Autoscaler, MetricsSnapshot, ScalingDecision};
use crate::error::Result;
use crate::job::{Job, TaskGroup};
use crate::storage::{keys, store_get_json, store_set_json, BoxedStateStore};
use crate::types::NodeId;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TaskStatus {
Pending,
Running,
Failed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Assignment {
pub workload_id: String,
pub job_id: String,
pub group: String,
pub ordinal: u32,
pub node: NodeId,
pub gpu_ids: Vec<u32>,
pub resources: ResourceRequirements,
pub status: TaskStatus,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SimBinding {
pub cell_id: String,
pub placements: Vec<(String, NodeId)>,
pub reservations: Vec<GangReservation>,
}
#[derive(Debug, Clone)]
struct BackoffState {
attempts: u32,
next_try: Instant,
}
pub trait MetricsSource: Send + Sync {
fn job_metrics(&self, job_id: &str) -> Option<(f64, f64)>;
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ReconcileReport {
pub scheduled: usize,
pub released: usize,
pub pending: usize,
pub rescaled: usize,
pub sim_scheduled: usize,
pub sim_released: usize,
}
pub struct Reconciler {
store: BoxedStateStore,
autoscaler: Arc<Autoscaler>,
algorithm: Box<dyn SchedulingAlgorithm>,
gang: GangScheduler,
metrics_source: Option<Arc<dyn MetricsSource>>,
nodes: HashMap<NodeId, NodeResources>,
bound: HashMap<String, Assignment>,
sim_bound: HashMap<String, SimBinding>,
backoff: HashMap<String, BackoffState>,
sim_backoff: HashMap<String, BackoffState>,
interval: Duration,
max_backoff: Duration,
}
impl Reconciler {
pub fn new(store: BoxedStateStore, autoscaler: Arc<Autoscaler>) -> Self {
Self {
store,
autoscaler,
algorithm: Box::new(BinPackScheduler::new()),
gang: GangScheduler::new(),
metrics_source: None,
nodes: HashMap::new(),
bound: HashMap::new(),
sim_bound: HashMap::new(),
backoff: HashMap::new(),
sim_backoff: HashMap::new(),
interval: Duration::from_secs(5),
max_backoff: Duration::from_secs(300),
}
}
pub fn with_algorithm<A: SchedulingAlgorithm + 'static>(mut self, algorithm: A) -> Self {
self.algorithm = Box::new(algorithm);
self
}
pub fn with_metrics_source(mut self, source: Arc<dyn MetricsSource>) -> Self {
self.metrics_source = Some(source);
self
}
pub fn with_interval(mut self, interval: Duration) -> Self {
self.interval = interval;
self
}
pub fn register_node(&mut self, node: NodeResources) {
self.nodes.insert(node.node_id, node);
}
pub fn remove_node(&mut self, node_id: &NodeId) {
self.nodes.remove(node_id);
}
pub fn bound_count(&self) -> usize {
self.bound.len()
}
pub fn assignments(&self) -> Vec<Assignment> {
self.bound.values().cloned().collect()
}
pub fn sim_bound_count(&self) -> usize {
self.sim_bound.len()
}
pub fn sim_bindings(&self) -> Vec<SimBinding> {
self.sim_bound.values().cloned().collect()
}
pub async fn bootstrap(&mut self) -> Result<()> {
let persisted: HashMap<String, Assignment> =
store_get_json(self.store.as_ref(), keys::ASSIGNMENTS)
.await?
.unwrap_or_default();
let mut rebuilt = HashMap::new();
for (id, mut a) in persisted {
match self.nodes.get_mut(&a.node) {
Some(node) => {
let before: Vec<u32> = node.gpus_allocated.clone();
if node.allocate(&a.resources) {
a.gpu_ids = node
.gpus_allocated
.iter()
.filter(|d| !before.contains(d))
.copied()
.collect();
rebuilt.insert(id, a);
} else {
warn!(workload = %id, "assignment no longer fits on replay; dropping");
}
}
None => {
debug!(workload = %id, "assignment's node is gone; dropping for reschedule");
}
}
}
let count = rebuilt.len();
self.bound = rebuilt;
let persisted_sim: HashMap<String, SimBinding> =
store_get_json(self.store.as_ref(), keys::SIM_ASSIGNMENTS)
.await?
.unwrap_or_default();
let mut rebuilt_sim = HashMap::new();
for (cid, mut binding) in persisted_sim {
let mut applied: Vec<(NodeId, ResourceRequirements, Vec<u32>)> = Vec::new();
let mut ok = true;
for r in &binding.reservations {
match self.nodes.get_mut(&r.node) {
Some(node) => {
let before: Vec<u32> = node.gpus_allocated.clone();
if node.allocate(&r.resources) {
let gpu_ids = node
.gpus_allocated
.iter()
.filter(|d| !before.contains(d))
.copied()
.collect::<Vec<_>>();
applied.push((r.node, r.resources.clone(), gpu_ids));
} else {
ok = false;
break;
}
}
None => {
ok = false;
break;
}
}
}
if ok {
for (res, (_, _, gpu_ids)) in binding.reservations.iter_mut().zip(applied.iter()) {
res.gpu_ids = gpu_ids.clone();
}
rebuilt_sim.insert(cid, binding);
} else {
for (node_id, req, gpu_ids) in &applied {
if let Some(node) = self.nodes.get_mut(node_id) {
node.release(req, gpu_ids);
}
}
debug!(cell = %cid, "sim binding could not be fully replayed; dropping for reschedule");
}
}
let sim_count = rebuilt_sim.len();
self.sim_bound = rebuilt_sim;
self.persist().await?;
self.persist_sim().await?;
info!(restored = count, sim_restored = sim_count, "reconciler bootstrapped from store");
Ok(())
}
pub async fn run(mut self, mut shutdown_rx: broadcast::Receiver<()>) -> Result<()> {
let mut ticker = tokio::time::interval(self.interval);
info!(interval_secs = self.interval.as_secs(), "reconcile loop started");
loop {
tokio::select! {
_ = ticker.tick() => {
match self.reconcile_once().await {
Ok(report) => {
if report != ReconcileReport::default() {
debug!(?report, "reconcile pass");
}
}
Err(e) => warn!(error = %e, "reconcile pass failed"),
}
}
_ = shutdown_rx.recv() => {
info!("reconcile loop stopping; persisting final state");
let _ = self.persist().await;
return Ok(());
}
}
}
}
pub async fn reconcile_once(&mut self) -> Result<ReconcileReport> {
let mut report = ReconcileReport::default();
let mut jobs = self.load_jobs().await?;
report.rescaled = self.autoscale(&mut jobs).await?;
let desired = expand_jobs(&jobs);
let desired_ids: std::collections::HashSet<&String> = desired.keys().collect();
let to_release: Vec<String> = self
.bound
.keys()
.filter(|id| {
!desired_ids.contains(*id) || !self.nodes.contains_key(&self.bound[*id].node)
})
.cloned()
.collect();
for id in to_release {
if let Some(a) = self.bound.remove(&id) {
if self.nodes.contains_key(&a.node) {
Self::do_release(&mut self.nodes, &a);
}
self.backoff.remove(&id);
report.released += 1;
}
}
let now = Instant::now();
let mut pending_ids: Vec<&String> = desired
.keys()
.filter(|id| !self.bound.contains_key(*id))
.collect();
pending_ids.sort();
for id in pending_ids {
if let Some(b) = self.backoff.get(id) {
if now < b.next_try {
report.pending += 1;
continue;
}
}
let spec = &desired[id];
match Self::try_place(&mut self.nodes, self.algorithm.as_ref(), &spec.workload) {
Some((node_id, gpu_ids)) => {
let assignment = Assignment {
workload_id: spec.workload.id.clone(),
job_id: spec.job_id.clone(),
group: spec.group.clone(),
ordinal: spec.ordinal,
node: node_id,
gpu_ids,
resources: spec.workload.resources.clone(),
status: TaskStatus::Pending,
};
self.bound.insert(id.clone(), assignment);
self.backoff.remove(id);
report.scheduled += 1;
}
None => {
self.bump_backoff(id, now);
report.pending += 1;
}
}
}
if report.scheduled > 0 || report.released > 0 {
self.persist().await?;
}
self.reconcile_sim_cells(now, &mut report).await?;
Ok(report)
}
async fn reconcile_sim_cells(
&mut self,
now: Instant,
report: &mut ReconcileReport,
) -> Result<()> {
let cells = self.load_sim_cells().await?;
let desired: std::collections::HashSet<&String> = cells.iter().map(|c| &c.id).collect();
let to_release: Vec<String> = self
.sim_bound
.keys()
.filter(|cid| {
!desired.contains(*cid)
|| self.sim_bound[*cid]
.reservations
.iter()
.any(|r| !self.nodes.contains_key(&r.node))
})
.cloned()
.collect();
for cid in to_release {
if let Some(binding) = self.sim_bound.remove(&cid) {
for r in &binding.reservations {
if let Some(node) = self.nodes.get_mut(&r.node) {
node.release(&r.resources, &r.gpu_ids);
}
}
self.sim_backoff.remove(&cid);
report.sim_released += 1;
}
}
let mut pending: Vec<&SimCell> = cells
.iter()
.filter(|c| !self.sim_bound.contains_key(&c.id))
.collect();
pending.sort_by(|a, b| a.next_deadline.cmp(&b.next_deadline).then(a.id.cmp(&b.id)));
for cell in pending {
if let Some(b) = self.sim_backoff.get(&cell.id) {
if now < b.next_try {
continue;
}
}
let group = cell.gang_group();
let decision = self.gang.schedule_gang(&group, &mut self.nodes);
if decision.committed {
self.sim_bound.insert(
cell.id.clone(),
SimBinding {
cell_id: cell.id.clone(),
placements: decision.placements,
reservations: decision.reservations,
},
);
self.sim_backoff.remove(&cell.id);
report.sim_scheduled += 1;
} else {
Self::backoff_bump(&mut self.sim_backoff, &cell.id, now, self.max_backoff);
}
}
if report.sim_scheduled > 0 || report.sim_released > 0 {
self.persist_sim().await?;
}
Ok(())
}
pub fn mark_failed(&mut self, workload_id: &str) {
if let Some(a) = self.bound.remove(workload_id) {
if self.nodes.contains_key(&a.node) {
Self::do_release(&mut self.nodes, &a);
}
}
}
async fn load_jobs(&self) -> Result<Vec<Job>> {
let mut jobs = Vec::new();
for key in self.store.list_prefix(keys::JOBS).await? {
if let Some(job) = store_get_json::<Job>(self.store.as_ref(), &key).await? {
jobs.push(job);
}
}
Ok(jobs)
}
async fn autoscale(&self, jobs: &mut [Job]) -> Result<usize> {
let Some(source) = self.metrics_source.clone() else {
return Ok(0);
};
let mut rescaled = 0;
for job in jobs.iter_mut() {
let Some((cpu, mem)) = source.job_metrics(&job.id) else {
continue;
};
let mut changed = false;
for group in &mut job.groups {
if group.scaling.min >= group.scaling.max {
continue;
}
let key = format!("{}/{}", job.id, group.name);
let snap = MetricsSnapshot::new(cpu, mem, group.scaling.desired);
let decision = self.autoscaler.evaluate(&key, snap).await;
let new_desired = match decision {
ScalingDecision::ScaleUp(n) => {
(group.scaling.desired + n).min(group.scaling.max)
}
ScalingDecision::ScaleDown(n) => group
.scaling
.desired
.saturating_sub(n)
.max(group.scaling.min),
ScalingDecision::ScaleTo(c) => c.clamp(group.scaling.min, group.scaling.max),
ScalingDecision::NoChange => group.scaling.desired,
};
if new_desired != group.scaling.desired {
info!(job = %job.id, group = %group.name, from = group.scaling.desired, to = new_desired, "autoscale");
group.scaling.desired = new_desired;
changed = true;
rescaled += 1;
}
}
if changed {
store_set_json(self.store.as_ref(), &keys::job(&job.id), job).await?;
}
}
Ok(rescaled)
}
async fn persist(&self) -> Result<()> {
store_set_json(self.store.as_ref(), keys::ASSIGNMENTS, &self.bound).await
}
async fn persist_sim(&self) -> Result<()> {
store_set_json(self.store.as_ref(), keys::SIM_ASSIGNMENTS, &self.sim_bound).await
}
async fn load_sim_cells(&self) -> Result<Vec<SimCell>> {
let mut cells = Vec::new();
for key in self.store.list_prefix(keys::SIMCELLS).await? {
if let Some(cell) = store_get_json::<SimCell>(self.store.as_ref(), &key).await? {
cells.push(cell);
}
}
Ok(cells)
}
fn backoff_bump(
map: &mut HashMap<String, BackoffState>,
id: &str,
now: Instant,
max_backoff: Duration,
) {
let entry = map
.entry(id.to_string())
.or_insert(BackoffState { attempts: 0, next_try: now });
entry.attempts = entry.attempts.saturating_add(1);
let secs = 1u64.checked_shl(entry.attempts.min(20)).unwrap_or(u64::MAX);
entry.next_try = now + Duration::from_secs(secs).min(max_backoff);
}
fn bump_backoff(&mut self, id: &str, now: Instant) {
let entry = self
.backoff
.entry(id.to_string())
.or_insert(BackoffState { attempts: 0, next_try: now });
entry.attempts = entry.attempts.saturating_add(1);
let secs = 1u64.checked_shl(entry.attempts.min(20)).unwrap_or(u64::MAX);
let delay = Duration::from_secs(secs).min(self.max_backoff);
entry.next_try = now + delay;
}
fn try_place(
nodes: &mut HashMap<NodeId, NodeResources>,
algorithm: &dyn SchedulingAlgorithm,
workload: &Workload,
) -> Option<(NodeId, Vec<u32>)> {
let req = &workload.resources;
let best_id = nodes
.values()
.filter(|n| n.can_fit(req))
.max_by(|a, b| {
algorithm
.score(workload, a)
.partial_cmp(&algorithm.score(workload, b))
.unwrap_or(std::cmp::Ordering::Equal)
})
.map(|n| n.node_id)?;
let node = nodes.get_mut(&best_id)?;
let before: Vec<u32> = node.gpus_allocated.clone();
if node.allocate(req) {
let gpu_ids = node
.gpus_allocated
.iter()
.filter(|d| !before.contains(d))
.copied()
.collect();
Some((best_id, gpu_ids))
} else {
None
}
}
fn do_release(nodes: &mut HashMap<NodeId, NodeResources>, a: &Assignment) {
if let Some(node) = nodes.get_mut(&a.node) {
node.release(&a.resources, &a.gpu_ids);
}
}
}
struct DesiredReplica {
workload: Workload,
job_id: String,
group: String,
ordinal: u32,
}
fn expand_jobs(jobs: &[Job]) -> HashMap<String, DesiredReplica> {
let mut out = HashMap::new();
for job in jobs {
for group in &job.groups {
let resources = group_resources(group);
for ordinal in 0..group.scaling.desired {
let id = format!("{}/{}/{}", job.id, group.name, ordinal);
let workload = Workload::new(id.clone(), format!("{}-{}", job.name, group.name))
.with_resources(resources.clone())
.with_priority(job.priority as i32);
out.insert(
id,
DesiredReplica {
workload,
job_id: job.id.clone(),
group: group.name.clone(),
ordinal,
},
);
}
}
}
out
}
fn group_resources(group: &TaskGroup) -> ResourceRequirements {
let mut cpu: u64 = 0;
let mut memory: u64 = 0;
let mut gpu: u32 = 0;
for task in &group.tasks {
cpu += task.resources.cpu as u64;
memory += task.resources.memory as u64;
gpu += task.resources.gpu.unwrap_or(0);
}
let mut req = ResourceRequirements::new().cpu(cpu).memory(memory);
if gpu > 0 {
req = req.gpu(gpu, 0);
}
req
}
#[cfg(test)]
mod tests {
use super::*;
use crate::autoscaler::AutoscalerConfig;
use crate::job::{Job, Task};
use crate::storage::MemoryStore;
use crate::types::{GpuResources, NodeId};
fn store() -> BoxedStateStore {
Arc::new(MemoryStore::new())
}
fn autoscaler() -> Arc<Autoscaler> {
Arc::new(Autoscaler::new(AutoscalerConfig::default().hysteresis_secs(0)).unwrap())
}
async fn submit(store: &BoxedStateStore, job: &Job) {
store_set_json(store.as_ref(), &keys::job(&job.id), job)
.await
.unwrap();
}
fn job_with_desired(name: &str, group: &str, cpu: u32, mem: u32, desired: u32) -> Job {
let mut job = Job::new(name).with_group(group, Task::new("t").resources(cpu, mem));
job.groups[0].scaling = crate::job::ScalingConfig::new(1, 10).with_desired(desired);
job
}
#[tokio::test]
async fn schedules_all_desired_replicas() {
let store = store();
let job = job_with_desired("svc", "api", 1000, 1024, 3);
submit(&store, &job).await;
let mut rec = Reconciler::new(store, autoscaler());
rec.register_node(NodeResources::new(NodeId::new(), 8000, 8192));
let report = rec.reconcile_once().await.unwrap();
assert_eq!(report.scheduled, 3);
assert_eq!(rec.bound_count(), 3);
let report2 = rec.reconcile_once().await.unwrap();
assert_eq!(report2.scheduled, 0);
assert_eq!(report2.released, 0);
assert_eq!(rec.bound_count(), 3);
}
#[tokio::test]
async fn scale_down_releases_and_restores_capacity() {
let store = store();
let mut job = job_with_desired("svc", "api", 1000, 1024, 3);
submit(&store, &job).await;
let mut rec = Reconciler::new(store.clone(), autoscaler());
let node_id = NodeId::new();
rec.register_node(NodeResources::new(node_id, 8000, 8192));
rec.reconcile_once().await.unwrap();
assert_eq!(rec.bound_count(), 3);
let used_at_3 = rec.nodes[&node_id].cpu_allocated;
assert_eq!(used_at_3, 3000);
job.groups[0].scaling.desired = 1;
submit(&store, &job).await;
let report = rec.reconcile_once().await.unwrap();
assert_eq!(report.released, 2);
assert_eq!(rec.bound_count(), 1);
assert_eq!(rec.nodes[&node_id].cpu_allocated, 1000);
}
#[tokio::test]
async fn node_loss_reschedules_onto_survivor() {
let store = store();
let job = job_with_desired("svc", "api", 1000, 1024, 2);
submit(&store, &job).await;
let mut rec = Reconciler::new(store, autoscaler());
let dead = NodeId::new();
rec.register_node(NodeResources::new(dead, 8000, 8192));
rec.reconcile_once().await.unwrap();
assert!(rec.assignments().iter().all(|a| a.node == dead));
let survivor = NodeId::new();
rec.remove_node(&dead);
rec.register_node(NodeResources::new(survivor, 8000, 8192));
let report = rec.reconcile_once().await.unwrap();
assert_eq!(rec.bound_count(), 2);
assert!(rec.assignments().iter().all(|a| a.node == survivor));
assert!(report.scheduled >= 2);
}
#[tokio::test]
async fn insufficient_capacity_backs_off_without_overallocating() {
let store = store();
let job = job_with_desired("svc", "api", 1000, 1024, 4);
submit(&store, &job).await;
let mut rec = Reconciler::new(store, autoscaler());
let node_id = NodeId::new();
rec.register_node(NodeResources::new(node_id, 2000, 8192));
let report = rec.reconcile_once().await.unwrap();
assert_eq!(report.scheduled, 2);
assert_eq!(report.pending, 2);
assert_eq!(rec.bound_count(), 2);
assert!(rec.nodes[&node_id].cpu_allocated <= 2000);
}
#[tokio::test]
async fn gpu_ids_are_freed_exactly_on_release() {
let store = store();
let mut job = Job::new("train").with_group(
"worker",
Task::new("w").with_resources(crate::job::Resources::new(500, 1024).with_gpu(1)),
);
job.groups[0].scaling = crate::job::ScalingConfig::new(1, 10).with_desired(2);
submit(&store, &job).await;
let mut rec = Reconciler::new(store.clone(), autoscaler());
let node_id = NodeId::new();
let node = NodeResources::new(node_id, 8000, 8192)
.with_gpu(GpuResources::new(0, "A100", 40960))
.with_gpu(GpuResources::new(1, "A100", 40960));
rec.register_node(node);
rec.reconcile_once().await.unwrap();
assert_eq!(rec.bound_count(), 2);
assert_eq!(rec.nodes[&node_id].gpus_allocated.len(), 2);
let mut ids: Vec<u32> = rec.assignments().iter().flat_map(|a| a.gpu_ids.clone()).collect();
ids.sort();
assert_eq!(ids, vec![0, 1]);
job.groups[0].scaling.desired = 0;
submit(&store, &job).await;
rec.reconcile_once().await.unwrap();
assert_eq!(rec.bound_count(), 0);
assert_eq!(rec.nodes[&node_id].gpus_allocated.len(), 0);
}
#[tokio::test]
async fn bootstrap_rebuilds_actual_state_and_capacity() {
let store = store();
let job = job_with_desired("svc", "api", 1000, 1024, 3);
submit(&store, &job).await;
let node_id = NodeId::new();
{
let mut rec = Reconciler::new(store.clone(), autoscaler());
rec.register_node(NodeResources::new(node_id, 8000, 8192));
rec.reconcile_once().await.unwrap();
assert_eq!(rec.bound_count(), 3);
}
let mut rec2 = Reconciler::new(store, autoscaler());
rec2.register_node(NodeResources::new(node_id, 8000, 8192));
rec2.bootstrap().await.unwrap();
assert_eq!(rec2.bound_count(), 3);
assert_eq!(rec2.nodes[&node_id].cpu_allocated, 3000);
}
struct FixedMetrics(f64, f64);
impl MetricsSource for FixedMetrics {
fn job_metrics(&self, _job_id: &str) -> Option<(f64, f64)> {
Some((self.0, self.1))
}
}
#[tokio::test]
async fn autoscaling_increases_desired_and_persists() {
let store = store();
let job = job_with_desired("svc", "api", 500, 512, 2);
let job_id = job.id.clone();
submit(&store, &job).await;
let mut rec = Reconciler::new(store.clone(), autoscaler())
.with_metrics_source(Arc::new(FixedMetrics(0.95, 0.95)));
rec.register_node(NodeResources::new(NodeId::new(), 16000, 32768));
let report = rec.reconcile_once().await.unwrap();
assert_eq!(report.rescaled, 1, "high utilization should scale up");
let reloaded: Job = store_get_json(store.as_ref(), &keys::job(&job_id))
.await
.unwrap()
.unwrap();
assert_eq!(reloaded.groups[0].scaling.desired, 3);
assert_eq!(rec.bound_count(), 3);
}
#[tokio::test]
async fn schedules_and_releases_sim_cells_via_gang() {
use crate::scheduler::sim::{AgentPolicy, CoPlacement, SimCell, SimWorld};
use crate::storage::StateStore;
let store = store();
let cell = SimCell::new("alpha", SimWorld::cpu(1000, 2048), Duration::from_millis(50))
.with_agent(AgentPolicy::gpu("p0", 200, 256, 4096))
.with_agent(AgentPolicy::gpu("p1", 200, 256, 4096))
.with_co_placement(CoPlacement::InterconnectLocalGpu);
store_set_json(store.as_ref(), &keys::simcell("alpha"), &cell)
.await
.unwrap();
let mut rec = Reconciler::new(store.clone(), autoscaler());
let node_id = NodeId::new();
rec.register_node(
NodeResources::new(node_id, 8000, 16384)
.with_gpu(GpuResources::new(0, "A100", 8192))
.with_gpu(GpuResources::new(1, "A100", 8192)),
);
let report = rec.reconcile_once().await.unwrap();
assert_eq!(report.sim_scheduled, 1);
assert_eq!(rec.sim_bound_count(), 1);
assert_eq!(rec.nodes[&node_id].gpus_allocated.len(), 2);
assert_eq!(rec.nodes[&node_id].cpu_allocated, 1000 + 200 + 200);
let r2 = rec.reconcile_once().await.unwrap();
assert_eq!(r2.sim_scheduled, 0);
assert_eq!(r2.sim_released, 0);
store.delete(&keys::simcell("alpha")).await.unwrap();
let r3 = rec.reconcile_once().await.unwrap();
assert_eq!(r3.sim_released, 1);
assert_eq!(rec.sim_bound_count(), 0);
assert_eq!(rec.nodes[&node_id].gpus_allocated.len(), 0);
assert_eq!(rec.nodes[&node_id].cpu_allocated, 0);
}
}