orchestrata_ultimate.core.atomic_json
Reference › orchestrata_ultimate › core › atomic_json
Atomic file writes for JSON and text, used by the generator and gate scripts and by traceability_utils. A plain open(path, "w") truncates the target at once, so two processes writing the same path leave a splice of both documents. The module records a measured case: a 75 MB AST snapshot decoded as a complete object followed by trailing bytes of a previous generation, which crashed a gate run before any gate reported.
dump_json_atomic and dump_text_atomic both go through _write_atomic: write to a temporary file in the destination directory (same filesystem, so the final step is a rename), fsync it, then os.replace it over the target. Concurrent writers therefore produce a whole document with last-writer-wins, and a crashed writer leaves the previous file untouched. Parent directories are created, a new file gets mode 0644, and an existing target keeps its mode. Options include indent, default, sort_keys and trailing_newline.
At a glance
| Source | src/orchestrata_ultimate/core/atomic_json.py |
| Lines of code | 160 |
| Classes | 0 |
| Functions | 3 |
| Imports (internal) | none |
| Imported by | orchestrata_ultimate.core.traceability_utils, scripts.build_runtime_callgraph, scripts.derive_rtm, scripts.evaluate_semantic_graph_qa, scripts.extract_reference_edges, scripts.gen_codebase_map, scripts.gen_cxp, scripts.gen_intent_lexicon, scripts.gen_level0, scripts.generate_rtm_evidence, scripts.serialize_codebase_ast |
| Tests that import it | tests/core/test_atomic_json.py, tests/generated_core/test_atomic_json_generated.py, tests/scripts/test_gen_codebase_map.py |
Class diagram
No classes; see Functions.
Dependency diagram
API
Functions
| Function | Signature | Summary |
|---|---|---|
dump_json_atomic |
dump_json_atomic(path: str | os.PathLike[str], obj: Any, *, indent: int | None = 2, default: Callable[[Any], Any] | None = None, sort_keys: bool = False, trailing_newline: bool = False) -> None |
Serialise obj to path as JSON, atomically. |
dump_text_atomic |
dump_text_atomic(path: str | os.PathLike[str], text: str, *, trailing_newline: bool = False) -> None |
Write text to path atomically — the text sibling of the above. |
_write_atomic |
_write_atomic(path: str | os.PathLike[str], emit: Callable[[TextIO], None]) -> None |
Temp file in the destination directory, fsync, then os.replace. |
Source
"""Atomic JSON writes — a truncate-in-place write is a corruption waiting to land.
``open(path, "w")`` truncates the target the instant it is called and then
streams bytes into it. Two processes doing that to the same path do not
serialise: each truncates, each writes from offset 0, and the file that
survives is a SPLICE of both — the shorter writer's prefix followed by the
longer writer's tail. The splice is still a file, still readable, and
``json.loads`` reports it as ``Extra data: line N column M`` far from
anything a human wrote.
Measured, 2026-08-12: ``plans/ast-ultimate.json`` (75,563,054 bytes) decoded
to a complete object at byte 73,507,683 with 2,055,371 bytes of a previous
generation trailing it, and hard-crashed ``scripts/gates.py --all`` in
``_runtime_call_count`` before a single gate reported. Three call sites wrote
that path with a bare ``open(..., "w")``: ``serialize_codebase_ast._cmd_serialize``
and ``build_runtime_callgraph.{build,reaugment}``.
:func:`dump_json_atomic` writes to a temporary file in the SAME directory —
same filesystem, so :func:`os.replace` is a rename, not a copy — fsyncs it,
then atomically replaces the target. Concurrent writers then produce
last-writer-wins with a whole document either way, and a crashed writer
leaves the previous file untouched instead of a truncated stub.
"""
from __future__ import annotations
import json
import os
import tempfile
from collections.abc import Callable
from pathlib import Path
from typing import Any, TextIO
__all__ = ["dump_json_atomic", "dump_text_atomic"]
#: Mode applied to a newly created file. An existing target's mode is
#: preserved instead — ``tempfile.mkstemp`` creates 0600, which would
#: silently tighten permissions on every rewrite of a shared artifact.
_DEFAULT_MODE = 0o644
def dump_json_atomic(
path: str | os.PathLike[str],
obj: Any,
*,
indent: int | None = 2,
default: Callable[[Any], Any] | None = None,
sort_keys: bool = False,
trailing_newline: bool = False,
) -> None:
"""Serialise ``obj`` to ``path`` as JSON, atomically.
The target is either fully replaced by the new document or left exactly
as it was; no reader ever observes a partial or spliced file.
Args:
path: Destination file. Parent directories are created.
obj: Any ``json.dump``-serialisable object.
indent: Passed through to ``json.dump``.
default: Fallback serialiser, passed through to ``json.dump``.
sort_keys: Passed through to ``json.dump``.
trailing_newline: Append ``"\\n"`` after the document. Several
existing artifacts are written that way; keeping the flag makes
this a byte-for-byte drop-in for them.
Raises:
OSError: The temporary file could not be created, written, or
renamed. The destination is untouched in every such case.
TypeError: ``obj`` is not serialisable. Destination untouched.
"""
def _emit(handle: TextIO) -> None:
json.dump(obj, handle, indent=indent, default=default, sort_keys=sort_keys)
if trailing_newline:
handle.write("\n")
_write_atomic(path, _emit)
def dump_text_atomic(
path: str | os.PathLike[str],
text: str,
*,
trailing_newline: bool = False,
) -> None:
"""Write ``text`` to ``path`` atomically — the text sibling of the above.
Same guarantee, same reason. A generated ``.md`` is exactly as unsafe to
truncate in place as a generated ``.json``: ``docs/CODEBASE_MAP.md`` and
``docs/CODEBASE_MAP_FULL.md`` are regenerated on a SessionStart hook while
the tree they describe is being staged, and ``Path.write_text`` truncates
the destination IN PLACE, keeping the inode. A reader holding an mmap of
that inode — ``git update-index`` hashing the file to stage it — then
faults on pages past the new end and dies with ``SIGBUS``/``BUS_ADRERR``.
Measured 2026-08-19: two ``git update-index --add`` cores, 28 minutes
apart, one inside ``zng_deflate``. Hardware was excluded on four
independent counters (NVMe media errors 0, btrfs corruption/csum/IO
counters all 0, scrub clean over 344 GiB, SMART PASSED), which leaves a
file that shrank under a live mapping.
Replacing the target instead of truncating it also fixes the reader:
``os.replace`` gives the destination a NEW inode, so an mmap already open
on the old one keeps reading the complete previous file.
Args:
path: Destination file. Parent directories are created.
text: Full document to write.
trailing_newline: Append ``"\\n"`` unless ``text`` already ends with
one, so this is a byte-for-byte drop-in for existing writers.
Raises:
OSError: The temporary file could not be created, written, or
renamed. The destination is untouched in every such case.
"""
def _emit(handle: TextIO) -> None:
handle.write(text)
if trailing_newline and not text.endswith("\n"):
handle.write("\n")
_write_atomic(path, _emit)
def _write_atomic(path: str | os.PathLike[str], emit: Callable[[TextIO], None]) -> None:
"""Temp file in the destination directory, fsync, then ``os.replace``.
Extracted so the delicate half — preserve an existing target's mode,
create the temp on the SAME filesystem so the replace is a rename rather
than a copy, fsync before publishing, and never leave scratch behind —
exists once. It was already duplicated privately in four modules
(``session/context.py``, ``claude/tools/session/context.py``,
``secrets_cli.py``, ``serialize_codebase_ast.py``); a second copy here
would have been the fifth, and the fsync/chmod ordering is precisely the
sort of detail that drifts between copies unnoticed.
"""
target = Path(path)
target.parent.mkdir(parents=True, exist_ok=True)
try:
mode = target.stat().st_mode & 0o777
except FileNotFoundError:
mode = _DEFAULT_MODE
fd, tmp_name = tempfile.mkstemp(
dir=str(target.parent), prefix=f".{target.name}.", suffix=".tmp"
)
tmp_path = Path(tmp_name)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
emit(handle)
handle.flush()
os.fsync(handle.fileno())
os.chmod(tmp_path, mode)
os.replace(tmp_path, target)
except BaseException:
# Never leave the scratch file behind — including on KeyboardInterrupt
# / SystemExit, which is exactly when a 75MB stray hurts most.
tmp_path.unlink(missing_ok=True)
raise