Fixes the "dead node shown NOMINAL" silent outage: node status was set only by events and never expired, so a node that crashed/lost connectivity stayed "online" forever (chelsty-infra was online for 16d, piha ~6d). The only thing that flipped status to offline was a node_offline event, which an unreachable node can never emit. Now node status is derived from freshness (now - last_seen), recomputed every observer cycle (incl. cycles with no new events): - always-on: fresh <=180s, stale 180-600s, dead >600s (3x the 60s heartbeat) - remote/LTE (chelsty-*): fresh <=900s, stale 900-3600s, dead >3600s Thresholds + tier logic live in ONE shared helper, services/control-plane/src/ liveness.py, imported by the observer and both operator UIs (bind-mounted into the agent-system webui image). No 3x copy. Transitions are not silent: the observer emits node_stale / node_offline / node_online (recovery) events tagged source=observer (skipped on re-ingest so they never reset last_seen), routed by the supervisor to alert_only actions. Read-time safety net: both UIs recompute liveness from last_seen at request time, so a stalled observer still surfaces dead nodes. Services inherit their node's liveness (cascade, variant B) without mutating services.json. Replaces the earlier binary NODE_OFFLINE_TTL_SECS flip. Tests: liveness unit tests, observer 3-state + transitions/recovery/baseline + self-event skip, operator_ui read-time net + cascade, supervisor node-event routing. 89 passed. docker compose config valid for both stacks. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
152 lines
6.1 KiB
Python
152 lines
6.1 KiB
Python
"""Shared node-liveness logic — single source of truth for freshness.
|
||
|
||
This module owns the fresh/stale/dead state machine and its TTL thresholds.
|
||
It is imported by every component that has to decide whether a node is alive:
|
||
|
||
- scripts/observer/observer.py authoritative writer of world/nodes.json
|
||
- services/control-plane/src/operator_ui.py read-time safety net (panel)
|
||
- services/agent-system/webui/web.py read-time safety net (panel);
|
||
this file is bind-mounted into the webui image at /app/liveness.py —
|
||
see services/agent-system/docker-compose.yml.
|
||
|
||
Why a single module: the previous bug ("dead node shown NOMINAL") was a missing
|
||
TTL. Thresholds and the tier logic must live in exactly one place so the
|
||
observer and both panels can never disagree about what "dead" means.
|
||
|
||
Heartbeat interval is 60 s on every node (node-agent CHECK_INTERVAL=60), so the
|
||
fresh ceiling is 3× the interval. If the interval ever changes, raise the
|
||
thresholds here proportionally and nothing else needs to move.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import time
|
||
from datetime import datetime
|
||
|
||
# --- Liveness tiers ---------------------------------------------------------
|
||
FRESH = "fresh"
|
||
STALE = "stale"
|
||
DEAD = "dead"
|
||
UNKNOWN = "unknown"
|
||
|
||
# --- TTL thresholds (seconds) ----------------------------------------------
|
||
# Always-on nodes (vps/piha/solaria/lustro): heartbeat every 60 s.
|
||
# fresh ≤ 180 s → 3 missed beats tolerated (+ observer cycle + rsync latency)
|
||
# dead > 600 s → 10 missed beats == really gone
|
||
DEFAULT_TTLS = {
|
||
"fresh": int(os.getenv("LIVENESS_TTL_FRESH", "180")),
|
||
"dead": int(os.getenv("LIVENESS_TTL_DEAD", "600")),
|
||
}
|
||
# Remote / LTE nodes (chelsty-*): intermittent uplink, events buffer locally
|
||
# and ship on reconnect. Wider so a typical multi-minute LTE blip reads as
|
||
# stale (degraded), but a genuine outage still escalates to dead within an hour.
|
||
REMOTE_TTLS = {
|
||
"fresh": int(os.getenv("LIVENESS_REMOTE_TTL_FRESH", "900")), # 15 min
|
||
"dead": int(os.getenv("LIVENESS_REMOTE_TTL_DEAD", "3600")), # 1 h
|
||
}
|
||
# A node uses REMOTE_TTLS if its topology role is in REMOTE_ROLES, or its name
|
||
# is in REMOTE_NODES (fallback when roles are absent from the node record).
|
||
REMOTE_ROLES = {"remote"}
|
||
REMOTE_NODES = {"chelsty-infra", "chelsty-ha"}
|
||
|
||
# --- Mappings ---------------------------------------------------------------
|
||
# Persisted node.status string written by the observer for each tier.
|
||
# UNKNOWN intentionally has no mapping: never overwrite status when we have no
|
||
# timing data (last_seen missing) — we don't guess a node dead.
|
||
_LIVENESS_TO_STATUS = {FRESH: "online", STALE: "stale", DEAD: "offline"}
|
||
# UI health bucket per liveness tier and per persisted status.
|
||
_LIVENESS_HEALTH = {FRESH: "nominal", STALE: "degraded", DEAD: "error", UNKNOWN: "unknown"}
|
||
_STATUS_HEALTH = {"online": "nominal", "stale": "degraded", "offline": "error"}
|
||
# Severity ordering for "take the worse of two health buckets".
|
||
_HEALTH_RANK = {"nominal": 0, "unknown": 1, "degraded": 2, "error": 3}
|
||
|
||
|
||
def parse_ts(ts):
|
||
"""Epoch float from int/float or ISO-8601 string; 0.0 on None/garbage.
|
||
|
||
node-agent emits int(time.time()); events.py / stability-agent emit ISO
|
||
strings. Both land in last_seen, so all arithmetic goes through here.
|
||
"""
|
||
if ts is None:
|
||
return 0.0
|
||
if isinstance(ts, (int, float)):
|
||
return float(ts)
|
||
try:
|
||
return datetime.fromisoformat(str(ts).replace("Z", "+00:00")).timestamp()
|
||
except Exception:
|
||
return 0.0
|
||
|
||
|
||
def ttls_for(node_name=None, roles=None):
|
||
"""Pick the TTL set for a node by its name or topology roles."""
|
||
roles = set(roles or ())
|
||
if (node_name in REMOTE_NODES) or (roles & REMOTE_ROLES):
|
||
return REMOTE_TTLS
|
||
return DEFAULT_TTLS
|
||
|
||
|
||
def compute_liveness(last_seen, now=None, ttls=None):
|
||
"""Classify a node from age = now - last_seen.
|
||
|
||
Returns FRESH / STALE / DEAD, or UNKNOWN when last_seen is missing or
|
||
unparseable (no data → no opinion; callers must not flip status on UNKNOWN).
|
||
"""
|
||
ttls = ttls or DEFAULT_TTLS
|
||
ts = parse_ts(last_seen)
|
||
if ts <= 0:
|
||
return UNKNOWN
|
||
age = (now if now is not None else time.time()) - ts
|
||
if age <= ttls["fresh"]:
|
||
return FRESH
|
||
if age <= ttls["dead"]:
|
||
return STALE
|
||
return DEAD
|
||
|
||
|
||
def liveness_to_status(liveness):
|
||
"""Persisted node.status for a tier, or None for UNKNOWN (leave as-is)."""
|
||
return _LIVENESS_TO_STATUS.get(liveness)
|
||
|
||
|
||
def _worse(a, b):
|
||
"""Return the more severe of two UI health buckets."""
|
||
return a if _HEALTH_RANK.get(a, 1) >= _HEALTH_RANK.get(b, 1) else b
|
||
|
||
|
||
def node_health(info, name=None, now=None):
|
||
"""UI health bucket for a node record, applying the read-time freshness net.
|
||
|
||
Takes the WORSE of (a) the observer-persisted status and (b) the freshness
|
||
derived from last_seen. Rationale:
|
||
- if the observer is alive it already wrote the right status; freshness
|
||
agrees and this is a no-op.
|
||
- if the observer itself is dead, nodes.json freezes with stale "online"
|
||
statuses — but last_seen keeps ageing, so freshness still surfaces the
|
||
outage. This is the whole point of duplicating the check at read time.
|
||
UNKNOWN freshness (no last_seen) never downgrades a node.
|
||
"""
|
||
stored_health = _STATUS_HEALTH.get(info.get("status", "unknown"), "unknown")
|
||
liveness = compute_liveness(
|
||
info.get("last_seen"), now=now, ttls=ttls_for(name, info.get("roles", []))
|
||
)
|
||
if liveness == UNKNOWN:
|
||
health = stored_health
|
||
else:
|
||
health = _worse(stored_health, _LIVENESS_HEALTH[liveness])
|
||
if health == "nominal" and info.get("disk_pressure") == "high":
|
||
return "degraded"
|
||
return health
|
||
|
||
|
||
def degrade_for_node(service_health, node_liveness):
|
||
"""Cascade node liveness onto a service's health (read-time, panel side).
|
||
|
||
A service cannot be healthier than the node it runs on: a service on a dead
|
||
node is error, on a stale node at least degraded.
|
||
"""
|
||
if node_liveness == DEAD:
|
||
return "error"
|
||
if node_liveness == STALE:
|
||
return _worse(service_health, "degraded")
|
||
return service_health
|