Source code for rath.persistence.atomic
"""Atomic file writes shared across the persistence planes.
Writing config, registry, and memory sidecar files with a bare
``path.write_text(...)`` has two problems:
1. **Not atomic** — a crash or exception mid-write leaves a truncated file
that later fails to parse.
2. **Not concurrency-safe on Windows** — even the temp-file + ``os.replace``
idiom raises :class:`PermissionError` when a second thread/process holds
the destination open during the replace, because Windows rejects a rename
onto an open file for a brief sharing window.
:func:`atomic_write_text` / :func:`atomic_write_json` solve both: they write
to a uniquely-named temp file in the target directory, ``os.replace`` it into
place (atomic on POSIX and Windows), serialize concurrent writers to the same
path through a process-global path-keyed lock, and retry the short Windows
sharing-violation window before giving up.
These are process-local guarantees. Cross-*process* exclusion for
append-style writers is a separate concern handled by
:class:`rath.session.persistence._lock.FileLock`.
"""
from __future__ import annotations
import json
import os
import sys
import tempfile
import threading
import time
from pathlib import Path
from typing import Any
__all__ = ["atomic_write_text", "atomic_write_json"]
# Process-global, path-keyed locks so two ConfigStore/registry instances that
# point at the same file cannot race their os.replace calls. Keyed by the
# resolved target path. The registry itself is guarded by ``_LOCKS_GUARD``.
_LOCKS: dict[Path, threading.Lock] = {}
_LOCKS_GUARD = threading.Lock()
# Windows only: os.replace onto a concurrently-held target raises
# PermissionError for a brief window. Retry a handful of times with a short
# backoff before surfacing the error.
_WIN_REPLACE_ATTEMPTS = 10
_WIN_REPLACE_BACKOFF_S = 0.02
def _lock_for(path: Path) -> threading.Lock:
with _LOCKS_GUARD:
lock = _LOCKS.get(path)
if lock is None:
lock = threading.Lock()
_LOCKS[path] = lock
return lock
def _replace_with_retry(src: Path, dst: Path) -> None:
"""``os.replace(src, dst)`` with a Windows sharing-violation retry loop."""
if not sys.platform.startswith("win"):
os.replace(src, dst)
return
last: OSError | None = None
for attempt in range(_WIN_REPLACE_ATTEMPTS):
try:
os.replace(src, dst)
return
except PermissionError as e: # target briefly held by another writer
last = e
time.sleep(_WIN_REPLACE_BACKOFF_S * (attempt + 1))
assert last is not None
raise last
[docs]
def atomic_write_text(
path: Path | str,
text: str,
*,
newline: bool = False,
encoding: str = "utf-8",
mode: int | None = None,
) -> None:
"""Atomically write ``text`` to ``path``.
Creates the parent directory if missing. Writes a uniquely-named temp
file in the same directory, then ``os.replace`` it into place. Concurrent
writers to the same resolved path are serialized; the Windows
sharing-violation window is retried. On any failure the temp file is
removed and the original target is left untouched.
``newline`` appends a trailing ``"\\n"`` when the caller has not already.
``mode`` (e.g. ``0o600``) restricts the final file on POSIX; ignored on
Windows (matching :func:`rath.config.secrets.chmod_user_only`).
"""
target = Path(path).resolve()
target.parent.mkdir(parents=True, exist_ok=True)
if newline and not text.endswith("\n"):
text = text + "\n"
lock = _lock_for(target)
with lock:
fd = tempfile.NamedTemporaryFile(
mode="w",
encoding=encoding,
dir=target.parent,
prefix=".atomic_",
suffix=".tmp",
delete=False,
)
tmp_path = Path(fd.name)
try:
fd.write(text)
fd.flush()
os.fsync(fd.fileno())
fd.close()
_replace_with_retry(tmp_path, target)
except BaseException:
try:
fd.close()
except Exception: # noqa: BLE001 -- already closing down
pass
tmp_path.unlink(missing_ok=True)
raise
if mode is not None and not sys.platform.startswith("win"):
try:
os.chmod(target, mode)
except OSError: # pragma: no cover -- racing fs / unsupported
pass
[docs]
def atomic_write_json(
path: Path | str,
payload: Any,
*,
indent: int | None = 2,
sort_keys: bool = False,
ensure_ascii: bool = False,
mode: int | None = None,
) -> None:
"""Atomically write ``payload`` as JSON to ``path``.
Serialization happens **before** the temp file is created, so an
unserializable payload raises without touching the filesystem (no temp
debris, original target intact). Otherwise defers to
:func:`atomic_write_text`.
"""
text = json.dumps(
payload, indent=indent, sort_keys=sort_keys, ensure_ascii=ensure_ascii
)
atomic_write_text(path, text, newline=True, mode=mode)