Files
hms-mediaengine/packages/cluster/hms_cluster/registry.py
T
HMS MediaEngine Agent c4574927fd Phase 1: Cluster-Fundament – Nachrichten, Registry, Paarung, Discovery
- hms_cluster: ClusterMessage (§6.5: Pflichtfelder, Sequenzen, Revisionen,
  execute_at, Trace-ID), CommandTracker (idempotent, vorwärts-only:
  accepted->armed->executed/failed)
- NodeRegistry: online/degraded/stale/offline über Schwellen, UI-Kategorien
  discovered/paired/unknown/incompatible/offline, Doppel-Node-ID blockiert,
  IP-Wechsel erhaelt node_id (§6.3, §6.5)
- Pairing: PIN (TTL 120s, Versuchslimit), Fingerprint (SHA-256 gruppiert),
  Token nur als Hash, Scopes read/control/content_sync/admin, Ablauf +
  sofortiger Widerruf (§6.3, §27.1, ADR-0010)
- Discovery-Modell: _hmsmedia._tcp.local. TXT ohne Secrets, Capability-
  Digest, persistente manuelle Fallback-Liste (ADR-0009)
- ADR-0009 (mDNS + Fallback) und ADR-0010 (Paarung) dokumentiert
- 25 Unit-Tests; Gesamtsuite 183 Tests gruen, Ruff gruen
2026-09-11 01:06:26 +02:00

169 lines
5.9 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.
"""Node-Registry mit Heartbeat-Zuständen (PLAN.md §6.3, §6.5).
- Zustände je Node: online / degraded / stale / offline mit konfigurierbaren
Schwellen (§6.5)
- doppelte node_id wird als Fehler blockiert, nie still übernommen (§6.3)
- Kategorien für die UI: discovered / paired / unknown / incompatible /
offline werden getrennt geführt (§6.3)
- persistente node_id bleibt identisch bei IP-Wechsel; Endpunkte werden
als „zuletzt bekannt" aktualisiert (§10.1 Node)
"""
from __future__ import annotations
import time
from dataclasses import dataclass, field
from enum import StrEnum
class NodeHealth(StrEnum):
ONLINE = "online"
DEGRADED = "degraded"
STALE = "stale"
OFFLINE = "offline"
class NodeCategory(StrEnum):
"""UI-Kategorien gemäß §6.3: gefunden, gepaart, unbekannt, inkompatibel,
offline werden getrennt aufgeführt."""
DISCOVERED = "discovered"
PAIRED = "paired"
UNKNOWN = "unknown"
INCOMPATIBLE = "incompatible"
OFFLINE = "offline"
class DuplicateNodeError(Exception):
"""Doppelte node_id wird als Fehler blockiert (§6.3)."""
@dataclass
class NodeEntry:
"""Registry-Eintrag: Identität stabil, Endpunkte „zuletzt bekannt" (§10.1)."""
node_id: str
display_name: str
roles: tuple[str, ...] = ()
api_port: int = 0
protocol_version: int = 1
capability_digest: str = ""
last_known_endpoints: list[str] = field(default_factory=list)
last_heartbeat_ns: int = 0
health: NodeHealth = NodeHealth.OFFLINE
category: NodeCategory = NodeCategory.DISCOVERED
clock_offset_ns: int = 0
def record_endpoint(self, endpoint: str) -> None:
"""IP-Wechsel: node_id bleibt, Endpunkt wird aktualisiert (§6.3)."""
if endpoint in self.last_known_endpoints:
self.last_known_endpoints.remove(endpoint)
self.last_known_endpoints.insert(0, endpoint)
del self.last_known_endpoints[4:] # die letzten 5 genügen
@dataclass
class HealthThresholds:
"""Konfigurierbare Schwellen je Zustand (§6.5).
Heartbeat pünktlich < degraded_after_ns; verspätet, aber vorhanden
< stale_after_ns; danach offline. Standard-Heartbeat 500 ms (§6.5).
"""
heartbeat_interval_ns: int = 500_000_000
degraded_after_ns: int = 2_000_000_000 # 2 s ohne Heartbeat
stale_after_ns: int = 5_000_000_000 # 5 s ohne Heartbeat
class NodeRegistry:
"""Autoritative Liste bekannter Nodes (Coordinator-seitig)."""
def __init__(
self,
thresholds: HealthThresholds | None = None,
protocol_version: int = 1,
) -> None:
self._nodes: dict[str, NodeEntry] = {}
self._thresholds = thresholds or HealthThresholds()
self._protocol_version = protocol_version
def register(
self,
node_id: str,
display_name: str,
roles: tuple[str, ...] = (),
api_port: int = 0,
protocol_version: int = 1,
capability_digest: str = "",
endpoint: str = "",
) -> NodeEntry:
"""Neue Node oder Update bekannter Node; Doppel-ID mit
widersprüchlicher Identität ist ein Fehler (§6.3)."""
existing = self._nodes.get(node_id)
if existing is not None and existing.display_name != display_name:
raise DuplicateNodeError(
f"node_id {node_id} bereits als {existing.display_name!r} registriert"
)
if existing is None:
entry = NodeEntry(
node_id=node_id,
display_name=display_name,
roles=tuple(roles),
api_port=api_port,
protocol_version=protocol_version,
capability_digest=capability_digest,
)
self._nodes[node_id] = entry
else:
entry = existing
entry.roles = tuple(roles)
entry.api_port = api_port
entry.capability_digest = capability_digest
if endpoint:
entry.record_endpoint(endpoint)
# Inkompatible Protokollversion sichtbar kategorisieren (§6.3)
if entry.protocol_version != self._protocol_version:
entry.category = NodeCategory.INCOMPATIBLE
return entry
def record_heartbeat(self, node_id: str, clock_offset_ns: int = 0) -> None:
entry = self._nodes.get(node_id)
if entry is None:
raise KeyError(f"unknown node {node_id}")
entry.last_heartbeat_ns = time.monotonic_ns()
entry.clock_offset_ns = clock_offset_ns
def evaluate_health(self, node_id: str) -> NodeHealth:
"""Berechnet den Zustand aus letztem Heartbeat + Schwellen (§6.5)."""
entry = self._nodes[node_id]
if entry.last_heartbeat_ns == 0:
entry.health = NodeHealth.OFFLINE
if entry.category not in (NodeCategory.INCOMPATIBLE, NodeCategory.PAIRED):
entry.category = NodeCategory.DISCOVERED
return entry.health
elapsed = time.monotonic_ns() - entry.last_heartbeat_ns
if elapsed < self._thresholds.degraded_after_ns:
entry.health = NodeHealth.ONLINE
elif elapsed < self._thresholds.stale_after_ns:
entry.health = NodeHealth.DEGRADED
else:
entry.health = NodeHealth.OFFLINE
if entry.category is NodeCategory.PAIRED:
entry.category = NodeCategory.OFFLINE # Vertrauen bleibt, nur weg
return entry.health
def mark_paired(self, node_id: str) -> None:
self._nodes[node_id].category = NodeCategory.PAIRED
def mark_unknown(self, node_id: str) -> None:
self._nodes[node_id].category = NodeCategory.UNKNOWN
def get(self, node_id: str) -> NodeEntry | None:
return self._nodes.get(node_id)
def by_category(self, category: NodeCategory) -> list[NodeEntry]:
return [n for n in self._nodes.values() if n.category is category]
def all(self) -> list[NodeEntry]:
return list(self._nodes.values())