6NDJSON event-log writer — M2.2 (event substrate).
10Persist every ``option_c_agent_contracts.TurnEvent`` published on the M2.1
11``EventBus`` to disk, one canonical-JSON line at a time. The writer subscribes
12to the bus as a wildcard (``"*"``) consumer and is the first real exerciser of
13the M2.1 surface (plan 098). Three disciplines load-bear:
15- **Atomic** — each ``_on_event`` writes one full line via a single ``write()``
16 + ``fsync()`` (the file is opened unbuffered), so a crash mid-call leaves
17 either a complete prior line or an incomplete trailing line — never a torn
18 middle line that breaks NDJSON parse of earlier events.
19- **Locked** — cross-process exclusion on the active file via a stdlib-only
20 ``_FileLock`` (``fcntl`` on POSIX, ``msvcrt`` on Windows). No third-party
21 ``filelock`` / ``portalocker`` dependency (ADR-1).
22- **Rotating** — when the active file reaches ``max_file_bytes`` the writer
23 rotates in-line at write time (never on a background thread, ADR-3): the
24 triggering event is written to the current file, then the next sequence slot
25 is opened for subsequent events.
27Replay tools (M2.7) parse the lines back with ``TurnEvent.model_validate_json``.
31- Expose ``EventWriter`` with ``attach`` / ``detach`` / ``close`` / ``_on_event``.
32- Serialize via ``canonical_json(event.model_dump(mode="json"))``.
33- Resume from the highest existing rotation slot in ``base_dir`` (ADR-4).
34- Surface its own write/serialize failures via ``_dbg`` (L2) before re-raising;
35 never silently swallow (the bus isolates the exception per M2.1 ADR-3).
37Out of scope (deferred): ``run_turn()`` integration (M2.3), session-identity
38binding (M2.5), replay (M2.7), concurrent-writer stress (M2.8), async/queued
39writes (synchronous per M2.1 ADR-1).
45 L2 — write / serialize / fsync failures (surfaced before re-raise)
46 L3 — attach / detach / rotation lifecycle
47 L4 — module-load coverage marker
51- One ``write()`` + one ``fsync()`` per published event; the file is unbuffered.
52- Rotation is observed synchronously after each write (ADR-3); no sidecar thread.
53- ``EventWriter`` adds no third-party dependency — locking is stdlib only.
57- oc_diagnostics (structured debug logging via _dbg)
58- oc_chat.core.event_bus (EventBus / Subscription)
59- oc_chat.core.events (TurnEvent; runs the contracts-import bootstrap)
60- option_c_agent_contracts (canonical_json serialisation of each event line)
62from __future__
import annotations
68from dataclasses
import dataclass, field
69from pathlib
import Path
70from typing
import IO, Optional
72from oc_diagnostics
import _dbg
78from option_c_agent_contracts
import canonical_json
80_dbg(
"OC_CHAT.MAIN",
"event_writer",
"module", 4)
84 """Exclusive, cross-platform advisory lock over an open binary file.
86 Stdlib only (ADR-1): ``fcntl.flock`` on POSIX, ``msvcrt.locking`` on Windows
87 (locking a single byte at offset 0, a region both writer processes agree
88 on). Used as a context manager around the write + fsync of one line.
91 def __init__(self, fileobj: IO[bytes]) ->
None:
102 func =
"_FileLock.acquire"
104 if sys.platform ==
"win32":
110 msvcrt.locking(fd, msvcrt.LK_LOCK, 1)
116 fcntl.flock(fd, fcntl.LOCK_EX)
117 _dbg(
"OC_CHAT.MAIN", func,
"acquired", 4)
120 func =
"_FileLock.release"
122 if sys.platform ==
"win32":
128 msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
134 fcntl.flock(fd, fcntl.LOCK_UN)
135 _dbg(
"OC_CHAT.MAIN", func,
"released", 4)
139 """Return the *target* chain that does not yet exist, DEEPEST FIRST.
141 Used to build an ownership receipt before ``mkdir(parents=True)``: every path
142 returned here is one this attempt is about to create, so an abort may remove
143 exactly those (and nothing above them). Deepest-first is the safe removal
146 missing: list[Path] = []
148 while not node.exists():
154 return tuple(missing)
157@dataclass(frozen=True)
159 """What a PREPARE attempt created, so an abort can undo exactly that (R09-07).
161 The distinction is load-bearing: an aborting transition must leave the
162 filesystem byte-identical to the pre-prepare snapshot, which means removing
163 the directories and the slot file this attempt made while never deleting a
164 pre-existing event log that merely happened to be in the way.
168 created_dirs: tuple[Path, ...] = field(default_factory=tuple)
170 created_file: Optional[Path] =
None
172 file_preexisted: bool =
False
177 """An opened-but-unsubscribed :class:`EventWriter` plus its ownership receipt.
179 Produced by :meth:`EventWriter.open_unsubscribed` in a transition's PREPARE
180 phase. Either the transition commits — and
181 :func:`oc_chat.core.event_bootstrap.install_prepared_writer` performs the
182 linearizable bus swap — or it aborts and :meth:`abort` undoes this attempt's
185 :meth:`abort` is IDEMPOTENT and safe to call repeatedly, including from a
186 resource-owning context manager unwinding under a repeated ``KeyboardInterrupt``.
189 writer:
"EventWriter"
190 receipt: EventWriterOwnershipReceipt
191 _aborted: bool = field(default=
False, repr=
False)
194 """Close the descriptor and remove ONLY what this attempt created.
196 Every step is individually guarded: a failure to remove one artifact must
197 not prevent the rest of the cleanup, and cleanup must never raise into an
198 already-failing unwind path. A created file is removed only while it is
199 still empty (size 0) — if anything was written to it, it is real data and
202 func =
"PreparedEventWriter.abort"
208 except Exception
as exc:
209 _dbg(
"OC_CHAT.MAIN", func, f
"EXTERNAL_ERROR: close failed: {exc}", 2)
210 created = self.receipt.created_file
211 if created
is not None:
213 if created.exists()
and created.stat().st_size == 0:
215 elif created.exists():
216 _dbg(
"OC_CHAT.MAIN", func, f
"kept non-empty {created.name} (has data)", 2)
217 except Exception
as exc:
218 _dbg(
"OC_CHAT.MAIN", func, f
"EXTERNAL_ERROR: unlink failed: {exc}", 2)
219 for node
in self.receipt.created_dirs:
221 if node.is_dir()
and not any(node.iterdir()):
223 except Exception
as exc:
224 _dbg(
"OC_CHAT.MAIN", func, f
"EXTERNAL_ERROR: rmdir {node} failed: {exc}", 2)
225 _dbg(
"OC_CHAT.MAIN", func,
"aborted prepared writer", 3)
229 """Append ``TurnEvent``s published on an :class:`EventBus` to a rotating NDJSON log."""
235 max_file_bytes: int = 10 * 1024 * 1024,
236 rotation_format: str =
"{stem}.{seq:04d}.jsonl",
237 stem: str =
"events",
247 self.
_bus: Optional[EventBus] =
None
252 """The file the next event will be written to (``None`` until attached)."""
259 """Return the highest existing rotation seq in ``base_dir`` (ADR-4), else 0."""
260 pattern = re.compile(rf
"^{re.escape(self._stem)}\.(\d+)\.jsonl$")
263 if not child.is_file():
265 match = pattern.match(child.name)
267 max_seq = max(max_seq, int(match.group(1)))
270 def attach(self, bus: EventBus) -> Subscription:
271 """Subscribe ``_on_event`` to ``bus`` as a wildcard consumer; open the active file.
273 Creates ``base_dir`` if missing and resumes from the highest existing
274 rotation slot. Returns the :class:`Subscription` handle so callers can
275 ``detach`` symmetrically.
279 self.
_base_dir.mkdir(parents=
True, exist_ok=
True)
288 f
"attached base_dir={self._base_dir} active={self._active_path.name}",
294 """Open the active file WITHOUT subscribing a bus — the PREPARE half.
296 :meth:`attach` opens the file and THEN subscribes; a transition needs
297 those two halves split, because the fallible I/O (mkdir, resume scan,
298 open) must happen in PREPARE while the bus mutation is the no-fail COMMIT
299 step (plan 152 ADR-6 / Codex R08-02).
301 Returns a :class:`PreparedEventWriter` carrying an OWNERSHIP RECEIPT that
302 records precisely which directories and which slot file THIS attempt
303 created versus which already existed. If the transition later aborts,
304 :meth:`PreparedEventWriter.abort` closes the descriptor and removes ONLY
305 this attempt's own artifacts, and only while they are still empty — a
306 pre-existing event log is never touched.
308 No bus interaction has happened when this returns, so an abort has
309 nothing to unsubscribe.
311 func =
"open_unsubscribed"
314 self.
_base_dir.mkdir(parents=
True, exist_ok=
True)
320 created_dirs=created_dirs,
321 created_file=
None if file_preexisted
else self.
_active_path,
322 file_preexisted=file_preexisted,
327 f
"opened base_dir={self._base_dir} active={self._active_path.name} "
328 f
"created_dirs={len(created_dirs)} file_preexisted={file_preexisted}",
334 """Subscribe an ALREADY-OPENED writer to *bus* — the COMMIT half.
336 Complements :meth:`open_unsubscribed`. Used by
337 :func:`oc_chat.core.event_bootstrap.install_prepared_writer`, which
338 performs the linearizable swap itself; this only records the resulting
339 handle on the writer so :meth:`detach` stays symmetric.
347 """Record a subscription created on this writer's behalf by the bus swap.
349 :meth:`EventBus.replace_subscription` builds the handle atomically, so the
350 writer cannot create it itself; this stores the result so :meth:`detach`
351 can unsubscribe symmetrically. Pure assignment — cannot fail.
358 """Unsubscribe from the bus and close the active file (flush + fsync first)."""
366 _dbg(
"OC_CHAT.MAIN", func,
"detached", 3)
369 """Detach and ensure the active file is flushed, fsynced, and closed.
371 Equivalent to :meth:`detach`; kept as an explicit lifecycle name for
372 callers that own the writer outside a bus subscription.
377 """Flush + fsync + close the active file handle if open. Caller holds the lock."""
378 func =
"_close_active"
384 except OSError
as exc:
388 f
"flush/fsync on close failed: {type(exc).__name__}: {exc}",
396 """Close the active file and open the next sequence slot. Caller holds the lock."""
403 _dbg(
"OC_CHAT.MAIN", func, f
"rotated {old_name} -> {self._active_path.name}", 3)
406 """Serialize ``event`` to one canonical-JSON line and append it under the file lock.
408 After the write, rotates in-line if the active file has reached
409 ``max_file_bytes`` (ADR-3). Serialization / write / fsync failures are
410 logged at L2 and re-raised (the bus isolates them per M2.1 ADR-3).
415 _dbg(
"OC_CHAT.MAIN", func,
"event received while detached; ignoring", 3)
418 line = canonical_json(event.model_dump(mode=
"json")).encode(
"utf-8") + b
"\n"
419 except Exception
as exc:
423 f
"serialization failed kind={event.kind!r}: {type(exc).__name__}: {exc}",
432 except OSError
as exc:
436 f
"write/fsync failed path={self._active_path}: {type(exc).__name__}: {exc}",
444__all__ = [
"EventWriter"]
"_FileLock" __enter__(self)
None __init__(self, IO[bytes] fileobj)
None __exit__(self, *object _exc)
Optional[Path] _active_path
"PreparedEventWriter" open_unsubscribed(self)
Path _slot_path(self, int seq)
Optional[IO[bytes]] _active_fd
Optional[Subscription] _subscription
None _on_event(self, TurnEvent event)
Subscription subscribe_only(self, EventBus bus)
Optional[Path] active_path(self)
Subscription attach(self, EventBus bus)
int _discover_resume_seq(self)
None adopt_subscription(self, EventBus bus, Subscription subscription)
None __init__(self, Path base_dir, *, int max_file_bytes=10 *1024 *1024, str rotation_format="{stem}.{seq:04d}.jsonl", str stem="events")
tuple[Path,...] _missing_ancestors(Path target)