7The per-run JSONL recovery journal (Plan 142m §4) — schema v1.
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.
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).
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.
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.
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).
49 L2 - journal open/close/publish/resolve/archive
50 L3 - per-line fold / append-boundary repair
55- oc_diagnostics (structured debug logging via _dbg)
57from __future__
import annotations
63from dataclasses
import dataclass, field
64from pathlib
import Path
65from typing
import Optional
67from oc_diagnostics
import _dbg
69_dbg(
"OC_CHAT.MAIN",
"module",
"loaded", 4)
71JOURNAL_SCHEMA_VERSION = 1
72EVENT_PLANNED_POSTIMAGE =
"planned_postimage"
73EVENT_APPLIED =
"applied"
74EVENT_RESOLVED =
"resolved"
75OUTCOME_SUCCESS =
"success"
76OUTCOME_ROLLED_BACK =
"rolled_back"
78_PARTIAL_SUFFIX =
".jsonl.partial"
79_PUBLISHED_SUFFIX =
".jsonl"
80_ARCHIVE_DIRNAME =
"archive"
84def _fsync(fileobj:
"object") ->
None:
85 """fsync a file object (best-effort seam; overridable in tests)."""
86 os.fsync(fileobj.fileno())
90 """Atomic ``os.replace`` seam (overridable in tests for ordering/failure)."""
95 """Best-effort directory fsync (unsupported on Windows — documented)."""
98 fd = os.open(str(path), os.O_RDONLY)
104 _dbg(
"OC_CHAT.MAIN", func,
"directory fsync unsupported (platform)", 3)
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"
116 """Write one JSON line, flush + fsync it (the per-line durability rule)."""
117 fileobj.write(
_dumps(obj))
123@dataclass(frozen=True)
125 """One WRITE-SET record's byte-for-byte preimage (§4)."""
128 preimage_bytes: bytes
129 file_mode: Optional[int]
139 "exists_before":
True,
140 "preimage_b64": base64.b64encode(self.
preimage_bytes).decode(
"ascii"),
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)
153def header_dict(target_schema_version: str, source_root: str, run_id: str) -> dict:
154 """The journal header line (line 1)."""
156 "journal_schema_version": JOURNAL_SCHEMA_VERSION,
157 "target_schema_version": target_schema_version,
158 "source_root": source_root,
165 preimages: list[PreimageRecord],
166 postimage_shas: list[str],
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"
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)
190 """A preimage entry folded with its event lines (the rollback consults these)."""
193 planned_postimage_sha: Optional[str] =
None
194 applied: bool =
False
198 return self.preimage[
"rel_path"]
201@dataclass(frozen=True)
203 """A folded view of one on-disk journal (the rollback + step-5 recovery basis)."""
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
214 """A published journal is UNRESOLVED iff corrupt OR its tail lacks a
215 terminal ``resolved`` event (fail-closed: corruption ⇒ unresolved)."""
220 return (self.
header or {}).get(
"source_root")
224 return (self.
header or {}).get(
"target_schema_version")
226 def entry_for(self, rel_path: str) -> Optional[FoldedEntry]:
228 if entry.rel_path == rel_path:
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"
240 raw = path.read_bytes()
241 text = raw.decode(
"utf-8", errors=
"replace")
244 ends_with_newline = text.endswith(
"\n")
245 segments = text.split(
"\n")
246 if ends_with_newline:
247 segments = segments[:-1]
249 complete = segments
if ends_with_newline
else segments[:-1]
250 torn_tail =
None if ends_with_newline
else segments[-1]
252 header: Optional[dict] =
None
253 entries: list[FoldedEntry] = []
255 outcome: Optional[str] =
None
257 by_path: dict[str, FoldedEntry] = {}
259 for index, line
in enumerate(complete):
263 obj = json.loads(line)
268 if isinstance(obj, dict)
and "journal_schema_version" in obj:
273 if not isinstance(obj, dict):
276 event = obj.get(
"event")
277 rel_path = str(obj.get(
"rel_path",
""))
278 if event == EVENT_RESOLVED:
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:
289 elif "preimage_b64" in obj:
291 entries.append(entry)
292 by_path[entry.rel_path] = entry
295 if torn_tail
not in (
None,
""):
297 json.loads(torn_tail)
299 _dbg(
"OC_CHAT.MAIN", func,
"torn final line — consistent partial", 3)
302 _dbg(
"OC_CHAT.MAIN", func, f
"folded {path.name}: resolved={resolved} corrupt={corrupt}", 2)
304 path=path, header=header, entries=entries, resolved=resolved,
305 outcome=outcome, corrupt=corrupt,
310 """Decode a folded entry's preimage bytes (base64)."""
311 return base64.b64decode(entry.preimage[
"preimage_b64"])
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():
324 for target_dir
in sorted(journal_root.iterdir()):
325 if not target_dir.is_dir():
327 for entry
in sorted(target_dir.iterdir()):
328 if not entry.is_file():
331 if not name.endswith(_PUBLISHED_SUFFIX)
or name.endswith(_PARTIAL_SUFFIX):
334 if parsed.unresolved:
335 unresolved.append(parsed)
336 _dbg(
"OC_CHAT.MAIN", func, f
"{len(unresolved)} unresolved journal(s)", 2)
342 """Builds, publishes, appends-to, resolves and archives one run's journal."""
344 def __init__(self, target_dir: Path, run_id: str, header: dict) ->
None:
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:
359 for preimage
in preimages:
361 _dbg(
"OC_CHAT.MAIN", func, f
"built partial ({len(preimages)} preimages)", 2)
364 """Atomically publish ``.partial`` → ``.jsonl`` (BEFORE the first source
365 write) + best-effort directory fsync."""
366 func =
"JournalWriter.publish"
370 _dbg(
"OC_CHAT.MAIN", func,
"journal published", 2)
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"
379 _dbg(
"OC_CHAT.MAIN", func,
"partial cleanup failed (harmless)", 1, override=
True)
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"
387 if raw == b
"" or raw.endswith(b
"\n"):
389 last_newline = raw.rfind(b
"\n")
390 keep = raw[: last_newline + 1]
if last_newline != -1
else b
""
395 _dbg(
"OC_CHAT.MAIN", func,
"truncated torn final line", 3)
398 """Append one event line (fsync'd) after repairing any torn tail."""
400 with open(self.
final_path,
"a", encoding=
"utf-8", newline=
"\n")
as fileobj:
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})
409 """Append the ``applied`` event (AFTER a successful replace)."""
410 self.
append_event({
"event": EVENT_APPLIED,
"rel_path": rel_path})
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)
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
426 archive_dir.mkdir(parents=
True, exist_ok=
True)
427 dest = archive_dir / f
"{self.run_id}{_PUBLISHED_SUFFIX}"
429 _dbg(
"OC_CHAT.MAIN", func,
"archived", 2)
432 _dbg(
"OC_CHAT.MAIN", func,
"archive move failed (harmless; resolved-in-place)", 2)
437 "JOURNAL_SCHEMA_VERSION",
438 "EVENT_PLANNED_POSTIMAGE",
442 "OUTCOME_ROLLED_BACK",
449 "project_journal_bytes",
None __init__(self, Path target_dir, str run_id, dict header)
None resolve(self, str outcome)
None build(self, list[PreimageRecord] preimages)
None _repair_torn_tail(self)
None append_event(self, dict event)
None planned_postimage(self, str rel_path, str postimage_sha256)
None cleanup_partial(self)
None applied(self, str rel_path)
Optional[FoldedEntry] entry_for(self, str rel_path)
Optional[str] source_root(self)
Optional[str] target_schema_version(self)
list[ParsedJournal] scan_unresolved(Path journal_root)
None _fsync_dir(Path path)
bytes preimage_bytes_of(FoldedEntry entry)
dict header_dict(str target_schema_version, str source_root, str run_id)
None _fsync("object" fileobj)
None _write_line("object" fileobj, dict obj)
PreimageRecord build_preimage(str rel_path, bytes raw_bytes, Optional[int] file_mode)
int project_journal_bytes(dict header, list[PreimageRecord] preimages, list[str] postimage_shas)
None _replace(Path src, Path dst)
ParsedJournal read_journal(Path path)