use serde_json::json;
use super::lifecycle::LifecycleState;
use super::plane::{PlacementControlPlane, PlacementRecord, now_ms};
use super::plane_lifecycle::{ControllerLife, LifecycleEvent, backoff_ms, event};
use crate::chain;
impl PlacementControlPlane {
fn blockers(&self, rec: &PlacementRecord, c: &ControllerLife) -> (Option<String>, Option<String>) {
let kind_ok = self.kinds.get(&rec.kind).is_none_or(|k| k.migratable());
let hard = match &c.order {
_ if !c.was_running => Some("it was not running when it was lost".to_string()),
None => Some("no stored placement order (adopted instance)".to_string()),
Some(o) if !o.package_dir.is_dir() => Some("its package directory is gone".to_string()),
Some(_) if c.reschedules >= c.policy.max_reschedules => {
Some(format!("already rescheduled {} times", c.reschedules))
}
Some(_) => None,
};
let soft = match &c.order {
Some(o) if o.pin.is_some() => Some(format!("pinned to {}", o.pin.as_deref().unwrap_or(""))),
_ if !c.policy.migratable => {
Some("non-migratable (operator, or the workload needs attached hardware)".to_string())
}
_ if !kind_ok => Some(format!("kind {} is non-migratable", rec.kind)),
_ => None,
};
(hard, soft)
}
fn alert_once(&self, rec: &PlacementRecord, why: String, out: &mut Vec<LifecycleEvent>) {
let seen = self.life_of(&rec.instance_id).and_then(|c| c.last_error);
if seen.as_deref() == Some(why.as_str()) {
return;
}
self.chain_event(
chain::EVENT_KIND_WORKLOAD_LIFECYCLE,
json!({ "phase": "alert", "instance_id": rec.instance_id, "node": rec.node_id,
"workload": rec.workload, "reason": format!("lost, not placed again: {why}") }),
);
self.update_life(&rec.instance_id, |c| c.last_error = Some(why.clone()));
out.push(event(rec, "alert", why));
}
pub(super) async fn reschedule(&self, rec: &PlacementRecord, node_dead: bool, out: &mut Vec<LifecycleEvent>) {
let Some(c) = self.life_of(&rec.instance_id) else { return };
let now = now_ms();
if now < c.next_attempt_ms {
return;
}
let (hard, soft) = self.blockers(rec, &c);
if let Some(why) = hard {
return self.alert_once(rec, why, out);
}
if let (Some(why), true) = (&soft, node_dead) {
return self.alert_once(rec, why.clone(), out);
}
let Some(mut order) = c.order.clone() else { return };
if node_dead {
order.avoid.push(rec.node_id.clone());
} else if soft.is_some() {
order.pin = Some(rec.node_id.clone());
}
order.dry_run = false;
let attempt = self.place_timed(&order, Some(self.cfg.reschedule_timeout)).await;
let new = match attempt {
Ok(r) if r.placed.is_some() => (r.placed.clone().expect("checked"), r.decision_id),
Ok(r) => {
let why = r
.attempts
.last()
.map(|a| format!("{}: {}", a.outcome, a.reason.clone().unwrap_or_default()))
.unwrap_or_else(|| "no node accepted it".into());
return self.attempt_failed(rec, &c, why, out);
}
Err(e) => return self.attempt_failed(rec, &c, e.to_string(), out),
};
let (new, decision_id) = new;
if let Ok(mut l) = self.lives.lock() {
let mut fresh = ControllerLife::new(
if order.start { LifecycleState::Running } else { LifecycleState::Loaded },
c.policy,
c.order.clone(),
c.reschedules + 1,
);
fresh.was_running = true;
l.insert(new.instance_id.clone(), fresh);
}
self.persist();
let phase = if node_dead {
self.set_state(rec, LifecycleState::Rescheduled, &format!("replaced on {}", new.node_id), false);
"rescheduled"
} else {
if new.instance_id != rec.instance_id {
self.forget_instances(std::slice::from_ref(&rec.instance_id));
} else {
debug_assert_eq!(new.node_id, rec.node_id, "same instance id on another node");
}
"replaced_after_loss"
};
self.chain_event(
chain::EVENT_KIND_WORKLOAD_MIGRATE,
json!({ "phase": phase, "reason": if node_dead { "node_dead" } else { "instance_lost" },
"workload": rec.workload, "from_node": rec.node_id, "from_instance": rec.instance_id,
"to_node": new.node_id, "to_instance": new.instance_id, "decision_id": decision_id }),
);
let action = if node_dead { "rescheduled" } else { "replaced" };
out.push(event(rec, action, format!("now {} on {}", new.instance_id, new.node_id)));
}
fn attempt_failed(&self, rec: &PlacementRecord, c: &ControllerLife, why: String, out: &mut Vec<LifecycleEvent>) {
let attempts = c.attempts + 1;
let next = now_ms() + backoff_ms(self.cfg.retry_backoff.as_millis() as u64, attempts);
let fresh = c.last_error.as_deref() != Some(why.as_str());
self.update_life(&rec.instance_id, |l| {
l.attempts = attempts;
l.next_attempt_ms = next;
l.last_error = Some(why.clone());
});
if fresh {
self.chain_event(
chain::EVENT_KIND_WORKLOAD_REFUSE,
json!({ "phase": "reschedule", "instance_id": rec.instance_id, "node": rec.node_id,
"workload": rec.workload, "reason": why, "attempt": attempts,
"next": "retry with backoff" }),
);
out.push(event(rec, "reschedule_failed", why));
}
if attempts == c.policy.max_reschedules {
self.chain_event(
chain::EVENT_KIND_WORKLOAD_LIFECYCLE,
json!({ "phase": "alert", "instance_id": rec.instance_id, "node": rec.node_id,
"workload": rec.workload,
"reason": format!("no node after {attempts} attempts; still retrying with backoff (at most every 5 minutes)") }),
);
out.push(event(rec, "alert", format!("no node after {attempts} attempts; still retrying")));
}
}
}