homelab-codex-ws/services/control-plane/src/liveness.py
oskar 5f1528e4ab feat(observer): 3-state node liveness (fresh/stale/dead) + transitions + read-time net
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>
2026-06-17 20:07:25 +02:00

152 lines
6.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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