169 lines
5.9 KiB
Python
169 lines
5.9 KiB
Python
|
|
"""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())
|