feat(perception): gate streaming lifecycle on controller readiness
This commit is contained in:
@@ -23,6 +23,8 @@ from .realtime_contract import StreamStart
|
||||
from .realtime_scene import _wire_integer
|
||||
from .streaming_queue import IngressReservation, StreamBundle, StreamMailbox
|
||||
from .worker_lease import WorkerLease, WorkerLeaseError
|
||||
from .worker_operating_envelope import WorkerSnapshot
|
||||
from .worker_readiness import WorkerReadinessError, WorkerReadinessMonitor
|
||||
|
||||
|
||||
def _group_exists(pgid: int) -> bool:
|
||||
@@ -45,8 +47,12 @@ class StreamingLifecycle:
|
||||
*,
|
||||
ttl_seconds: float = 2.0,
|
||||
clock_ns: Callable[[], int] = time.monotonic_ns,
|
||||
readiness: WorkerReadinessMonitor | None = None,
|
||||
) -> None:
|
||||
self.start, self.mailbox, self.stop_event = start, mailbox, stop
|
||||
self._clock_ns, self._readiness = clock_ns, readiness
|
||||
if readiness is not None:
|
||||
readiness.check(start, now_monotonic_ns=clock_ns(), require_warmup=False)
|
||||
self._lock = threading.RLock()
|
||||
self._cleanup_lock = threading.Lock()
|
||||
self._children: list[subprocess.Popen[bytes]] = []
|
||||
@@ -75,6 +81,40 @@ class StreamingLifecycle:
|
||||
if start == self.start:
|
||||
self.request_stop("lease-lost")
|
||||
raise
|
||||
self._check_readiness(require_warmup=self.state == GraphState.RUNNING)
|
||||
|
||||
def _check_readiness(self, *, require_warmup: bool) -> None:
|
||||
if self._readiness is not None:
|
||||
try:
|
||||
self._readiness.check(
|
||||
self.start,
|
||||
now_monotonic_ns=self._clock_ns(),
|
||||
require_warmup=require_warmup,
|
||||
)
|
||||
except WorkerReadinessError:
|
||||
self.request_stop("worker-not-ready")
|
||||
raise
|
||||
|
||||
def observe_worker(self, start: StreamStart, observed: WorkerSnapshot) -> None:
|
||||
"""Trusted control plane only; sensor payloads never call this method.
|
||||
|
||||
This is not a lease renewal. A fresh inventory and an active controller
|
||||
heartbeat are independent requirements. No host operations under lock.
|
||||
"""
|
||||
with self._lock:
|
||||
self._check(start, starting=True)
|
||||
if self._readiness is None:
|
||||
raise WorkerReadinessError("worker readiness monitoring is not configured")
|
||||
try:
|
||||
self._readiness.observe(
|
||||
start,
|
||||
observed,
|
||||
now_monotonic_ns=self._clock_ns(),
|
||||
require_warmup=self.state == GraphState.RUNNING,
|
||||
)
|
||||
except WorkerReadinessError:
|
||||
self.request_stop("worker-not-ready")
|
||||
raise
|
||||
|
||||
def renew(self, start: StreamStart) -> None:
|
||||
with self._lock:
|
||||
@@ -89,6 +129,7 @@ class StreamingLifecycle:
|
||||
if any(p.poll() is not None for p in self._children):
|
||||
self.request_stop("child-exited")
|
||||
raise WorkerLeaseError("owned child exited during warmup")
|
||||
self._check_readiness(require_warmup=True)
|
||||
self.state = GraphState.RUNNING
|
||||
|
||||
def spawn(self, factory: Callable[[], subprocess.Popen[bytes]]) -> subprocess.Popen[bytes]:
|
||||
@@ -157,6 +198,7 @@ class StreamingLifecycle:
|
||||
"lease-lost",
|
||||
"child-exited",
|
||||
"invalid-process-group",
|
||||
"worker-not-ready",
|
||||
"failed",
|
||||
):
|
||||
raise ValueError("unknown runtime stop reason")
|
||||
@@ -262,4 +304,9 @@ class StreamingLifecycle:
|
||||
_wire_integer(self.retired_ns) if self.retired_ns is not None else None
|
||||
),
|
||||
"event_clock": "worker-process-monotonic",
|
||||
"worker_readiness": (
|
||||
self._readiness.snapshot()
|
||||
if self._readiness is not None
|
||||
else {"enabled": False, "realtime_qualified": False}
|
||||
),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Bounded controller-owned readiness state, independent of the sensor stream.
|
||||
|
||||
The trusted Worker controller supplies observations in its local monotonic clock
|
||||
domain. It must include mode/envelope in the effective configuration identity.
|
||||
This does not collect Docker/NVML facts, authenticate a remote controller, set
|
||||
clocks, or certify real-time operation. The lifecycle serializes all access.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
from typing import Literal
|
||||
|
||||
from .realtime_contract import RealtimeContractError, StreamStart, _identifier, _integer
|
||||
from .worker_lease import WorkerLeaseError
|
||||
from .worker_operating_envelope import (
|
||||
WorkerOperatingEnvelope,
|
||||
WorkerSnapshot,
|
||||
operating_envelope_failures,
|
||||
)
|
||||
|
||||
ReadinessMode = Literal["strict-envelope", "labelled-experiment"]
|
||||
# Only measured performance conditions are waivable. Missing ownership, input
|
||||
# identity, warmup or inventory NEVER becomes an allowed overload experiment.
|
||||
_PERFORMANCE_FAILURES = frozenset(
|
||||
f"{field}-outside-envelope-or-unknown"
|
||||
for field in ("gpu_name", "driver_version", "cpu_limit_millicores", "memory_limit_mib")
|
||||
) | frozenset(
|
||||
f"{field}-below-envelope-or-unknown" for field in ("sm_clock_mhz", "memory_clock_mhz")
|
||||
)
|
||||
|
||||
|
||||
class WorkerReadinessError(WorkerLeaseError):
|
||||
"""A current controller lost the prerequisites for further execution."""
|
||||
|
||||
|
||||
class WorkerReadinessMonitor:
|
||||
def __init__(
|
||||
self,
|
||||
start: StreamStart,
|
||||
envelope: WorkerOperatingEnvelope,
|
||||
initial: WorkerSnapshot,
|
||||
*,
|
||||
mode: ReadinessMode,
|
||||
clock_domain_id: str,
|
||||
now_monotonic_ns: int,
|
||||
) -> None:
|
||||
if mode not in ("strict-envelope", "labelled-experiment"):
|
||||
raise RealtimeContractError("unknown Worker readiness mode")
|
||||
_identifier(clock_domain_id, "worker clock domain")
|
||||
self.start, self.envelope = start, envelope
|
||||
self.mode, self.clock_domain_id = mode, clock_domain_id
|
||||
self._observed = initial
|
||||
self._last_check_ns = now_monotonic_ns
|
||||
self._failure: tuple[str, ...] = ()
|
||||
self._current: tuple[str, ...] = ()
|
||||
self._post_warmup_violations: set[str] = set()
|
||||
self._warmup_checked = False
|
||||
self.observations = 1
|
||||
self.check(start, now_monotonic_ns=now_monotonic_ns, require_warmup=False)
|
||||
|
||||
def _bound(self, start: StreamStart) -> None:
|
||||
# A stale client may not poison the current owner's state.
|
||||
if start != self.start:
|
||||
raise WorkerReadinessError("readiness stream identity mismatch")
|
||||
|
||||
def _fail(self, reasons: tuple[str, ...]) -> None:
|
||||
self._failure = self._failure or reasons
|
||||
raise WorkerReadinessError("worker readiness lost: " + ",".join(self._failure))
|
||||
|
||||
def check(self, start: StreamStart, *, now_monotonic_ns: int, require_warmup: bool) -> None:
|
||||
self._bound(start)
|
||||
if self._failure:
|
||||
self._fail(self._failure)
|
||||
try:
|
||||
_integer(now_monotonic_ns, "worker monotonic time")
|
||||
if now_monotonic_ns < self._last_check_ns:
|
||||
raise RealtimeContractError("worker clock moved backwards")
|
||||
failures = operating_envelope_failures(
|
||||
self.envelope,
|
||||
start,
|
||||
self._observed,
|
||||
now_monotonic_ns=now_monotonic_ns,
|
||||
clock_domain_id=self.clock_domain_id,
|
||||
)
|
||||
except RealtimeContractError:
|
||||
self._fail(("worker-snapshot-clock-invalid",))
|
||||
return # Unreachable, keeps static narrowing explicit.
|
||||
self._last_check_ns = now_monotonic_ns
|
||||
self._current = failures
|
||||
if require_warmup:
|
||||
self._warmup_checked = True
|
||||
self._post_warmup_violations.update(set(failures) & _PERFORMANCE_FAILURES)
|
||||
fatal = tuple(
|
||||
failure
|
||||
for failure in failures
|
||||
if failure not in _PERFORMANCE_FAILURES
|
||||
and (require_warmup or failure != "warmup-not-complete")
|
||||
)
|
||||
if fatal:
|
||||
self._fail(fatal)
|
||||
if require_warmup and self.mode == "strict-envelope" and failures:
|
||||
self._fail(failures)
|
||||
|
||||
def observe(
|
||||
self,
|
||||
start: StreamStart,
|
||||
observed: WorkerSnapshot,
|
||||
*,
|
||||
now_monotonic_ns: int,
|
||||
require_warmup: bool,
|
||||
) -> None:
|
||||
# Recheck the OLD observation before replacement: late telemetry cannot
|
||||
# resurrect expired authority, even if the watchdog has not run yet.
|
||||
self.check(start, now_monotonic_ns=now_monotonic_ns, require_warmup=require_warmup)
|
||||
if observed.observed_monotonic_ns <= self._observed.observed_monotonic_ns:
|
||||
self._fail(("worker-snapshot-not-increasing",))
|
||||
self._observed = observed
|
||||
self.observations += 1
|
||||
self.check(start, now_monotonic_ns=now_monotonic_ns, require_warmup=require_warmup)
|
||||
|
||||
def snapshot(self) -> dict[str, object]:
|
||||
# No history of individual telemetry samples, only fixed-size state.
|
||||
observed = asdict(self._observed)
|
||||
observed["observed_monotonic_ns"] = str(self._observed.observed_monotonic_ns)
|
||||
return {
|
||||
"enabled": True,
|
||||
"mode": self.mode,
|
||||
"envelope": asdict(self.envelope),
|
||||
"worker_clock_domain_id": self.clock_domain_id,
|
||||
"last_check_monotonic_ns": str(self._last_check_ns),
|
||||
"observations": self.observations,
|
||||
"latest_observation": observed,
|
||||
"current_failures": list(self._current),
|
||||
"terminal_failures": list(self._failure),
|
||||
"post_warmup_envelope_violations": sorted(self._post_warmup_violations),
|
||||
"warmup_readiness_checked": self._warmup_checked,
|
||||
"realtime_qualified": False,
|
||||
"actuation_allowed": False,
|
||||
}
|
||||
Reference in New Issue
Block a user