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]>
190 lines
7.4 KiB
Rust
190 lines
7.4 KiB
Rust
//! Rich per-node metrics (from the workspace's Beszel hub). One latest snapshot
|
|
//! per node: scalar columns for the fleet cards + rules engine, plus a JSONB blob
|
|
//! for the per-node monitor page.
|
|
|
|
use cm_domain::{NodeId, WorkspaceId};
|
|
use serde_json::Value;
|
|
use sqlx::{PgPool, Row};
|
|
use uuid::Uuid;
|
|
|
|
use crate::DbError;
|
|
|
|
/// The latest metric snapshot for a node (nullable scalars + the full blob).
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct NodeMetrics {
|
|
pub cpu_pct: Option<f64>,
|
|
pub mem_pct: Option<f64>,
|
|
pub disk_pct: Option<f64>,
|
|
pub gpu_pct: Option<f64>,
|
|
pub temp_max: Option<f64>,
|
|
pub net_sent_ps: Option<i64>,
|
|
pub net_recv_ps: Option<i64>,
|
|
pub disk_read_ps: Option<i64>,
|
|
pub disk_write_ps: Option<i64>,
|
|
pub load1: Option<f64>,
|
|
pub container_count: Option<i32>,
|
|
pub data: Value,
|
|
}
|
|
|
|
/// Upsert a node's latest metrics snapshot.
|
|
pub async fn upsert(pool: &PgPool, node_id: NodeId, m: &NodeMetrics) -> Result<(), DbError> {
|
|
sqlx::query(
|
|
"INSERT INTO node_metrics
|
|
(node_id, updated_at, cpu_pct, mem_pct, disk_pct, gpu_pct, temp_max,
|
|
net_sent_ps, net_recv_ps, disk_read_ps, disk_write_ps, load1, container_count, data)
|
|
VALUES ($1, now(), $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
|
ON CONFLICT (node_id) DO UPDATE SET
|
|
updated_at = now(), cpu_pct = excluded.cpu_pct, mem_pct = excluded.mem_pct,
|
|
disk_pct = excluded.disk_pct, gpu_pct = excluded.gpu_pct, temp_max = excluded.temp_max,
|
|
net_sent_ps = excluded.net_sent_ps, net_recv_ps = excluded.net_recv_ps,
|
|
disk_read_ps = excluded.disk_read_ps, disk_write_ps = excluded.disk_write_ps,
|
|
load1 = excluded.load1, container_count = excluded.container_count, data = excluded.data",
|
|
)
|
|
.bind(node_id.as_uuid())
|
|
.bind(m.cpu_pct)
|
|
.bind(m.mem_pct)
|
|
.bind(m.disk_pct)
|
|
.bind(m.gpu_pct)
|
|
.bind(m.temp_max)
|
|
.bind(m.net_sent_ps)
|
|
.bind(m.net_recv_ps)
|
|
.bind(m.disk_read_ps)
|
|
.bind(m.disk_write_ps)
|
|
.bind(m.load1)
|
|
.bind(m.container_count)
|
|
.bind(&m.data)
|
|
.execute(pool)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// A node's current evaluatable scalars (Beszel-tapped, falling back to the
|
|
/// heartbeat health), for the rules engine + metrics-aware placement.
|
|
#[derive(Debug, Clone)]
|
|
pub struct EvalRow {
|
|
pub node_id: NodeId,
|
|
pub workspace_id: WorkspaceId,
|
|
pub status: String,
|
|
pub cpu_pct: Option<f64>,
|
|
pub mem_pct: Option<f64>,
|
|
pub disk_pct: Option<f64>,
|
|
pub gpu_pct: Option<f64>,
|
|
pub temp_max: Option<f64>,
|
|
pub load1: Option<f64>,
|
|
/// Absolute memory, for CAPACITY rather than utilisation. `mem_pct` cannot
|
|
/// answer "does another 8 GiB VM fit" — a node at 20% of 31 GiB and one at
|
|
/// 20% of 60 GiB report the same percentage and hold a different number of
|
|
/// VMs. From the 5s heartbeat, which is the only source with absolutes.
|
|
pub mem_total_bytes: Option<i64>,
|
|
pub mem_used_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
|
|
/// RAM we cannot read is one we are guessing at), and demotes rather than
|
|
/// excludes on stale Beszel metrics, which only ever break ties.
|
|
pub health_age_secs: Option<f64>,
|
|
pub metrics_age_secs: Option<f64>,
|
|
}
|
|
|
|
impl EvalRow {
|
|
/// Look up a metric by rule name.
|
|
pub fn metric(&self, name: &str) -> Option<f64> {
|
|
match name {
|
|
"cpu_pct" => self.cpu_pct,
|
|
"mem_pct" => self.mem_pct,
|
|
"disk_pct" => self.disk_pct,
|
|
"gpu_pct" => self.gpu_pct,
|
|
"temp_max" => self.temp_max,
|
|
"load1" => self.load1,
|
|
_ => None,
|
|
}
|
|
}
|
|
/// Free headroom heuristic (higher = more capacity) for placement ranking.
|
|
pub fn headroom(&self) -> f64 {
|
|
let used = self.cpu_pct.unwrap_or(0.0).max(self.mem_pct.unwrap_or(0.0));
|
|
100.0 - used
|
|
}
|
|
|
|
/// [`headroom`] when at least one source is recent, otherwise the WORST
|
|
/// possible score.
|
|
///
|
|
/// Used only as a placement TIEBREAK, never as an admission gate: stale
|
|
/// metrics may cost a node a tie, they may never win one. Admission is
|
|
/// decided by absolute memory from the heartbeat, which has its own
|
|
/// freshness check.
|
|
pub fn headroom_fresh(&self, max_age_secs: f64) -> f64 {
|
|
let fresh = |a: Option<f64>| a.is_some_and(|x| x <= max_age_secs);
|
|
if fresh(self.health_age_secs) || fresh(self.metrics_age_secs) {
|
|
self.headroom()
|
|
} else {
|
|
0.0
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Every node's current metric scalars (merged Beszel + heartbeat health).
|
|
pub async fn eval_all(pool: &PgPool) -> Result<Vec<EvalRow>, DbError> {
|
|
let rows = sqlx::query(
|
|
"SELECT n.id, n.workspace_id, n.status,
|
|
COALESCE(m.cpu_pct, h.cpu_pct) AS cpu_pct,
|
|
COALESCE(m.mem_pct, CASE WHEN h.mem_total > 0 THEN h.mem_used::float8 / h.mem_total * 100 END) AS mem_pct,
|
|
COALESCE(m.disk_pct, CASE WHEN h.disk_total > 0 THEN (h.disk_total - h.disk_free)::float8 / h.disk_total * 100 END) AS disk_pct,
|
|
m.gpu_pct, m.temp_max,
|
|
COALESCE(m.load1, h.load1) AS load1,
|
|
h.mem_total AS mem_total_bytes,
|
|
h.mem_used AS mem_used_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() - m.updated_at)::float8 AS metrics_age_secs
|
|
FROM nodes n
|
|
LEFT JOIN node_health h ON h.node_id = n.id
|
|
LEFT JOIN node_metrics m ON m.node_id = n.id",
|
|
)
|
|
.fetch_all(pool)
|
|
.await?;
|
|
Ok(rows
|
|
.into_iter()
|
|
.map(|r| EvalRow {
|
|
node_id: NodeId::from(r.get::<Uuid, _>("id")),
|
|
workspace_id: WorkspaceId::from(r.get::<Uuid, _>("workspace_id")),
|
|
status: r.get("status"),
|
|
cpu_pct: r.get("cpu_pct"),
|
|
mem_pct: r.get("mem_pct"),
|
|
disk_pct: r.get("disk_pct"),
|
|
gpu_pct: r.get("gpu_pct"),
|
|
temp_max: r.get("temp_max"),
|
|
load1: r.get("load1"),
|
|
mem_total_bytes: r.get("mem_total_bytes"),
|
|
mem_used_bytes: r.get("mem_used_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"),
|
|
metrics_age_secs: r.get("metrics_age_secs"),
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
/// The latest metrics blob for a node (the full snapshot for the monitor page).
|
|
pub async fn latest(pool: &PgPool, node_id: NodeId) -> Result<Option<Value>, DbError> {
|
|
let row = sqlx::query(
|
|
"SELECT data, extract(epoch from updated_at)::bigint AS updated FROM node_metrics WHERE node_id = $1",
|
|
)
|
|
.bind(node_id.as_uuid())
|
|
.fetch_optional(pool)
|
|
.await?;
|
|
Ok(row.map(|r| {
|
|
let mut data: Value = r.get("data");
|
|
if let Some(obj) = data.as_object_mut() {
|
|
obj.insert(
|
|
"updatedAt".into(),
|
|
serde_json::json!(r.get::<i64, _>("updated")),
|
|
);
|
|
}
|
|
data
|
|
}))
|
|
}
|