fix(k1): stabilize live recovery and media admission

This commit is contained in:
DCCONSTRUCTIONS
2026-08-22 00:03:01 +03:00
parent 7217244886
commit eaad9deda1
29 changed files with 1645 additions and 199 deletions
+59 -11
View File
@@ -355,24 +355,66 @@ def newly_finalized_recording_ids(
return tuple(sorted(set(current_finalized) - known_finalized))
def observation_archive_revision(roots: Iterable[Path]) -> tuple[tuple[object, ...], ...]:
"""Return a cheap change fence for direct-child observation archives.
The K1 writer creates and retires ``.current_session`` in the archive root,
and every session directory is a direct child of that same root. Those
operations advance the directory metadata, while growing capture/media
files do not. This lets the reconciler notice session start, completion and
removal without recursively reopening tens of thousands of immutable
evidence files every two seconds.
"""
revisions: list[tuple[object, ...]] = []
for configured_root in roots:
root = configured_root.expanduser().resolve(strict=False)
try:
metadata = root.lstat()
except OSError:
revisions.append((str(root), None))
continue
revisions.append(
(
str(root),
metadata.st_dev,
metadata.st_ino,
metadata.st_mtime_ns,
metadata.st_ctime_ns,
)
)
revisions.sort(key=lambda item: str(item[0]))
return tuple(revisions)
async def _recording_preparation_reconciler() -> None:
"""Prepare sessions finalized during this process, never historical rows."""
known_finalized: set[str] | None = None
reconciled_archive_revision: tuple[tuple[object, ...], ...] | None = None
while True:
try:
await asyncio.to_thread(refresh_observation_catalog)
finalized = set(await asyncio.to_thread(finalized_replayable_recording_ids))
newly_finalized = newly_finalized_recording_ids(
known_finalized,
finalized,
archive_revision = await asyncio.to_thread(
observation_archive_revision,
(archive.root for archive in plugin_environment.observation_archives),
)
if newly_finalized:
await asyncio.to_thread(
enqueue_replayable_recordings,
newly_finalized,
if archive_revision != reconciled_archive_revision:
await asyncio.to_thread(refresh_observation_catalog)
finalized = set(await asyncio.to_thread(finalized_replayable_recording_ids))
newly_finalized = newly_finalized_recording_ids(
known_finalized,
finalized,
)
known_finalized = finalized
if newly_finalized:
await asyncio.to_thread(
enqueue_replayable_recordings,
newly_finalized,
)
known_finalized = finalized
# Keep the pre-scan revision. If a writer changed the archive
# during the expensive discovery, the next two-second check
# observes the newer revision and performs one follow-up pass.
reconciled_archive_revision = archive_revision
recording_reconciler_readiness.record_success()
except Exception as exc:
# A transient filesystem/catalog failure must not permanently
@@ -496,6 +538,12 @@ _CLOSED_WEBSOCKET_SEND_ERRORS = frozenset(
}
)
# This stream carries the comparatively heavy control-state snapshot, not
# camera or point-cloud media. Action requests return their own result, and the
# frontend also keeps a four-second REST fallback, so a two-second passive
# cadence preserves status recovery without starving the live media sockets.
DEVICE_PLUGIN_EVENT_POLL_INTERVAL_SECONDS = 2.0
def _is_closed_websocket_send_error(exc: RuntimeError) -> bool:
message = " ".join(str(exc).split())
@@ -528,7 +576,7 @@ async def device_plugin_events(websocket: WebSocket, plugin_id: str) -> None:
if _is_closed_websocket_send_error(exc):
return
raise
await asyncio.sleep(0.5)
await asyncio.sleep(DEVICE_PLUGIN_EVENT_POLL_INTERVAL_SECONDS)
except (PluginNotFoundError, PluginActionNotFoundError):
await websocket.close(code=1008, reason="Device plugin is not available")
except (PluginExecutionError, PluginRuntimeUnavailableError):