oc_chat
Loading...
Searching...
No Matches
journal.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/registry/source_migrations/journal.py
4
5# OCT-LINT: disable=tests_exist reason="Covered by oc_chat/tests/registry/test_source_migrate_journal.py"
6"""
7The per-run JSONL recovery journal (Plan 142m §4) — schema v1.
8
9Purpose
10-------
11A byte-for-byte PREIMAGE backup + a durable, crash-tolerant event log for the
12destructive ``migrate`` engine. The journal is a **JSON Lines** file
13(``<run-id>.jsonl``): line 1 is a header, then one preimage line per WRITE-SET
14record, then per write TWO event lines (``planned_postimage`` before the
15``os.replace`` — the durable rollback anchor — and ``applied`` after), then a
16terminal ``resolved`` event that IS the binding state transition.
17
18Every line is flushed + fsync'd. A HOST crash truncates at a line boundary (a
19CONSISTENT partial, folded as-is); an in-process FAILED append (ENOSPC mid-write)
20can leave a TORN final line — the APPEND-BOUNDARY RULE truncates it to the last
21complete line before the next append. A published journal whose header is
22unparseable, or whose body has a non-JSON complete line, is CORRUPT ⇒ UNRESOLVED
23(corruption cannot be proven NOT to be recovery state).
24
25Lifecycle (the sole normative definition):
26- ``<run-id>.jsonl.partial`` — being built; NEVER "unresolved".
27- ``<run-id>.jsonl`` — published (``os.replace``) before the first source write;
28 UNRESOLVED until its tail carries a terminal ``resolved`` event.
29- ``.journal/<target>/archive/<run-id>.jsonl`` — best-effort tidy move AFTER the
30 ``resolved`` append (which is the real transition); ``archive/`` is NEVER scanned.
31
32Responsibilities
33----------------
34- Build/publish the preimage journal, append fsync'd event lines, resolve+archive.
35- Fold a published journal from disk (the rollback + step-5 recovery basis) and
36 project its EXACT byte size for the disk preflight.
37
38Contracts
39---------
40- Every line is flushed + fsync'd; publish is atomic (``os.replace``) BEFORE the
41 first source write; the terminal ``resolved`` event is the binding state change.
42- A torn final line is a consistent partial; a corrupt/unparseable journal is
43 UNRESOLVED (fail-closed).
44
45Diagnostics
46-----------
47Domain: OC_CHAT.MAIN
48Levels:
49 L2 - journal open/close/publish/resolve/archive
50 L3 - per-line fold / append-boundary repair
51 L4 - module load
52
53Dependencies
54------------
55- oc_diagnostics (structured debug logging via _dbg)
56"""
57from __future__ import annotations
58
59import base64
60import hashlib
61import json
62import os
63from dataclasses import dataclass, field
64from pathlib import Path
65from typing import Optional
66
67from oc_diagnostics import _dbg
68
69_dbg("OC_CHAT.MAIN", "module", "loaded", 4)
70
71JOURNAL_SCHEMA_VERSION = 1
72EVENT_PLANNED_POSTIMAGE = "planned_postimage"
73EVENT_APPLIED = "applied"
74EVENT_RESOLVED = "resolved"
75OUTCOME_SUCCESS = "success"
76OUTCOME_ROLLED_BACK = "rolled_back"
77
78_PARTIAL_SUFFIX = ".jsonl.partial"
79_PUBLISHED_SUFFIX = ".jsonl"
80_ARCHIVE_DIRNAME = "archive"
81
82
83# --- IO seams (monkeypatched by tests for fsync-ordering + failure injection) --
84def _fsync(fileobj: "object") -> None:
85 """fsync a file object (best-effort seam; overridable in tests)."""
86 os.fsync(fileobj.fileno()) # type: ignore[attr-defined]
87
88
89def _replace(src: Path, dst: Path) -> None:
90 """Atomic ``os.replace`` seam (overridable in tests for ordering/failure)."""
91 os.replace(src, dst)
92
93
94def _fsync_dir(path: Path) -> None:
95 """Best-effort directory fsync (unsupported on Windows — documented)."""
96 func = "_fsync_dir"
97 try:
98 fd = os.open(str(path), os.O_RDONLY)
99 except OSError:
100 return
101 try:
102 os.fsync(fd)
103 except OSError:
104 _dbg("OC_CHAT.MAIN", func, "directory fsync unsupported (platform)", 3)
105 finally:
106 os.close(fd)
107
108
109def _dumps(obj: dict) -> str:
110 """The single JSON encoder + LF convention every journal line uses (the exact
111 form the step-10 disk preflight projects)."""
112 return json.dumps(obj, sort_keys=True, separators=(",", ":")) + "\n"
113
114
115def _write_line(fileobj: "object", obj: dict) -> None:
116 """Write one JSON line, flush + fsync it (the per-line durability rule)."""
117 fileobj.write(_dumps(obj)) # type: ignore[attr-defined]
118 fileobj.flush() # type: ignore[attr-defined]
119 _fsync(fileobj)
120
121
122# --- preimage / header --------------------------------------------------------
123@dataclass(frozen=True)
125 """One WRITE-SET record's byte-for-byte preimage (§4)."""
126
127 rel_path: str
128 preimage_bytes: bytes
129 file_mode: Optional[int]
130
131 @property
132 def sha256(self) -> str:
133 return hashlib.sha256(self.preimage_bytes).hexdigest()
134
135 def to_line(self) -> dict:
136 return {
137 "rel_path": self.rel_path,
138 "action": "replace",
139 "exists_before": True,
140 "preimage_b64": base64.b64encode(self.preimage_bytes).decode("ascii"),
141 "file_mode": self.file_mode,
142 "sha256": self.sha256,
143 }
144
145
146def build_preimage(rel_path: str, raw_bytes: bytes, file_mode: Optional[int]) -> PreimageRecord:
147 """Build a preimage record from a write-set candidate's raw on-disk bytes."""
148 func = "build_preimage"
149 _dbg("OC_CHAT.MAIN", func, f"preimage {rel_path} ({len(raw_bytes)}B)", 3)
150 return PreimageRecord(rel_path=rel_path, preimage_bytes=raw_bytes, file_mode=file_mode)
151
152
153def header_dict(target_schema_version: str, source_root: str, run_id: str) -> dict:
154 """The journal header line (line 1)."""
155 return {
156 "journal_schema_version": JOURNAL_SCHEMA_VERSION,
157 "target_schema_version": target_schema_version,
158 "source_root": source_root,
159 "run_id": run_id,
160 }
161
162
164 header: dict,
165 preimages: list[PreimageRecord],
166 postimage_shas: list[str],
167) -> int:
168 """Serialize the PROJECTED journal with the SAME encoder + newline convention
169 and return its exact UTF-8 byte length (the step-10 disk-preflight input):
170 header + one preimage line per write-set record + one ``planned_postimage`` +
171 one ``applied`` per write + the terminal ``resolved`` line. Exact — never a
172 scalar multiplier (per-line JSON structure dominates for many tiny records)."""
173 func = "project_journal_bytes"
174 lines = [_dumps(header)]
175 for preimage in preimages:
176 lines.append(_dumps(preimage.to_line()))
177 for preimage, sha in zip(preimages, postimage_shas):
178 lines.append(_dumps({"event": EVENT_PLANNED_POSTIMAGE, "rel_path": preimage.rel_path,
179 "postimage_sha256": sha}))
180 lines.append(_dumps({"event": EVENT_APPLIED, "rel_path": preimage.rel_path}))
181 lines.append(_dumps({"event": EVENT_RESOLVED, "outcome": OUTCOME_SUCCESS}))
182 total = sum(len(line.encode("utf-8")) for line in lines)
183 _dbg("OC_CHAT.MAIN", func, f"projected journal {total}B ({len(preimages)} preimages)", 3)
184 return total
185
186
187# --- reading / folding --------------------------------------------------------
188@dataclass
190 """A preimage entry folded with its event lines (the rollback consults these)."""
191
192 preimage: dict
193 planned_postimage_sha: Optional[str] = None
194 applied: bool = False
195
196 @property
197 def rel_path(self) -> str:
198 return self.preimage["rel_path"]
199
200
201@dataclass(frozen=True)
203 """A folded view of one on-disk journal (the rollback + step-5 recovery basis)."""
204
205 path: Path
206 header: Optional[dict]
207 entries: list[FoldedEntry] = field(default_factory=list)
208 resolved: bool = False
209 outcome: Optional[str] = None
210 corrupt: bool = False
211
212 @property
213 def unresolved(self) -> bool:
214 """A published journal is UNRESOLVED iff corrupt OR its tail lacks a
215 terminal ``resolved`` event (fail-closed: corruption ⇒ unresolved)."""
216 return self.corrupt or not self.resolved
217
218 @property
219 def source_root(self) -> Optional[str]:
220 return (self.header or {}).get("source_root")
221
222 @property
223 def target_schema_version(self) -> Optional[str]:
224 return (self.header or {}).get("target_schema_version")
225
226 def entry_for(self, rel_path: str) -> Optional[FoldedEntry]:
227 for entry in self.entries:
228 if entry.rel_path == rel_path:
229 return entry
230 return None
231
232
233def read_journal(path: Path) -> ParsedJournal:
234 """Read + fold a published journal from DISK (the rollback data source + the
235 step-5 recovery check). A TORN final line (no trailing newline, unparseable) is
236 a consistent partial — ignored, NOT corrupt; a non-JSON COMPLETE line, or an
237 unparseable header, is corrupt ⇒ UNRESOLVED."""
238 func = "read_journal"
239 path = Path(path)
240 raw = path.read_bytes()
241 text = raw.decode("utf-8", errors="replace")
242 if text == "":
243 return ParsedJournal(path=path, header=None, corrupt=True)
244 ends_with_newline = text.endswith("\n")
245 segments = text.split("\n")
246 if ends_with_newline:
247 segments = segments[:-1] # drop the empty trailing segment
248 # The last segment is a TORN candidate only when the file did NOT end with \n.
249 complete = segments if ends_with_newline else segments[:-1]
250 torn_tail = None if ends_with_newline else segments[-1]
251
252 header: Optional[dict] = None
253 entries: list[FoldedEntry] = []
254 resolved = False
255 outcome: Optional[str] = None
256 corrupt = False
257 by_path: dict[str, FoldedEntry] = {}
258
259 for index, line in enumerate(complete):
260 if line == "":
261 continue
262 try:
263 obj = json.loads(line)
264 except ValueError:
265 corrupt = True # a non-JSON COMPLETE body line ⇒ corrupt
266 continue
267 if index == 0:
268 if isinstance(obj, dict) and "journal_schema_version" in obj:
269 header = obj
270 else:
271 corrupt = True
272 continue
273 if not isinstance(obj, dict):
274 corrupt = True
275 continue
276 event = obj.get("event")
277 rel_path = str(obj.get("rel_path", ""))
278 if event == EVENT_RESOLVED:
279 resolved = True
280 outcome = obj.get("outcome")
281 elif event == EVENT_PLANNED_POSTIMAGE:
282 entry = by_path.get(rel_path)
283 if entry is not None:
284 entry.planned_postimage_sha = obj.get("postimage_sha256")
285 elif event == EVENT_APPLIED:
286 entry = by_path.get(rel_path)
287 if entry is not None:
288 entry.applied = True
289 elif "preimage_b64" in obj:
290 entry = FoldedEntry(preimage=obj)
291 entries.append(entry)
292 by_path[entry.rel_path] = entry
293 else:
294 corrupt = True
295 if torn_tail not in (None, ""):
296 try:
297 json.loads(torn_tail)
298 except ValueError:
299 _dbg("OC_CHAT.MAIN", func, "torn final line — consistent partial", 3)
300 if header is None:
301 corrupt = True
302 _dbg("OC_CHAT.MAIN", func, f"folded {path.name}: resolved={resolved} corrupt={corrupt}", 2)
303 return ParsedJournal(
304 path=path, header=header, entries=entries, resolved=resolved,
305 outcome=outcome, corrupt=corrupt,
306 )
307
308
309def preimage_bytes_of(entry: FoldedEntry) -> bytes:
310 """Decode a folded entry's preimage bytes (base64)."""
311 return base64.b64decode(entry.preimage["preimage_b64"])
312
313
314def scan_unresolved(journal_root: Path) -> list[ParsedJournal]:
315 """Step 5: return every UNRESOLVED published journal under
316 ``journal_root/<target>/`` — scanned NON-recursively per target dir, so
317 ``archive/`` is never scanned. Recovery state is a ROOT-level hazard (ANY
318 target). ``.partial`` files are excluded."""
319 func = "scan_unresolved"
320 journal_root = Path(journal_root)
321 unresolved: list[ParsedJournal] = []
322 if not journal_root.is_dir():
323 return unresolved
324 for target_dir in sorted(journal_root.iterdir()):
325 if not target_dir.is_dir():
326 continue
327 for entry in sorted(target_dir.iterdir()):
328 if not entry.is_file():
329 continue # archive/ (a dir) is skipped; non-recursive
330 name = entry.name
331 if not name.endswith(_PUBLISHED_SUFFIX) or name.endswith(_PARTIAL_SUFFIX):
332 continue
333 parsed = read_journal(entry)
334 if parsed.unresolved:
335 unresolved.append(parsed)
336 _dbg("OC_CHAT.MAIN", func, f"{len(unresolved)} unresolved journal(s)", 2)
337 return unresolved
338
339
340# --- writing ------------------------------------------------------------------
342 """Builds, publishes, appends-to, resolves and archives one run's journal."""
343
344 def __init__(self, target_dir: Path, run_id: str, header: dict) -> None:
345 self.target_dir = Path(target_dir)
346 self.run_id = run_id
347 self.header = header
348 self.partial_path = self.target_dir / f"{run_id}{_PARTIAL_SUFFIX}"
349 self.final_path = self.target_dir / f"{run_id}{_PUBLISHED_SUFFIX}"
350 self.published = False
351
352 def build(self, preimages: list[PreimageRecord]) -> None:
353 """Write header + all preimage lines to ``<run-id>.jsonl.partial`` (fsync
354 per line). Raises ``OSError`` on a write failure (the caller cleans up)."""
355 func = "JournalWriter.build"
356 self.target_dir.mkdir(parents=True, exist_ok=True)
357 with open(self.partial_path, "w", encoding="utf-8", newline="\n") as fileobj:
358 _write_line(fileobj, self.header)
359 for preimage in preimages:
360 _write_line(fileobj, preimage.to_line())
361 _dbg("OC_CHAT.MAIN", func, f"built partial ({len(preimages)} preimages)", 2)
362
363 def publish(self) -> None:
364 """Atomically publish ``.partial`` → ``.jsonl`` (BEFORE the first source
365 write) + best-effort directory fsync."""
366 func = "JournalWriter.publish"
369 self.published = True
370 _dbg("OC_CHAT.MAIN", func, "journal published", 2)
371
372 def cleanup_partial(self) -> None:
373 """Best-effort delete of the ``.partial`` on a build/publish failure (a
374 surviving ``.partial`` never refuses — only a published ``.jsonl`` does)."""
375 func = "JournalWriter.cleanup_partial"
376 try:
377 self.partial_path.unlink(missing_ok=True)
378 except OSError:
379 _dbg("OC_CHAT.MAIN", func, "partial cleanup failed (harmless)", 1, override=True)
380
381 def _repair_torn_tail(self) -> None:
382 """APPEND-BOUNDARY RULE: before appending, truncate any torn final line
383 (an in-process failed append can leave partial bytes with no newline) so
384 the next append never fuses into a non-JSON line."""
385 func = "JournalWriter._repair_torn_tail"
386 raw = self.final_path.read_bytes()
387 if raw == b"" or raw.endswith(b"\n"):
388 return
389 last_newline = raw.rfind(b"\n")
390 keep = raw[: last_newline + 1] if last_newline != -1 else b""
391 with open(self.final_path, "wb") as fileobj:
392 fileobj.write(keep)
393 fileobj.flush()
394 _fsync(fileobj)
395 _dbg("OC_CHAT.MAIN", func, "truncated torn final line", 3)
396
397 def append_event(self, event: dict) -> None:
398 """Append one event line (fsync'd) after repairing any torn tail."""
399 self._repair_torn_tail()
400 with open(self.final_path, "a", encoding="utf-8", newline="\n") as fileobj:
401 _write_line(fileobj, event)
402
403 def planned_postimage(self, rel_path: str, postimage_sha256: str) -> None:
404 """Append the durable ``planned_postimage`` anchor (BEFORE the replace)."""
405 self.append_event({"event": EVENT_PLANNED_POSTIMAGE, "rel_path": rel_path,
406 "postimage_sha256": postimage_sha256})
407
408 def applied(self, rel_path: str) -> None:
409 """Append the ``applied`` event (AFTER a successful replace)."""
410 self.append_event({"event": EVENT_APPLIED, "rel_path": rel_path})
411
412 def resolve(self, outcome: str) -> None:
413 """Append + fsync the terminal ``resolved`` event — the BINDING state
414 transition (a resolved journal never refuses, even if never archived)."""
415 func = "JournalWriter.resolve"
416 self.append_event({"event": EVENT_RESOLVED, "outcome": outcome})
417 _dbg("OC_CHAT.MAIN", func, f"resolved ({outcome})", 2)
418
419 def archive(self) -> Path:
420 """Best-effort tidy move to ``archive/<run-id>.jsonl`` AFTER ``resolve``.
421 Returns the archived path on success, else the resolved-in-place path (an
422 archive-move failure is L2-logged and harmless)."""
423 func = "JournalWriter.archive"
424 archive_dir = self.target_dir / _ARCHIVE_DIRNAME
425 try:
426 archive_dir.mkdir(parents=True, exist_ok=True)
427 dest = archive_dir / f"{self.run_id}{_PUBLISHED_SUFFIX}"
428 _replace(self.final_path, dest)
429 _dbg("OC_CHAT.MAIN", func, "archived", 2)
430 return dest
431 except OSError:
432 _dbg("OC_CHAT.MAIN", func, "archive move failed (harmless; resolved-in-place)", 2)
433 return self.final_path
434
435
436__all__ = [
437 "JOURNAL_SCHEMA_VERSION",
438 "EVENT_PLANNED_POSTIMAGE",
439 "EVENT_APPLIED",
440 "EVENT_RESOLVED",
441 "OUTCOME_SUCCESS",
442 "OUTCOME_ROLLED_BACK",
443 "PreimageRecord",
444 "FoldedEntry",
445 "ParsedJournal",
446 "JournalWriter",
447 "build_preimage",
448 "header_dict",
449 "project_journal_bytes",
450 "read_journal",
451 "preimage_bytes_of",
452 "scan_unresolved",
453]
Definition journal.py:189
str rel_path(self)
Definition journal.py:197
None __init__(self, Path target_dir, str run_id, dict header)
Definition journal.py:344
None build(self, list[PreimageRecord] preimages)
Definition journal.py:352
None planned_postimage(self, str rel_path, str postimage_sha256)
Definition journal.py:403
Optional[FoldedEntry] entry_for(self, str rel_path)
Definition journal.py:226
list[ParsedJournal] scan_unresolved(Path journal_root)
Definition journal.py:314
bytes preimage_bytes_of(FoldedEntry entry)
Definition journal.py:309
dict header_dict(str target_schema_version, str source_root, str run_id)
Definition journal.py:153
None _write_line("object" fileobj, dict obj)
Definition journal.py:115
PreimageRecord build_preimage(str rel_path, bytes raw_bytes, Optional[int] file_mode)
Definition journal.py:146
int project_journal_bytes(dict header, list[PreimageRecord] preimages, list[str] postimage_shas)
Definition journal.py:167
None _replace(Path src, Path dst)
Definition journal.py:89
ParsedJournal read_journal(Path path)
Definition journal.py:233