fix(placement): a young VM's unconsumed memory was handed out twice

The capacity harness scenario, on its first full run, caught what it was
written to catch:

  capacity:   architect peaked at 6 of 6 slot(s)
  FAIL       capacity: 'morpheus' peaked at 3 concurrent VM(s) with only 2 slot(s)
  capacity:   tank peaked at 6 of 6 slot(s)
  PASS       capacity: the over-capacity missions QUEUED
  PASS       capacity: all 16 queued/placed missions completed

`capacity_of` inferred the host's own footprint by subtracting the VMs' FULL
8 GiB claim from observed usage — which assumes they have already consumed it.
A VM booted seconds ago holds about an eighth. On morpheus (31757 MiB total,
4314 MiB idle, 2 slots) with 2 young VMs at ~6314 MiB observed, the inference
6314 - 16384 goes negative, clamps to the 2048 floor, and invents 2266 MiB —
exactly enough for a third VM on a two-slot node.

The footprint is only honestly MEASURABLE when nothing is committed, so
remember it then: `nodes.mem_baseline_mib`, sampled by `survey` whenever it
observes an idle node with fresh health. When VMs are committed, take the
LARGER of the remembered reading and the old inference — a node that was once
idle at 4 GiB and is now running a 20 GiB build must not be scored as idle,
which would be the same over-commit arrived at from the other direction. Both
directions have a test; the second is the one that would otherwise rot.

Raising HOST_BASELINE_FLOOR_MIB would have made this one node's numbers pass
and drifted the moment the fleet changed shape.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-09 05:25:28 -07:00
co-authored by Claude Opus 5
parent 2056bb1d9e
commit 4fedfcec30
4 changed files with 151 additions and 17 deletions
+111 -17
View File
@@ -152,6 +152,34 @@ impl PlacementError {
} }
} }
/// What the host itself costs, excluding its phase VMs.
///
/// With nothing committed the answer is simply what the node reports. With VMs
/// committed it cannot be measured, only remembered or inferred — and inference
/// is where this went wrong: subtracting the VMs' FULL 8 GiB claim from
/// observed usage assumes they have already consumed it. A VM booted seconds
/// ago holds about an eighth of that, so the subtraction goes negative, hits
/// the floor, and hands back memory the host is really using.
///
/// Measured on morpheus (31757 MiB total, 4314 MiB idle, 2 slots) with 2 VMs
/// committed and young: the inferred baseline collapsed to the 2048 floor,
/// freeing 2266 MiB — exactly enough to admit a 3rd VM to a 2-slot node. The
/// `capacity` harness scenario caught it on its first full run.
///
/// So prefer the remembered idle reading, and take the LARGER of it and the
/// inference: a host that has genuinely started doing non-VM work must not be
/// under-charged just because it was once idle at a lower number.
fn host_baseline(mem_used_mib: i64, committed_vms: i64, baseline_mib: Option<i64>) -> i64 {
if committed_vms <= 0 {
// Directly observable, and the only moment it is.
return mem_used_mib.max(HOST_BASELINE_FLOOR_MIB);
}
let inferred = mem_used_mib - committed_vms * MEM_PER_VM_MIB as i64;
inferred
.max(baseline_mib.unwrap_or(0))
.max(HOST_BASELINE_FLOOR_MIB)
}
/// The whole capacity decision for one node, as pure arithmetic. /// The whole capacity decision for one node, as pure arithmetic.
/// ///
/// Separated from every I/O concern so the numbers can be tested against measured /// Separated from every I/O concern so the numbers can be tested against measured
@@ -163,13 +191,12 @@ pub fn capacity_of(
mem_used_mib: i64, mem_used_mib: i64,
disk_free_gib: i64, disk_free_gib: i64,
committed_vms: i64, committed_vms: i64,
// What this node used the last time it was seen with nothing committed.
// `None` before it has ever been observed idle.
baseline_mib: Option<i64>,
headroom: f64, headroom: f64,
) -> Result<NodeCapacity, Unfit> { ) -> Result<NodeCapacity, Unfit> {
// What the host itself costs, inferred by subtracting what the VMs claimed. let host_baseline = host_baseline(mem_used_mib, committed_vms, baseline_mib);
// Floored because a node reporting less used than its VMs claim would
// otherwise produce a negative baseline and invent free memory.
let host_baseline =
(mem_used_mib - committed_vms * MEM_PER_VM_MIB as i64).max(HOST_BASELINE_FLOOR_MIB);
let committed_use = committed_vms * MEM_PER_VM_MIB as i64 + host_baseline; let committed_use = committed_vms * MEM_PER_VM_MIB as i64 + host_baseline;
// The worse of the two views. Observed alone under-counts a freshly booted // The worse of the two views. Observed alone under-counts a freshly booted
@@ -230,6 +257,7 @@ pub fn from_eval(
used / MIB, used / MIB,
row.disk_free_bytes.unwrap_or(0) / GIB, row.disk_free_bytes.unwrap_or(0) / GIB,
committed_vms, committed_vms,
row.mem_baseline_mib,
row.headroom_fresh(MAX_METRICS_AGE_SECS), row.headroom_fresh(MAX_METRICS_AGE_SECS),
) )
} }
@@ -350,6 +378,22 @@ pub async fn survey(
.collect(); .collect();
let committed = commitments(&live, &keys); let committed = commitments(&live, &keys);
// An idle node is the ONLY time its own footprint is measurable rather
// than inferred, so take the reading whenever we get one. Cheap: an
// UPDATE per idle node per survey, and it is what stops a young VM's
// unconsumed memory from being handed out a second time.
if committed == 0 {
if let Some(used) = row.mem_used_bytes.filter(|_| {
row.health_age_secs
.is_some_and(|a| a <= MAX_HEALTH_AGE_SECS)
}) {
let mib = used / (1024 * 1024);
if row.mem_baseline_mib != Some(mib) {
let _ = cm_db::repo::nodes::set_mem_baseline(pool, node, mib).await;
}
}
}
match from_eval(row, &name, committed) { match from_eval(row, &name, committed) {
Ok(c) => fit.push(c), Ok(c) => fit.push(c),
Err(why) => unfit.push((node, name, why)), Err(why) => unfit.push((node, name, why)),
@@ -446,17 +490,66 @@ mod tests {
/// Observed-usage-only arithmetic says (61440-12000-4096)/8192 = 5 more VMs. /// Observed-usage-only arithmetic says (61440-12000-4096)/8192 = 5 more VMs.
/// The node has room for ONE. Booking those five is a node in swap, and every /// The node has room for ONE. Booking those five is a node in swap, and every
/// VM on it slows down together. /// VM on it slows down together.
/// A node whose VMs have not yet consumed their claim must not hand the
/// difference out again.
///
/// This is the bug the `capacity` harness scenario found on its first full
/// run — "morpheus peaked at 3 concurrent VM(s) with only 2 slot(s)" — and
/// the numbers here are that node's real ones. Idle it reports 4314 MiB of
/// 31757 and the survey correctly gives it 2 slots. Two VMs later, each
/// holding roughly 1 GiB of its 8 GiB, observed usage is ~6314 MiB;
/// inferring the baseline as 6314 - 16384 goes negative, clamps to the
/// 2048 floor, and invents 2266 MiB — exactly one more VM than exists.
#[test]
fn a_young_vms_unconsumed_memory_is_not_handed_out_twice() {
// Idle: the reading that gets remembered, and the slot count it implies.
let idle = capacity_of(nid(3), "morpheus", 31757, 4314, 312, 0, None, 90.0)
.expect("an idle morpheus fits VMs");
assert_eq!(idle.slots, 2, "idle capacity is the number we are defending");
// Two committed, both young. WITHOUT the remembered baseline this
// returned 1 slot and admitted a third VM.
let inferred = capacity_of(nid(3), "morpheus", 31757, 6314, 312, 2, None, 90.0);
assert!(
inferred.is_ok(),
"the old inference is preserved as the no-baseline fallback"
);
// WITH it, the node is correctly full.
let remembered = capacity_of(nid(3), "morpheus", 31757, 6314, 312, 2, Some(4314), 90.0);
assert!(
matches!(remembered, Err(Unfit::AtCapacity { committed: 2, .. })),
"a 2-slot node with 2 VMs committed is FULL, got {remembered:?}"
);
}
/// A host that starts doing real work outside its VMs is charged for it.
///
/// The remembered baseline is a floor, not a substitute. If it replaced the
/// inference outright, a node that was idle at 4 GiB and is now running a
/// 20 GiB build would still be scored as if it were idle — the same
/// over-commit, arrived at from the opposite direction.
#[test]
fn a_remembered_baseline_never_under_charges_a_busy_host() {
// 1 VM committed and consumed (8192), plus 20 GiB of non-VM work.
let used = 8192 + 20480;
let c = capacity_of(nid(3), "busy", 61440, used, 800, 1, Some(4096), 90.0)
.expect("still has room");
// Inference says 20480; the stale 4096 baseline must not win.
assert_eq!(c.used_eff_mib, used, "observed usage is charged in full");
}
#[test] #[test]
fn a_sold_out_node_is_not_mistaken_for_an_idle_one() { fn a_sold_out_node_is_not_mistaken_for_an_idle_one() {
let observed_only = let observed_only =
capacity_of(nid(1), "tank", 61440, 12000, 800, 0, 50.0).expect("fits"); capacity_of(nid(1), "tank", 61440, 12000, 800, 0, None, 50.0).expect("fits");
assert_eq!( assert_eq!(
observed_only.slots, 5, observed_only.slots, 5,
"this is what utilisation alone claims — the bug being fixed" "this is what utilisation alone claims — the bug being fixed"
); );
let with_commitments = let with_commitments =
capacity_of(nid(1), "tank", 61440, 12000, 800, 5, 50.0).expect("fits"); capacity_of(nid(1), "tank", 61440, 12000, 800, 5, None, 50.0).expect("fits");
assert_eq!( assert_eq!(
with_commitments.slots, 1, with_commitments.slots, 1,
"five 8 GiB claims are already spoken for, whatever the guests have touched" "five 8 GiB claims are already spoken for, whatever the guests have touched"
@@ -468,33 +561,33 @@ mod tests {
#[test] #[test]
fn the_measured_fleet_gets_the_slots_it_actually_has() { fn the_measured_fleet_gets_the_slots_it_actually_has() {
// tank: 60 GiB, ~6 GiB used at idle. // tank: 60 GiB, ~6 GiB used at idle.
let tank = capacity_of(nid(1), "tank", 61440, 6144, 869, 0, 90.0).unwrap(); let tank = capacity_of(nid(1), "tank", 61440, 6144, 869, 0, None, 90.0).unwrap();
assert_eq!(tank.slots, 6); assert_eq!(tank.slots, 6);
// architect: 60 GiB, ~7 GiB used. // architect: 60 GiB, ~7 GiB used.
let arch = capacity_of(nid(2), "architect", 61440, 7168, 388, 0, 90.0).unwrap(); let arch = capacity_of(nid(2), "architect", 61440, 7168, 388, 0, None, 90.0).unwrap();
assert_eq!(arch.slots, 6); assert_eq!(arch.slots, 6);
// morpheus: 31 GiB — deliberately the conservative 2, not 3. Three VMs // morpheus: 31 GiB — deliberately the conservative 2, not 3. Three VMs
// would leave under 2 GiB for the host, which is where the OOM killer // would leave under 2 GiB for the host, which is where the OOM killer
// lives, and an OOM-killed VM looks like an agent that gave up. // lives, and an OOM-killed VM looks like an agent that gave up.
let morph = capacity_of(nid(3), "morpheus", 31744, 5120, 312, 0, 90.0).unwrap(); let morph = capacity_of(nid(3), "morpheus", 31744, 5120, 312, 0, None, 90.0).unwrap();
assert_eq!(morph.slots, 2); assert_eq!(morph.slots, 2);
} }
/// Spread, don't stack; then real load; then determinism. /// Spread, don't stack; then real load; then determinism.
#[test] #[test]
fn ranking_prefers_free_slots_then_headroom_then_a_stable_order() { fn ranking_prefers_free_slots_then_headroom_then_a_stable_order() {
let a = capacity_of(nid(1), "a", 61440, 6144, 800, 0, 40.0).unwrap(); // 6 slots let a = capacity_of(nid(1), "a", 61440, 6144, 800, 0, None, 40.0).unwrap(); // 6 slots
let b = capacity_of(nid(2), "b", 61440, 6144, 800, 3, 90.0).unwrap(); // 3 slots let b = capacity_of(nid(2), "b", 61440, 6144, 800, 3, None, 90.0).unwrap(); // 3 slots
assert_eq!(rank(vec![b.clone(), a.clone()])[0].name, "a", "more slots wins"); assert_eq!(rank(vec![b.clone(), a.clone()])[0].name, "a", "more slots wins");
// Equal slots → the node under less real load. // Equal slots → the node under less real load.
let busy = capacity_of(nid(3), "busy", 61440, 6144, 800, 0, 10.0).unwrap(); let busy = capacity_of(nid(3), "busy", 61440, 6144, 800, 0, None, 10.0).unwrap();
let idle = capacity_of(nid(4), "idle", 61440, 6144, 800, 0, 95.0).unwrap(); let idle = capacity_of(nid(4), "idle", 61440, 6144, 800, 0, None, 95.0).unwrap();
assert_eq!(rank(vec![busy.clone(), idle.clone()])[0].name, "idle"); assert_eq!(rank(vec![busy.clone(), idle.clone()])[0].name, "idle");
// Equal on both → same answer twice. `last_seen DESC` could not promise this. // Equal on both → same answer twice. `last_seen DESC` could not promise this.
let x = capacity_of(nid(9), "x", 61440, 6144, 800, 0, 50.0).unwrap(); let x = capacity_of(nid(9), "x", 61440, 6144, 800, 0, None, 50.0).unwrap();
let y = capacity_of(nid(8), "y", 61440, 6144, 800, 0, 50.0).unwrap(); let y = capacity_of(nid(8), "y", 61440, 6144, 800, 0, None, 50.0).unwrap();
assert_eq!(rank(vec![x.clone(), y.clone()])[0].name, "y"); assert_eq!(rank(vec![x.clone(), y.clone()])[0].name, "y");
assert_eq!(rank(vec![y, x])[0].name, "y"); assert_eq!(rank(vec![y, x])[0].name, "y");
} }
@@ -518,7 +611,7 @@ mod tests {
/// names the real problem. /// names the real problem.
#[test] #[test]
fn a_node_short_of_disk_is_refused_even_with_memory_to_spare() { fn a_node_short_of_disk_is_refused_even_with_memory_to_spare() {
let e = capacity_of(nid(1), "tank", 61440, 6144, 25, 0, 90.0).unwrap_err(); let e = capacity_of(nid(1), "tank", 61440, 6144, 25, 0, None, 90.0).unwrap_err();
assert!(matches!(e, Unfit::NoDisk { free_gib: 25 }), "{e:?}"); assert!(matches!(e, Unfit::NoDisk { free_gib: 25 }), "{e:?}");
assert!(e.reason().contains("25 GiB")); assert!(e.reason().contains("25 GiB"));
} }
@@ -577,6 +670,7 @@ mod tests {
mem_total_bytes: Some(total), mem_total_bytes: Some(total),
mem_used_bytes: Some(used), mem_used_bytes: Some(used),
disk_free_bytes: Some(disk_free), disk_free_bytes: Some(disk_free),
mem_baseline_mib: None,
health_age_secs: Some(3.0), health_age_secs: Some(3.0),
metrics_age_secs: Some(3.0), metrics_age_secs: Some(3.0),
} }
+6
View File
@@ -78,6 +78,10 @@ pub struct EvalRow {
pub mem_total_bytes: Option<i64>, pub mem_total_bytes: Option<i64>,
pub mem_used_bytes: Option<i64>, pub mem_used_bytes: Option<i64>,
pub disk_free_bytes: Option<i64>, pub disk_free_bytes: Option<i64>,
/// Memory in use with no phase VMs committed, remembered from the last time
/// this node was observed idle. `None` until then, which makes placement
/// fall back to inferring it — the behaviour that over-committed morpheus.
pub mem_baseline_mib: Option<i64>,
/// Age of each source. Placement is fail-closed on stale health (a node whose /// Age of each source. Placement is fail-closed on stale health (a node whose
/// RAM we cannot read is one we are guessing at), and demotes rather than /// RAM we cannot read is one we are guessing at), and demotes rather than
/// excludes on stale Beszel metrics, which only ever break ties. /// excludes on stale Beszel metrics, which only ever break ties.
@@ -133,6 +137,7 @@ pub async fn eval_all(pool: &PgPool) -> Result<Vec<EvalRow>, DbError> {
h.mem_total AS mem_total_bytes, h.mem_total AS mem_total_bytes,
h.mem_used AS mem_used_bytes, h.mem_used AS mem_used_bytes,
h.disk_free AS disk_free_bytes, h.disk_free AS disk_free_bytes,
n.mem_baseline_mib,
EXTRACT(EPOCH FROM now() - h.captured_at)::float8 AS health_age_secs, EXTRACT(EPOCH FROM now() - h.captured_at)::float8 AS health_age_secs,
EXTRACT(EPOCH FROM now() - m.updated_at)::float8 AS metrics_age_secs EXTRACT(EPOCH FROM now() - m.updated_at)::float8 AS metrics_age_secs
FROM nodes n FROM nodes n
@@ -156,6 +161,7 @@ pub async fn eval_all(pool: &PgPool) -> Result<Vec<EvalRow>, DbError> {
mem_total_bytes: r.get("mem_total_bytes"), mem_total_bytes: r.get("mem_total_bytes"),
mem_used_bytes: r.get("mem_used_bytes"), mem_used_bytes: r.get("mem_used_bytes"),
disk_free_bytes: r.get("disk_free_bytes"), disk_free_bytes: r.get("disk_free_bytes"),
mem_baseline_mib: r.get("mem_baseline_mib"),
health_age_secs: r.get("health_age_secs"), health_age_secs: r.get("health_age_secs"),
metrics_age_secs: r.get("metrics_age_secs"), metrics_age_secs: r.get("metrics_age_secs"),
}) })
+14
View File
@@ -409,3 +409,17 @@ mod tests {
assert_eq!(backend_key(Some("agent-terminal")), "agent-terminal"); assert_eq!(backend_key(Some("agent-terminal")), "agent-terminal");
} }
} }
/// Remember a node's idle memory footprint.
///
/// Only ever called with a reading taken while the node had ZERO phase VMs
/// committed — that is the one moment the number is honestly observable.
/// Writing it at any other time would record the VMs as part of the host.
pub async fn set_mem_baseline(pool: &PgPool, node_id: NodeId, mib: i64) -> Result<(), DbError> {
sqlx::query("UPDATE nodes SET mem_baseline_mib = $2 WHERE id = $1")
.bind(node_id.as_uuid())
.bind(mib)
.execute(pool)
.await?;
Ok(())
}
+20
View File
@@ -0,0 +1,20 @@
-- What a node's memory looks like with NO phase VMs on it.
--
-- `vm_placement::capacity_of` used to infer the host's own footprint by
-- subtracting the VMs' full claim from observed usage. That is right only when
-- the VMs have actually consumed what they claimed. A VM booted seconds ago
-- holds ~1 GiB of its 8 GiB, so the subtraction goes negative, hits the 2 GiB
-- floor, and hands back memory the host is really using.
--
-- Measured on morpheus (31757 MiB total, 4314 MiB idle) with 2 VMs committed:
-- the inferred baseline collapsed from 4314 to 2048, which freed 2266 MiB —
-- just enough to fit a 3rd VM into a 2-slot node. The capacity harness caught
-- it as "morpheus peaked at 3 concurrent VM(s) with only 2 slot(s)".
--
-- So remember the baseline instead of re-deriving it: it is only honestly
-- observable when the node has nothing committed, and that is exactly when it
-- is recorded.
ALTER TABLE nodes ADD COLUMN IF NOT EXISTS mem_baseline_mib BIGINT;
COMMENT ON COLUMN nodes.mem_baseline_mib IS
'Memory in use with zero phase VMs committed, sampled by vm_placement::survey. NULL until first observed idle.';