Files
hms-mediaengine/packages/cluster/hms_cluster/activation.py
T
HMS MediaEngine Agent cc119d3751 Phase 2: State-Sync-Fundament – State-Store, Gruppen, Clock, Aktivierung
- 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
2026-09-11 01:31:26 +02:00

149 lines
5.4 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.
"""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
100300 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 100300 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)