Brain-5D-Space / src /storage /async_runtime.py
github-actions[bot]
Sync: publish Space API fix
5e0b58b
Raw History Blame Contribute Delete
8.02 kB
"""Bounded asynchronous journal writer for Brain-5D runtime persistence."""
from __future__ import annotations
import time
from dataclasses import dataclass
from queue import Full, Queue
from threading import Event, Lock, Thread
from typing import Final
from .delta_journal import DeltaJournal, DeltaRecord
from .runtime import (
RuntimeNetworkLike,
StepResultLike,
StorageRuntimeConfig,
StorageSession,
)
_STOP: Final[object] = object()
@dataclass(frozen=True, slots=True)
class AsyncStorageConfig:
"""Configuration for bounded asynchronous persistence."""
queue_size: int = 1000
drop_on_overflow: bool = False
enqueue_timeout_s: float = 0.25
def __post_init__(self) -> None:
if self.queue_size <= 0:
raise ValueError("queue_size must be positive")
if self.enqueue_timeout_s < 0.0:
raise ValueError("enqueue_timeout_s must be non-negative")
@dataclass(frozen=True, slots=True)
class StorageTelemetrySnapshot:
"""Immutable storage runtime telemetry."""
queue_depth: int
queue_capacity: int
batches_enqueued: int
batches_written: int
deltas_written: int
bytes_written: int
dropped_batches: int
write_latency_ms: float
commit_latency_ms: float
journal_size_bytes: int
worker_failed: bool
@dataclass(frozen=True, slots=True)
class _Batch:
tick: int
deltas: tuple[DeltaRecord, ...]
class AsyncStorageSession:
"""Persist typed delta batches on a bounded background worker thread.
Delta detection remains on the simulation thread so the worker never reads
mutable network state. Only immutable ``DeltaRecord`` instances cross the
queue boundary.
"""
def __init__(
self,
network: RuntimeNetworkLike,
runtime_config: StorageRuntimeConfig,
async_config: AsyncStorageConfig,
) -> None:
self.network = network
self.runtime_config = runtime_config
self.async_config = async_config
self._collector = StorageSession(network, runtime_config)
self._queue: Queue[_Batch | object] = Queue(maxsize=async_config.queue_size)
self._thread: Thread | None = None
self._stop = Event()
self._failure: BaseException | None = None
self._lock = Lock()
self._attached = False
self._batches_enqueued = 0
self._batches_written = 0
self._deltas_written = 0
self._bytes_written = 0
self._dropped_batches = 0
self._write_latency_ms = 0.0
self._commit_latency_ms = 0.0
def __enter__(self) -> AsyncStorageSession:
self.start()
return self
def __exit__(
self,
*_args: object,
) -> None:
self.close()
@property
def attached(self) -> bool:
"""Return whether the network hook is active."""
return self._attached
@property
def telemetry(self) -> StorageTelemetrySnapshot:
"""Return a consistent snapshot of queue and write telemetry."""
with self._lock:
journal_size = (
self.runtime_config.journal_path.stat().st_size
if self.runtime_config.journal_path.exists()
else 0
)
return StorageTelemetrySnapshot(
queue_depth=self._queue.qsize(),
queue_capacity=self.async_config.queue_size,
batches_enqueued=self._batches_enqueued,
batches_written=self._batches_written,
deltas_written=self._deltas_written,
bytes_written=self._bytes_written,
dropped_batches=self._dropped_batches,
write_latency_ms=self._write_latency_ms,
commit_latency_ms=self._commit_latency_ms,
journal_size_bytes=journal_size,
worker_failed=self._failure is not None,
)
def start(self) -> None:
"""Initialize snapshot/fingerprints and start the storage worker."""
if self._attached:
return
self._prepare_collector()
self._stop.clear()
self._thread = Thread(
target=self._worker_main,
name="brain5d-storage",
daemon=True,
)
self._thread.start()
self.network.add_post_step_hook(self.capture)
self._attached = True
def _prepare_collector(self) -> None:
"""Prime the synchronous collector without leaving its journal open."""
self._collector.prepare_snapshot()
self._collector.prime()
def capture(self, result: StepResultLike) -> None:
"""Collect immutable deltas and enqueue one tick batch."""
self._raise_worker_failure()
deltas = self._collector.collect_deltas(result)
batch = _Batch(int(result.tick), deltas)
if self.async_config.drop_on_overflow:
try:
self._queue.put_nowait(batch)
except Full:
with self._lock:
self._dropped_batches += 1
return
else:
self._queue.put(batch, timeout=self.async_config.enqueue_timeout_s or None)
with self._lock:
self._batches_enqueued += 1
def flush(self) -> None:
"""Wait until all queued batches have been processed."""
self._queue.join()
self._raise_worker_failure()
def close(self) -> None:
"""Drain pending work, commit, detach, and stop the worker."""
if self._attached:
self.network.remove_post_step_hook(self.capture)
self._attached = False
if self._thread is None:
return
self.flush()
self._queue.put(_STOP)
self._thread.join()
self._thread = None
self._raise_worker_failure()
def _raise_worker_failure(self) -> None:
if self._failure is not None:
raise RuntimeError("asynchronous storage worker failed") from self._failure
def _worker_main(self) -> None:
try:
with DeltaJournal(
self.runtime_config.journal_path,
base_tick=self.network.current_tick,
) as journal:
scan = journal.validate()
if scan.has_uncommitted_tail:
journal.truncate_uncommitted_tail()
while not self._stop.is_set():
item = self._queue.get()
try:
if item is _STOP:
if journal.dirty_entry_count:
self._record_commit(journal)
return
if not isinstance(item, _Batch):
raise TypeError("invalid async storage queue item")
self._write_batch(journal, item)
finally:
self._queue.task_done()
except BaseException as exc: # worker must surface all failures
self._failure = exc
def _write_batch(self, journal: DeltaJournal, batch: _Batch) -> None:
started = time.perf_counter()
written_bytes = 0
for delta in batch.deltas:
journal.append(delta)
written_bytes += len(delta.payload)
elapsed_ms = (time.perf_counter() - started) * 1000.0
if (
batch.tick % self.runtime_config.commit_interval_ticks == 0
and journal.dirty_entry_count
):
self._record_commit(journal)
with self._lock:
self._batches_written += 1
self._deltas_written += len(batch.deltas)
self._bytes_written += written_bytes
self._write_latency_ms = elapsed_ms
def _record_commit(self, journal: DeltaJournal) -> None:
started = time.perf_counter()
journal.commit()
elapsed_ms = (time.perf_counter() - started) * 1000.0
with self._lock:
self._commit_latency_ms = elapsed_ms