Spaces:
Paused
Paused
File size: 8,017 Bytes
5e0b58b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 | """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
|