feat(phase-c): drift-adaptive anomaly detection + smarter eviction scoring

serve_v2: Welford online stats per node (hot_pct, hit_rate, fs_pct,
chunk_miss_rate) updated on every poller tick. Anomaly score = sum of z²;
warn at 9, alert at 16. Exposed via GET /api/v2/anomalies. Score embedded
in MetricSample so sparklines can also surface it.

hot: Replace pure-LRU gc_by_space with size×age eviction scoring.
Candidates are ranked by ln(size) × ln(age_secs); pinned projects immune.
Evicts the most space with the least cost rather than just the oldest entry.

dashboard: Fleet anomaly banner in CommandCenter (red/amber) + per-node
"anomaly"/"drift" badge on NodeCard. Anomalies polled every 10s alongside
the fleet endpoint.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
Omar Sobh
2026-07-23 17:10:09 +00:00
co-authored by Claude Sonnet 4.6
parent 3fdf46d416
commit 70ad863006
5 changed files with 228 additions and 16 deletions
+139 -3
View File
@@ -34,9 +34,99 @@ use crate::cluster::transport::{NodeIdentity, QuicClient};
use crate::config::{Config, PeerEntry, TokenEntry};
use crate::sessions::{LeasedTag, Session, SessionStore};
// ── metrics history ring buffer ──────────────────────────────────
// ── metrics history ring buffer + drift-adaptive anomaly detection ──
const HISTORY_MAX_SAMPLES: usize = 1440; // 24h at 1-min resolution
/// Require at least this many samples before reporting an anomaly score.
/// Protects against false positives on a freshly-started daemon.
const ANOMALY_MIN_SAMPLES: u64 = 10;
/// Sum-of-z² above this value triggers a "warn" level anomaly.
/// Interpretation: average ~1.5σ deviation across 4 tracked metrics.
const ANOMALY_WARN_THRESHOLD: f64 = 9.0;
/// Sum-of-z² above this value triggers "alert".
const ANOMALY_ALERT_THRESHOLD: f64 = 16.0;
/// Welford's online algorithm for a running mean + variance.
/// Numerically stable, O(1) per update.
#[derive(Default, Clone)]
struct Welford {
n: u64,
mean: f64,
m2: f64,
}
impl Welford {
fn update(&mut self, x: f64) {
self.n += 1;
let delta = x - self.mean;
self.mean += delta / self.n as f64;
self.m2 += delta * (x - self.mean);
}
fn stddev(&self) -> f64 {
if self.n < 2 { return 1.0; }
(self.m2 / (self.n - 1) as f64).sqrt().max(1e-9)
}
/// Squared z-score for a new observation (non-destructive).
fn z_sq(&self, x: f64) -> f64 {
if self.n < ANOMALY_MIN_SAMPLES { return 0.0; }
let z = (x - self.mean) / self.stddev();
z * z
}
}
/// Per-node rolling stats for drift-adaptive anomaly detection.
#[derive(Default, Clone)]
struct NodeAnomalyStats {
hot_pct: Welford,
hit_rate: Welford,
fs_pct: Welford,
chunk_miss_rate: Welford,
/// Most recent anomaly score (sum of z²); 0 until ANOMALY_MIN_SAMPLES.
pub last_score: f64,
/// Total samples seen (mirrors hot_pct.n for convenience).
pub n: u64,
}
impl NodeAnomalyStats {
fn update(&mut self, s: &MetricSample) {
let hot_pct = if s.hot_max_bytes > 0 {
s.hot_used_bytes as f64 / s.hot_max_bytes as f64
} else {
0.0
};
let fs_pct = if s.fs_total_bytes > 0 {
s.fs_used_bytes as f64 / s.fs_total_bytes as f64
} else {
0.0
};
let total_chunks = s.has_chunk_hits + s.has_chunk_misses;
let chunk_miss_rate = if total_chunks > 0 {
s.has_chunk_misses as f64 / total_chunks as f64
} else {
0.0
};
self.last_score = self.hot_pct.z_sq(hot_pct)
+ self.hit_rate.z_sq(s.cache_hit_rate)
+ self.fs_pct.z_sq(fs_pct)
+ self.chunk_miss_rate.z_sq(chunk_miss_rate);
self.hot_pct.update(hot_pct);
self.hit_rate.update(s.cache_hit_rate);
self.fs_pct.update(fs_pct);
self.chunk_miss_rate.update(chunk_miss_rate);
self.n = self.hot_pct.n;
}
fn level(&self) -> &'static str {
if self.n < ANOMALY_MIN_SAMPLES { return "ok"; }
if self.last_score >= ANOMALY_ALERT_THRESHOLD { "alert" }
else if self.last_score >= ANOMALY_WARN_THRESHOLD { "warn" }
else { "ok" }
}
}
/// One time-series sample snapshotted from a peer's DashboardStatus RPC.
#[derive(Debug, Clone, Serialize)]
@@ -52,18 +142,41 @@ pub struct MetricSample {
pub fs_used_bytes: u64,
pub fs_total_bytes: u64,
pub fs_available_bytes: u64,
/// Drift-adaptive anomaly score (sum of z² across 4 metrics).
/// `None` until the node has collected ANOMALY_MIN_SAMPLES.
#[serde(skip_serializing_if = "Option::is_none")]
pub anomaly_score: Option<f64>,
}
#[derive(Serialize)]
pub struct AnomalyStatus {
pub node: String,
pub score: f64,
pub level: &'static str,
pub samples_used: u64,
}
pub struct MetricsHistory {
samples: HashMap<String, VecDeque<MetricSample>>,
anomaly: HashMap<String, NodeAnomalyStats>,
}
impl MetricsHistory {
fn new() -> Self {
Self { samples: HashMap::new() }
Self {
samples: HashMap::new(),
anomaly: HashMap::new(),
}
}
fn push(&mut self, node: &str, sample: MetricSample) {
fn push(&mut self, node: &str, mut sample: MetricSample) {
// Update Welford stats and embed anomaly score in the sample.
let stats = self.anomaly.entry(node.to_string()).or_default();
stats.update(&sample);
if stats.n >= ANOMALY_MIN_SAMPLES {
sample.anomaly_score = Some(stats.last_score);
}
let deque = self.samples.entry(node.to_string()).or_default();
deque.push_back(sample);
while deque.len() > HISTORY_MAX_SAMPLES {
@@ -81,6 +194,21 @@ impl MetricsHistory {
})
.unwrap_or_default()
}
pub fn anomaly_statuses(&self) -> Vec<AnomalyStatus> {
let mut out: Vec<AnomalyStatus> = self
.anomaly
.iter()
.map(|(node, s)| AnomalyStatus {
node: node.clone(),
score: s.last_score,
level: s.level(),
samples_used: s.n,
})
.collect();
out.sort_by(|a, b| a.node.cmp(&b.node));
out
}
}
// ─────────────────────────────────────────────────────────────────
@@ -1375,6 +1503,8 @@ async fn metrics_poller(state: Arc<V2State>) {
fs_used_bytes: r.filesystem.as_ref().map(|f| f.used_bytes).unwrap_or(0),
fs_total_bytes: r.filesystem.as_ref().map(|f| f.total_bytes).unwrap_or(0),
fs_available_bytes: r.filesystem.as_ref().map(|f| f.available_bytes).unwrap_or(0),
// Filled in by MetricsHistory::push after Welford update.
anomaly_score: None,
};
let mut hist = state.history.lock().await;
hist.push(&peer_name, sample);
@@ -1402,6 +1532,11 @@ async fn handle_metrics_history(
Json(hist.get_last(&name, limit))
}
async fn handle_anomalies(State(s): State<Arc<V2State>>) -> Json<Vec<AnomalyStatus>> {
let hist = s.history.lock().await;
Json(hist.anomaly_statuses())
}
// ─────────────────────────────────────────────────────────────────
pub fn build(state: Arc<V2State>) -> Router {
@@ -1426,6 +1561,7 @@ pub fn build(state: Arc<V2State>) -> Router {
.route("/api/v2/fleet", get(handle_fleet))
.route("/api/v2/node/:name/status", get(handle_node_status))
.route("/api/v2/node/:name/metrics-history", get(handle_metrics_history))
.route("/api/v2/anomalies", get(handle_anomalies))
.route("/api/v2/storage/blobs", get(handle_blobs))
.route("/api/v2/storage/tags", get(handle_tags))
.route("/api/v2/storage/refs", get(handle_refs))