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:
co-authored by
Claude Sonnet 4.6
parent
3fdf46d416
commit
70ad863006
+139
-3
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user