fix(placement): a drained previous node re-places the phase instead of failing it

`drain-midmission` found this. `choose` treated its `want` argument as a hard
requirement, and the only caller passes `missions.target_node_id` — which is
not an operator's choice, only where the PREVIOUS phase happened to run. Two
consequences, both wrong:

  - A node drained or filled between phases produced `TargetUnfit`, which
    `is_transient()` says false to, so `phase_runner` FAILED the phase rather
    than queueing or moving it. The queue silently did not apply to the second
    phase of any mission.
  - While the node stayed fit, every later phase went straight back to it
    regardless of ranking — accidental mission-to-node affinity, which this
    module's own header says must not exist.

Mission state lives on the gateway (inject -> run -> collect -> destroy), so
re-placing costs nothing. The pin is now advisory: preferred while it fits,
and when it does not, the reason is logged and ranking proceeds. `TargetUnfit`
is deleted rather than left unconstructed, so it cannot come back as a
non-transient failure by accident.

The scenario had its own race: it waited for phase 0 to COMPLETE before
draining, but warm phases finish in ~80s against a 10s placement sweep, so
phase 1 was often already placed — and the run then blamed the platform for
running on a node that was not yet drained. It now drains while phase 0 is
still running, which does not disturb a live VM and is the more faithful test.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-09 06:34:48 -07:00
co-authored by Claude Opus 5
parent d4af58be85
commit 13a35138e9
2 changed files with 73 additions and 21 deletions
+54 -11
View File
@@ -119,8 +119,6 @@ pub enum PlacementError {
FleetAtCapacity { report: String }, FleetAtCapacity { report: String },
/// We could not READ capacity. Must never be reported as "full". /// We could not READ capacity. Must never be reported as "full".
FleetUnreadable { report: String }, FleetUnreadable { report: String },
/// An explicit target exists but cannot take the work.
TargetUnfit { node: NodeId, why: Unfit },
} }
impl PlacementError { impl PlacementError {
@@ -143,11 +141,6 @@ impl PlacementError {
PlacementError::FleetUnreadable { report } => format!( PlacementError::FleetUnreadable { report } => format!(
"cannot read node capacity — this is NOT a full fleet; check the node daemons.\n{report}" "cannot read node capacity — this is NOT a full fleet; check the node daemons.\n{report}"
), ),
PlacementError::TargetUnfit { node, why } => format!(
"node {} cannot take this phase: {}",
node.as_uuid(),
why.reason()
),
} }
} }
} }
@@ -449,15 +442,30 @@ pub async fn choose(
} }
let report = report(&fit, &unfit); let report = report(&fit, &unfit);
// `want` is ADVISORY, always. The only caller passes `missions.target_node_id`,
// which is simply where the PREVIOUS phase ran — not an operator's choice.
// Treating it as a requirement had two consequences, both wrong:
//
// - a drained or full previous node failed the phase outright, because
// the resulting error was not transient and so never reached the queue.
// `drain-midmission` was written to exercise exactly that path.
// - and while the node stayed fit, every later phase went back to it
// regardless of ranking — accidental mission-to-node affinity, which
// this module's own header says must not exist.
//
// Mission state lives on the gateway (inject -> run -> collect -> destroy),
// so re-placing costs nothing. Prefer the pin when it still fits; say out
// loud why it did not when it does not, and rank as usual.
if let Some(want) = want { if let Some(want) = want {
if let Some(c) = fit.iter().find(|c| c.node_id.as_uuid() == want) { if let Some(c) = fit.iter().find(|c| c.node_id.as_uuid() == want) {
return Ok(c.node_id); return Ok(c.node_id);
} }
if let Some((n, _, why)) = unfit.iter().find(|(n, _, _)| n.as_uuid() == want) { if let Some((_, name, why)) = unfit.iter().find(|(n, _, _)| n.as_uuid() == want) {
return Err(PlacementError::TargetUnfit { node: *n, why: why.clone() }); eprintln!(
"vm_placement: the previous phase's node {name} is {} — re-placing this phase",
why.reason()
);
} }
// Target is not even a candidate — fall through and place it somewhere
// capable rather than failing, since the target was advisory.
} }
if let Some(best) = fit.into_iter().next() { if let Some(best) = fit.into_iter().next() {
@@ -500,6 +508,41 @@ mod tests {
/// holding roughly 1 GiB of its 8 GiB, observed usage is ~6314 MiB; /// holding roughly 1 GiB of its 8 GiB, observed usage is ~6314 MiB;
/// inferring the baseline as 6314 - 16384 goes negative, clamps to the /// inferring the baseline as 6314 - 16384 goes negative, clamps to the
/// 2048 floor, and invents 2266 MiB — exactly one more VM than exists. /// 2048 floor, and invents 2266 MiB — exactly one more VM than exists.
/// A drained previous node re-places the next phase; it does not fail it.
///
/// `drain-midmission` found this: `choose` treated `missions.target_node_id`
/// — which is only ever "where the last phase ran" — as a hard requirement,
/// so a node an operator cordoned mid-mission produced a non-transient
/// error that failed the phase instead of moving it. The same path also
/// gave every later phase silent affinity back to the first node.
#[test]
fn an_unfit_previous_node_is_re_placed_not_refused() {
let drained = uuid::Uuid::from_u128(1);
let healthy = capacity_of(nid(2), "tank", 61440, 6144, 800, 0, None, 90.0).unwrap();
// Stand in for `choose`'s decision: the pin is consulted, then dropped.
let fit = vec![healthy.clone()];
let picked = fit
.iter()
.find(|c| c.node_id.as_uuid() == drained)
.or_else(|| fit.first())
.expect("a fit node exists");
assert_eq!(
picked.node_id,
nid(2),
"with the pinned node absent from `fit`, ranking must still yield a node"
);
// And the error that used to be produced here no longer exists, so it
// cannot be reintroduced as a non-transient failure by accident.
for e in [
PlacementError::FleetAtCapacity { report: String::new() },
PlacementError::FleetUnreadable { report: String::new() },
] {
assert!(e.is_transient(), "both no-node outcomes must QUEUE, not fail");
}
}
#[test] #[test]
fn a_young_vms_unconsumed_memory_is_not_handed_out_twice() { fn a_young_vms_unconsumed_memory_is_not_handed_out_twice() {
// Idle: the reading that gets remembered, and the slot count it implies. // Idle: the reading that gets remembered, and the slot count it implies.
+19 -10
View File
@@ -1283,13 +1283,22 @@ except Exception: print(0)')
info "drain-midmission: mission=$mission" info "drain-midmission: mission=$mission"
api "$token" PATCH "/api/missions/$mission/status" '{"status":"running"}' >/dev/null api "$token" PATCH "/api/missions/$mission/status" '{"status":"running"}' >/dev/null
# Wait for phase 0 to finish, then drain the node it used — before phase 1 # Drain the node while phase 0 is still RUNNING on it.
# is placed. Polling is on the PHASE, not the mission: by the time the #
# mission is terminal there is nothing left to re-place. # The first version waited for phase 0 to COMPLETE and lost the race: warm
# phases finish in ~80s and `start_pending_phases` sweeps every 10s, so
# phase 1 was routinely already placed by the time the drain landed — and the
# run then reported "phase 1 ran on the DRAINED node" for a drain that had not
# yet happened. Draining does not touch a VM already running, only future
# placement, so doing it mid-phase is both safe and the more faithful test.
#
# Polling is on the PHASE, not the mission: by the time the mission is
# terminal there is nothing left to re-place.
while [ "$waited" -lt "$MISSION_TIMEOUT" ]; do while [ "$waited" -lt "$MISSION_TIMEOUT" ]; do
first_node=$(ssh "$HOST" "docker exec clawmates_postgres_1 psql -U postgres -d clawmates -tAc \ first_node=$(ssh "$HOST" "docker exec clawmates_postgres_1 psql -U postgres -d clawmates -tAc \
\"select m.target_node_id from mission_phases p join missions m on m.id = p.mission_id \"select m.target_node_id from mission_phases p join missions m on m.id = p.mission_id
where p.mission_id = '$mission' and p.order_idx = 0 and p.status = 'completed';\"" \ where p.mission_id = '$mission' and p.order_idx = 0
and p.status in ('running','completed') and m.target_node_id is not null;\"" \
| head -1 | tr -d '[:space:]') | head -1 | tr -d '[:space:]')
[ -n "$first_node" ] && break [ -n "$first_node" ] && break
# A mission that died before phase 0 completed has nothing to test. # A mission that died before phase 0 completed has nothing to test.
@@ -1297,16 +1306,16 @@ except Exception: print(0)')
try: print(json.load(sys.stdin).get("status","")) try: print(json.load(sys.stdin).get("status",""))
except Exception: pass' 2>/dev/null) except Exception: pass' 2>/dev/null)
case "$status" in failed|cancelled) case "$status" in failed|cancelled)
norun "drain-midmission: mission ended $status before phase 0 completed"; return 1 ;; norun "drain-midmission: mission ended $status before phase 0 started"; return 1 ;;
esac esac
sleep 10 sleep 3
waited=$((waited + 10)) waited=$((waited + 3))
done done
if [ -z "$first_node" ]; then if [ -z "$first_node" ]; then
norun "drain-midmission: phase 0 never completed in ${MISSION_TIMEOUT}s" norun "drain-midmission: phase 0 never started in ${MISSION_TIMEOUT}s"
return 1 return 1
fi fi
info "drain-midmission: phase 0 ran on $first_node — draining it" info "drain-midmission: phase 0 is on $first_node — draining it now"
ssh "$HOST" "docker exec clawmates_postgres_1 psql -U postgres -d clawmates -tAc \ ssh "$HOST" "docker exec clawmates_postgres_1 psql -U postgres -d clawmates -tAc \
\"update nodes set status='draining' where id='$first_node';\"" >/dev/null \"update nodes set status='draining' where id='$first_node';\"" >/dev/null
@@ -1332,7 +1341,7 @@ except Exception: pass' 2>/dev/null)
|| { fail "drain-midmission: could not read DRAIN.md from the pushed branch"; return 1; } || { fail "drain-midmission: could not read DRAIN.md from the pushed branch"; return 1; }
case "$delivered" in case "$delivered" in
*PHASE-ONE-OK*PHASE-TWO-OK*) *PHASE-ONE-OK*PHASE-TWO-OK*)
pass "drain-midmission: phase 1 read phase 0's work from a DIFFERENT node and appended" ;; pass "drain-midmission: phase 1 read phase 0's work and appended to it" ;;
*PRIOR-PHASE-WORK-WAS-LOST*) *PRIOR-PHASE-WORK-WAS-LOST*)
fail "drain-midmission: re-placement lost the previous phase's work" ;; fail "drain-midmission: re-placement lost the previous phase's work" ;;
*) *)