homelab-codex-ws/scripts/maintenance/cleanup_event_backlog.py
oskar dff76ecd30 fix(events): service_healthy tylko na transition + cleanup 358k backlog + retencja — glob zalewu paralizowal reconcile supervisora
- node-agent: service_healthy emitowany tylko przy przejsciu w stan zdrowy
  (per-service in-memory state), nie co cykl dla kazdego zdrowego serwisu.
  To samo dla control-plane HTTP probe. healthcheck_failed/containers_not_running/
  incydenty pozostaja emitowane bez zmian (realne sygnaly).
- node-agent: naprawiono _cleanup_control_plane_fs — istniejaca retencja
  eventow byla martwa od migracji observer_checkpoint.json na epoch-int
  (str(path) <= int rzucal TypeError, lapane cicho przez broad except).
  Teraz porownuje epoch-do-epoch i czysci wylacznie service_healthy/node_health
  starsze niz checkpoint + 3-dniowy bufor; healthcheck_failed/incydenty/ha_*
  zachowane bezterminowo.
- scripts/maintenance/cleanup_event_backlog.py: jednorazowy skrypt do
  bezpiecznego czyszczenia backlogu na VPS (dry-run domyslnie, --apply
  do usuniecia). Ten sam warunek: typ szumu + starsze niz checkpoint + 1h bufor.
2026-07-16 20:20:17 +02:00

207 lines
8 KiB
Python

#!/usr/bin/env python3
"""
cleanup_event_backlog.py — safe backlog cleanup for /opt/homelab/events/.
Removes ONLY processed noise events (service_healthy, node_health by default)
that are both:
1. Strictly older than the observer's per-node checkpoint timestamp
(observer_checkpoint.json) — i.e. guaranteed already processed into
world_state, per the same ordering guarantee observer.py itself relies on
(oldest-first, monotonically-advancing checkpoint).
2. Older than a minimum safety age (default 3600s / 1h) — defense in depth
against clock skew or a checkpoint write racing this script, mirroring
the dual-condition pattern already used in node_agent.py's
_cleanup_control_plane_fs.
Everything else — healthcheck_failed, containers_not_running, incidents,
ha_* events, node liveness transitions, disk/memory/cpu pressure, and any
event newer than the checkpoint — is never touched.
Defaults to DRY RUN: prints counts per node/type and does not delete
anything. Pass --apply to actually delete.
Usage:
python3 cleanup_event_backlog.py # dry run, defaults
python3 cleanup_event_backlog.py --apply # actually delete
python3 cleanup_event_backlog.py --types service_healthy,node_health
python3 cleanup_event_backlog.py --min-age-secs 86400 # extra-cautious
"""
import argparse
import json
import os
import re
import sys
import time
from pathlib import Path
RUNTIME_PATH = Path(os.getenv("RUNTIME_PATH", "/opt/homelab"))
EVENTS_DIR = RUNTIME_PATH / "events"
CHECKPOINT_FILE = RUNTIME_PATH / "state" / "observer_checkpoint.json"
# Matches observer.py's _EVENT_TS_RE: evt-<node>-<unixts>-<type>-<svc>.json
_EVENT_TS_RE = re.compile(r"-(\d{9,11})-")
DEFAULT_TYPES = {"service_healthy", "node_health"}
def _ts_from_name(name: str):
m = _EVENT_TS_RE.search(Path(name).stem)
return int(m.group(1)) if m else None
def _type_from_name(name: str):
"""Best-effort event type extraction from evt-<node>-<ts>-<type>-<svc>.json.
Mirrors the informal convention node-agent/observer use when building
filenames; falls back to 'unknown' rather than guessing wrong.
"""
stem = Path(name).stem
m = re.match(r"evt-.+?-\d{9,11}-(.+)$", stem)
if not m:
return "unknown"
rest = m.group(1)
# rest is "<type>-<svc_slug>"; type itself may contain underscores but not
# dashes in this codebase's event vocabulary, so split on first dash after
# the known type token is ambiguous — instead check against DEFAULT_TYPES
# and a couple of other well-known multi-word-free types directly.
for t in KNOWN_TYPES:
if rest == t or rest.startswith(t + "-"):
return t
return rest.split("-")[0]
# Every event type this repo's emitters are known to produce (node_agent.py,
# ha_diag/event_emitter.py, observer.py's own _emit_node_transition), longest
# first so startswith() matching in _type_from_name doesn't shadow a longer
# type with a shorter prefix (e.g. "node_health" vs "node_offline" share no
# prefix, but keep this list explicit rather than relying on luck).
KNOWN_TYPES = sorted(
{
"service_healthy", "service_unhealthy", "service_recovered",
"node_health", "node_online", "node_offline", "node_stale",
"healthcheck_failed", "containers_not_running", "container_restarting",
"container_state_unexpected", "disk_pressure", "high_memory", "high_cpu",
"mqtt_unreachable",
"ha_websocket_dead", "ha_websocket_recovered", "ha_integration_failed",
"ha_entity_unavailable_long", "ha_automation_failing", "ha_update_available",
"ha_recorder_lag", "ha_system_health_degraded",
"deployment_started", "deployment_completed", "deployment_failed",
"remediation_started", "remediation_completed",
},
key=len, reverse=True,
)
def load_checkpoints(checkpoint_file: Path = CHECKPOINT_FILE):
if not checkpoint_file.exists():
print(f"WARNING: no checkpoint file at {checkpoint_file} — refusing to "
f"guess; nothing is safe to delete without it.", file=sys.stderr)
return {}
data = json.loads(checkpoint_file.read_text())
raw = data.get("node_checkpoints", {})
out = {}
for node, val in raw.items():
if isinstance(val, bool):
continue
if isinstance(val, (int, float)):
out[node] = int(val)
return out
def scan(types_to_clean, min_age_secs, checkpoints, now, events_dir: Path = EVENTS_DIR):
"""Return (candidates: list[Path], counts: dict[node][type] -> (delete, keep))."""
candidates = []
counts = {}
if not events_dir.exists():
return candidates, counts
for node_dir in sorted(p for p in events_dir.iterdir() if p.is_dir()):
node = node_dir.name
checkpoint = checkpoints.get(node)
counts.setdefault(node, {})
for f in node_dir.glob("*.json"):
etype = _type_from_name(f.name)
ts = _ts_from_name(f.name)
if ts is None:
try:
ts = int(f.stat().st_mtime)
except OSError:
continue
bucket = counts[node].setdefault(etype, {"delete": 0, "keep": 0})
if etype not in types_to_clean:
bucket["keep"] += 1
continue
if checkpoint is None:
# No checkpoint for this node dir at all (e.g. a ghost/foreign
# dir) — never guess; keep everything.
bucket["keep"] += 1
continue
if ts >= checkpoint:
# Not guaranteed processed yet — never delete.
bucket["keep"] += 1
continue
if now - ts < min_age_secs:
# Within the safety buffer even though past checkpoint.
bucket["keep"] += 1
continue
bucket["delete"] += 1
candidates.append(f)
return candidates, counts
def print_report(counts, min_age_secs, checkpoints):
total_delete = 0
total_keep = 0
for node in sorted(counts):
cp = checkpoints.get(node)
cp_str = f"checkpoint={cp}" if cp is not None else "NO CHECKPOINT (nothing eligible)"
print(f"\n=== {node} ({cp_str}) ===")
for etype, c in sorted(counts[node].items(), key=lambda kv: -sum(kv[1].values())):
if c["delete"] or c["keep"]:
print(f" {etype:32s} delete={c['delete']:>7d} keep={c['keep']:>7d}")
total_delete += c["delete"]
total_keep += c["keep"]
print(f"\nTOTAL: delete={total_delete} keep={total_keep}")
print(f"(min_age_secs={min_age_secs}, i.e. only files older than checkpoint AND "
f"more than {min_age_secs}s old are ever deleted)")
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--apply", action="store_true",
help="Actually delete. Without this flag, dry-run only.")
ap.add_argument("--types", default=",".join(sorted(DEFAULT_TYPES)),
help=f"Comma-separated event types to clean (default: {DEFAULT_TYPES})")
ap.add_argument("--min-age-secs", type=int, default=3600,
help="Extra safety buffer past the checkpoint (default 3600s = 1h)")
args = ap.parse_args()
types_to_clean = {t.strip() for t in args.types.split(",") if t.strip()}
checkpoints = load_checkpoints(CHECKPOINT_FILE)
now = int(time.time())
candidates, counts = scan(types_to_clean, args.min_age_secs, checkpoints, now, EVENTS_DIR)
print_report(counts, args.min_age_secs, checkpoints)
if not args.apply:
print(f"\nDRY RUN — no files deleted. Re-run with --apply to delete "
f"the {len(candidates)} files listed above as 'delete'.")
return
deleted = 0
for f in candidates:
try:
f.unlink(missing_ok=True)
deleted += 1
except Exception as exc:
print(f"Failed to remove {f}: {exc}", file=sys.stderr)
print(f"\nDeleted {deleted}/{len(candidates)} files.")
if __name__ == "__main__":
main()