oc_chat
Loading...
Searching...
No Matches
event_writer.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/core/event_writer.py
4
5"""
6NDJSON event-log writer — M2.2 (event substrate).
7
8Purpose
9-------
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:
14
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.
26
27Replay tools (M2.7) parse the lines back with ``TurnEvent.model_validate_json``.
28
29Responsibilities
30----------------
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).
36
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).
40
41Diagnostics
42-----------
43Domain: OC_CHAT.MAIN
44Levels:
45 L2 — write / serialize / fsync failures (surfaced before re-raise)
46 L3 — attach / detach / rotation lifecycle
47 L4 — module-load coverage marker
48
49Contracts
50---------
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.
54
55Dependencies
56------------
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)
61"""
62from __future__ import annotations
63
64import os
65import re
66import sys
67import threading
68from dataclasses import dataclass, field
69from pathlib import Path
70from typing import IO, Optional
71
72from oc_diagnostics import _dbg
73
74# Importing the bus pulls in oc_chat.core.events, whose module-level bootstrap
75# makes option_c_agent_contracts importable before the canonical_json import.
76from oc_chat.core.event_bus import EventBus, Subscription
77from oc_chat.core.events import TurnEvent
78from option_c_agent_contracts import canonical_json
79
80_dbg("OC_CHAT.MAIN", "event_writer", "module", 4)
81
82
84 """Exclusive, cross-platform advisory lock over an open binary file.
85
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.
89 """
90
91 def __init__(self, fileobj: IO[bytes]) -> None:
92 self._fileobj = fileobj
93
94 def __enter__(self) -> "_FileLock":
95 self.acquire()
96 return self
97
98 def __exit__(self, *_exc: object) -> None:
99 self.release()
100
101 def acquire(self) -> None:
102 func = "_FileLock.acquire"
103 fd = self._fileobj.fileno()
104 if sys.platform == "win32":
105 import msvcrt
106
107 saved = self._fileobj.tell()
108 self._fileobj.seek(0)
109 try:
110 msvcrt.locking(fd, msvcrt.LK_LOCK, 1)
111 finally:
112 self._fileobj.seek(saved)
113 else:
114 import fcntl
115
116 fcntl.flock(fd, fcntl.LOCK_EX)
117 _dbg("OC_CHAT.MAIN", func, "acquired", 4)
118
119 def release(self) -> None:
120 func = "_FileLock.release"
121 fd = self._fileobj.fileno()
122 if sys.platform == "win32":
123 import msvcrt
124
125 saved = self._fileobj.tell()
126 self._fileobj.seek(0)
127 try:
128 msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
129 finally:
130 self._fileobj.seek(saved)
131 else:
132 import fcntl
133
134 fcntl.flock(fd, fcntl.LOCK_UN)
135 _dbg("OC_CHAT.MAIN", func, "released", 4)
136
137
138def _missing_ancestors(target: Path) -> tuple[Path, ...]:
139 """Return the *target* chain that does not yet exist, DEEPEST FIRST.
140
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
144 order.
145 """
146 missing: list[Path] = []
147 node = Path(target)
148 while not node.exists():
149 missing.append(node)
150 parent = node.parent
151 if parent == node: # filesystem root — stop, never walk past it
152 break
153 node = parent
154 return tuple(missing)
155
156
157@dataclass(frozen=True)
159 """What a PREPARE attempt created, so an abort can undo exactly that (R09-07).
160
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.
165 """
166
167 #: Directories this attempt created, deepest first. Removed only if empty.
168 created_dirs: tuple[Path, ...] = field(default_factory=tuple)
169 #: The slot file this attempt created, if any. Removed only if still empty.
170 created_file: Optional[Path] = None
171 #: ``True`` when the slot file was already on disk (never removed).
172 file_preexisted: bool = False
173
174
175@dataclass
177 """An opened-but-unsubscribed :class:`EventWriter` plus its ownership receipt.
178
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
183 filesystem effects.
184
185 :meth:`abort` is IDEMPOTENT and safe to call repeatedly, including from a
186 resource-owning context manager unwinding under a repeated ``KeyboardInterrupt``.
187 """
188
189 writer: "EventWriter"
190 receipt: EventWriterOwnershipReceipt
191 _aborted: bool = field(default=False, repr=False)
192
193 def abort(self) -> None:
194 """Close the descriptor and remove ONLY what this attempt created.
195
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
200 is kept.
201 """
202 func = "PreparedEventWriter.abort"
203 if self._aborted:
204 return # idempotent — repeated SIGINT safe
205 self._aborted = True
206 try:
207 self.writer.close()
208 except Exception as exc: # pragma: no cover - defensive
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:
212 try:
213 if created.exists() and created.stat().st_size == 0:
214 created.unlink()
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: # deepest first
220 try:
221 if node.is_dir() and not any(node.iterdir()):
222 node.rmdir()
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)
226
227
229 """Append ``TurnEvent``s published on an :class:`EventBus` to a rotating NDJSON log."""
230
232 self,
233 base_dir: Path,
234 *,
235 max_file_bytes: int = 10 * 1024 * 1024,
236 rotation_format: str = "{stem}.{seq:04d}.jsonl",
237 stem: str = "events",
238 ) -> None:
239 self._base_dir = Path(base_dir)
240 self._max_file_bytes = int(max_file_bytes)
241 self._rotation_format = rotation_format
242 self._stem = stem
243 self._active_fd: Optional[IO[bytes]] = None
244 self._active_path: Optional[Path] = None
245 self._seq = 0
246 self._subscription: Optional[Subscription] = None
247 self._bus: Optional[EventBus] = None
248 self._lock = threading.RLock()
249
250 @property
251 def active_path(self) -> Optional[Path]:
252 """The file the next event will be written to (``None`` until attached)."""
253 return self._active_path
254
255 def _slot_path(self, seq: int) -> Path:
256 return self._base_dir / self._rotation_format.format(stem=self._stem, seq=seq)
257
258 def _discover_resume_seq(self) -> int:
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$")
261 max_seq = 0
262 for child in self._base_dir.iterdir():
263 if not child.is_file():
264 continue
265 match = pattern.match(child.name)
266 if match:
267 max_seq = max(max_seq, int(match.group(1)))
268 return max_seq
269
270 def attach(self, bus: EventBus) -> Subscription:
271 """Subscribe ``_on_event`` to ``bus`` as a wildcard consumer; open the active file.
272
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.
276 """
277 func = "attach"
278 with self._lock:
279 self._base_dir.mkdir(parents=True, exist_ok=True)
280 self._seq = self._discover_resume_seq()
281 self._active_path = self._slot_path(self._seq)
282 self._active_fd = open(self._active_path, "ab", buffering=0)
283 self._bus = bus
284 self._subscription = bus.subscribe("*", self._on_event)
285 _dbg(
286 "OC_CHAT.MAIN",
287 func,
288 f"attached base_dir={self._base_dir} active={self._active_path.name}",
289 3,
290 )
291 return self._subscription
292
293 def open_unsubscribed(self) -> "PreparedEventWriter":
294 """Open the active file WITHOUT subscribing a bus — the PREPARE half.
295
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).
300
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.
307
308 No bus interaction has happened when this returns, so an abort has
309 nothing to unsubscribe.
310 """
311 func = "open_unsubscribed"
312 with self._lock:
313 created_dirs = _missing_ancestors(self._base_dir)
314 self._base_dir.mkdir(parents=True, exist_ok=True)
315 self._seq = self._discover_resume_seq()
316 self._active_path = self._slot_path(self._seq)
317 file_preexisted = self._active_path.exists()
318 self._active_fd = open(self._active_path, "ab", buffering=0)
320 created_dirs=created_dirs,
321 created_file=None if file_preexisted else self._active_path,
322 file_preexisted=file_preexisted,
323 )
324 _dbg(
325 "OC_CHAT.MAIN",
326 func,
327 f"opened base_dir={self._base_dir} active={self._active_path.name} "
328 f"created_dirs={len(created_dirs)} file_preexisted={file_preexisted}",
329 3,
330 )
331 return PreparedEventWriter(writer=self, receipt=receipt)
332
333 def subscribe_only(self, bus: EventBus) -> Subscription:
334 """Subscribe an ALREADY-OPENED writer to *bus* — the COMMIT half.
335
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.
340 """
341 with self._lock:
342 self._bus = bus
343 self._subscription = bus.subscribe("*", self._on_event)
344 return self._subscription
345
346 def adopt_subscription(self, bus: EventBus, subscription: Subscription) -> None:
347 """Record a subscription created on this writer's behalf by the bus swap.
348
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.
352 """
353 with self._lock:
354 self._bus = bus
355 self._subscription = subscription
356
357 def detach(self) -> None:
358 """Unsubscribe from the bus and close the active file (flush + fsync first)."""
359 func = "detach"
360 with self._lock:
361 if self._subscription is not None and self._bus is not None:
362 self._bus.unsubscribe(self._subscription)
363 self._subscription = None
364 self._bus = None
365 self._close_active()
366 _dbg("OC_CHAT.MAIN", func, "detached", 3)
367
368 def close(self) -> None:
369 """Detach and ensure the active file is flushed, fsynced, and closed.
370
371 Equivalent to :meth:`detach`; kept as an explicit lifecycle name for
372 callers that own the writer outside a bus subscription.
373 """
374 self.detach()
375
376 def _close_active(self) -> None:
377 """Flush + fsync + close the active file handle if open. Caller holds the lock."""
378 func = "_close_active"
379 if self._active_fd is None:
380 return
381 try:
382 self._active_fd.flush()
383 os.fsync(self._active_fd.fileno())
384 except OSError as exc: # best-effort on teardown
385 _dbg(
386 "OC_CHAT.MAIN",
387 func,
388 f"flush/fsync on close failed: {type(exc).__name__}: {exc}",
389 2,
390 )
391 finally:
392 self._active_fd.close()
393 self._active_fd = None
394
395 def _rotate(self) -> None:
396 """Close the active file and open the next sequence slot. Caller holds the lock."""
397 func = "_rotate"
398 old_name = self._active_path.name if self._active_path is not None else "?"
399 self._close_active()
400 self._seq += 1
401 self._active_path = self._slot_path(self._seq)
402 self._active_fd = open(self._active_path, "ab", buffering=0)
403 _dbg("OC_CHAT.MAIN", func, f"rotated {old_name} -> {self._active_path.name}", 3)
404
405 def _on_event(self, event: TurnEvent) -> None:
406 """Serialize ``event`` to one canonical-JSON line and append it under the file lock.
407
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).
411 """
412 func = "_on_event"
413 with self._lock:
414 if self._active_fd is None:
415 _dbg("OC_CHAT.MAIN", func, "event received while detached; ignoring", 3)
416 return
417 try:
418 line = canonical_json(event.model_dump(mode="json")).encode("utf-8") + b"\n"
419 except Exception as exc: # noqa: BLE001 - surface then re-raise
420 _dbg(
421 "OC_CHAT.MAIN",
422 func,
423 f"serialization failed kind={event.kind!r}: {type(exc).__name__}: {exc}",
424 2,
425 )
426 raise
427 try:
428 with _FileLock(self._active_fd):
429 self._active_fd.write(line)
430 self._active_fd.flush()
431 os.fsync(self._active_fd.fileno())
432 except OSError as exc:
433 _dbg(
434 "OC_CHAT.MAIN",
435 func,
436 f"write/fsync failed path={self._active_path}: {type(exc).__name__}: {exc}",
437 2,
438 )
439 raise
440 if os.fstat(self._active_fd.fileno()).st_size >= self._max_file_bytes:
441 self._rotate()
442
443
444__all__ = ["EventWriter"]
None __init__(self, IO[bytes] fileobj)
None __exit__(self, *object _exc)
"PreparedEventWriter" open_unsubscribed(self)
Optional[Subscription] _subscription
None _on_event(self, TurnEvent event)
Subscription subscribe_only(self, EventBus bus)
Subscription attach(self, EventBus bus)
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)