cc119d3751
- ProjectStateStore (§6.4, §24.2): monotone State-/Projekt-Revisionen, Snapshot nach Verbindung, Delta mit neu/geaendert/geloescht, Szenenabruf als Zielzustand (Uebergang macht der Renderer, §18.1), Projekt-/Livezustand strikt getrennt - GroupRouter (§6.3, §10.1): Ziele All/Node/Output/ServerGroup, Regeln selected/tag_query/all, Commit-Vorschau (§17.5) - ClockEstimator (§6.4): RTT-Min-Filter (<=2x Min gegen Jitter), Offset-/Drift-Schaetzung (Drift erst ab 1 s Fenster belastbar), Showzeit -> lokale Node-Zeit - ActivationCoordinator (§6.5): zeitgestempelte Preset-Aktivierung, 200 ms Vorlauf, Arm/Execute/Ack-Kette, FAILED bei fehlender Arm-Bestaetigung (kein stilles Ueberbruecken) - 35 neue Unit-Tests; Gesamtsuite 293 gruen, Ruff gruen
149 lines
5.4 KiB
Python
149 lines
5.4 KiB
Python
"""Zeitgestempelte Preset-Aktivierung über Gruppen (§6.5, §6.4).
|
||
|
||
Koordinierter Show-Modus (§6.3): Der Coordinator verteilt zeitgestempelte
|
||
Preset-/Parameterkommandos mit Preload/Arm/Ack und execute_at typischerweise
|
||
100–300 ms im Voraus. Zwei-Phasen-Aktivierung (§6.5): vollständig ins
|
||
Staging übertragen und validieren, danach atomar auf dieselbe Revision
|
||
schalten.
|
||
|
||
execute_at ist eine Showzeit (Coordinator-Zeitbasis); jeder Node bildet sie
|
||
über seinen Clock-Offset auf die lokale monotone Zeit ab (§6.4).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import uuid
|
||
from dataclasses import dataclass, field
|
||
from enum import StrEnum
|
||
|
||
from hms_cluster.clock import now_monotonic_ns
|
||
from hms_cluster.groups import GroupRouter, TargetKind
|
||
|
||
DEFAULT_LEAD_NS = 200_000_000 # 200 ms Vorlauf (§6.4: typisch 100–300 ms)
|
||
|
||
|
||
class ArmState(StrEnum):
|
||
"""Phasen eines geplanten Preset-Starts (§6.5)."""
|
||
|
||
CREATED = "created"
|
||
ARMED = "armed"
|
||
EXECUTED = "executed"
|
||
FAILED = "failed"
|
||
|
||
|
||
@dataclass
|
||
class PlannedActivation:
|
||
"""Ein zeitgestempelter Gruppen-Command (§6.5)."""
|
||
|
||
command_id: str
|
||
scene_id: str
|
||
target_kind: TargetKind
|
||
target_id: str | None
|
||
execute_at_show_ns: int # Coordinator-Showzeit
|
||
created_ns: int
|
||
state: ArmState = ArmState.CREATED
|
||
armed_nodes: frozenset[str] = field(default_factory=frozenset)
|
||
executed_nodes: frozenset[str] = field(default_factory=frozenset)
|
||
|
||
@classmethod
|
||
def plan(
|
||
cls,
|
||
scene_id: str,
|
||
target_kind: TargetKind,
|
||
target_id: str | None,
|
||
now_show_ns: int,
|
||
lead_ns: int = DEFAULT_LEAD_NS,
|
||
) -> PlannedActivation:
|
||
"""Plant die Ausführung `lead_ns` im Voraus (§6.4)."""
|
||
return cls(
|
||
command_id=str(uuid.uuid4()),
|
||
scene_id=scene_id,
|
||
target_kind=target_kind,
|
||
target_id=target_id,
|
||
execute_at_show_ns=now_show_ns + lead_ns,
|
||
created_ns=now_monotonic_ns(),
|
||
)
|
||
|
||
|
||
class ActivationCoordinator:
|
||
"""Koordiniert Preload/Arm/Execute über eine Node-Gruppe (§6.5).
|
||
|
||
- schedule(): plant die Aktivierung mit Vorlauf
|
||
- acknowledge_arm(): Node bestätigt arm (Preflight grün)
|
||
- acknowledge_execute(): Node hat ausgeführt (mit Ist-Zeit)
|
||
- due(): Aktivierungen, deren Showzeit erreicht ist (pro Tick)
|
||
|
||
Fehlerfälle (§6.5): fehlt eine Arm-Bestätigung, blockiert die
|
||
Ausführung standardmäßig; das wird als FAILED sichtbar dokumentiert
|
||
und nicht still überbrückt.
|
||
"""
|
||
|
||
def __init__(self, router: GroupRouter) -> None:
|
||
self._router = router
|
||
self._planned: dict[str, PlannedActivation] = {}
|
||
|
||
def schedule(
|
||
self,
|
||
scene_id: str,
|
||
target_kind: TargetKind = TargetKind.ALL,
|
||
target_id: str | None = None,
|
||
now_show_ns: int | None = None,
|
||
lead_ns: int = DEFAULT_LEAD_NS,
|
||
) -> PlannedActivation:
|
||
"""Plant eine Aktivierung; Ziel-Nodes werden sofort aufgelöst."""
|
||
now = now_show_ns if now_show_ns is not None else now_monotonic_ns()
|
||
planned = PlannedActivation.plan(
|
||
scene_id=scene_id,
|
||
target_kind=target_kind,
|
||
target_id=target_id,
|
||
now_show_ns=now,
|
||
lead_ns=lead_ns,
|
||
)
|
||
self._planned[planned.command_id] = planned
|
||
return planned
|
||
|
||
def expected_nodes(self, planned: PlannedActivation) -> frozenset[str]:
|
||
"""Ziel-Nodes dieser Aktivierung (§17.5: vor Commit sichtbar)."""
|
||
return self._router.preview_targets(planned.target_kind, planned.target_id)
|
||
|
||
def acknowledge_arm(self, command_id: str, node_id: str) -> ArmState:
|
||
"""Node hat geprüft und armed (§6.5)."""
|
||
planned = self._planned.get(command_id)
|
||
if planned is None:
|
||
raise KeyError(f"unbekannter Command {command_id}")
|
||
planned.armed_nodes = frozenset(set(planned.armed_nodes) | {node_id})
|
||
return planned.state
|
||
|
||
def acknowledge_execute(self, command_id: str, node_id: str) -> ArmState:
|
||
"""Node hat ausgeführt; Ist-Zeit wird vom Node gemeldet (§6.5)."""
|
||
planned = self._planned.get(command_id)
|
||
if planned is None:
|
||
raise KeyError(f"unbekannter Command {command_id}")
|
||
planned.executed_nodes = frozenset(set(planned.executed_nodes) | {node_id})
|
||
if planned.executed_nodes >= self.expected_nodes(planned):
|
||
planned.state = ArmState.EXECUTED
|
||
return planned.state
|
||
|
||
def due(self, now_show_ns: int | None = None) -> list[PlannedActivation]:
|
||
"""Aktivierungen, deren Showzeit gekommen ist – nur für armed.
|
||
|
||
Nicht voll armbare Aktivierungen werden FAILED, nicht still
|
||
ausgeführt (§6.5: fehlende Bestätigung blockiert standardmäßig).
|
||
"""
|
||
now = now_show_ns if now_show_ns is not None else now_monotonic_ns()
|
||
due_list: list[PlannedActivation] = []
|
||
for planned in list(self._planned.values()):
|
||
if planned.state is not ArmState.CREATED:
|
||
continue
|
||
if now >= planned.execute_at_show_ns:
|
||
expected = self.expected_nodes(planned)
|
||
if expected and planned.armed_nodes >= expected:
|
||
planned.state = ArmState.ARMED
|
||
due_list.append(planned)
|
||
else:
|
||
planned.state = ArmState.FAILED # §6.5: Blockade sichtbar
|
||
return due_list
|
||
|
||
def get(self, command_id: str) -> PlannedActivation | None:
|
||
return self._planned.get(command_id)
|