Files
hms-mediaengine/packages/cluster/hms_cluster/activation.py
T

149 lines
5.4 KiB
Python
Raw Normal View History

"""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)