mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
[eric] merge #52 (tarun): fsync tempfile + parent dir in atomic_write_json for crash durability
This commit is contained in:
@@ -32,10 +32,19 @@ def atomic_write_json(path: str, payload, *, indent: int = 2) -> None:
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(payload, f, indent=indent)
|
||||
# os.replace is atomic for the filename, but the file's data
|
||||
# may still be sitting in the page cache when the rename
|
||||
# commits. A power loss between rename and the kernel's next
|
||||
# writeback can leave a zero-length or torn file even though
|
||||
# the rename "succeeded". fsync before the rename is what
|
||||
# actually makes this crash-safe.
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
# Windows: Defender can briefly hold the destination open; a couple of retries covers every real case.
|
||||
for attempt in range(3):
|
||||
try:
|
||||
os.replace(tmp, path)
|
||||
_fsync_dir(directory)
|
||||
return
|
||||
except PermissionError:
|
||||
if attempt == 2:
|
||||
@@ -49,6 +58,24 @@ def atomic_write_json(path: str, payload, *, indent: int = 2) -> None:
|
||||
raise
|
||||
|
||||
|
||||
def _fsync_dir(directory: str) -> None:
|
||||
# Best-effort fsync of the parent dir so the rename itself sticks
|
||||
# across a crash. POSIX needs this (ext4/xfs/btrfs); Windows doesn't
|
||||
# let you open a directory for fsync, so we just skip there. Failing
|
||||
# here is non-fatal: the file content is already fsync'd above and
|
||||
# the rename has happened in memory.
|
||||
try:
|
||||
dir_fd = os.open(directory, os.O_RDONLY)
|
||||
except OSError:
|
||||
return
|
||||
try:
|
||||
os.fsync(dir_fd)
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
os.close(dir_fd)
|
||||
|
||||
|
||||
def read_json_or_none(path: str) -> dict | None:
|
||||
"""Parse `path`; return None (and log) on a missing/garbled file rather than
|
||||
raising. Schema validation is the caller's job, kept separate so a real
|
||||
|
||||
@@ -49,6 +49,62 @@ def test_atomic_write_preserves_existing_when_new_write_fails(tmp_path):
|
||||
assert read_json_or_none(p) == {"good": 1}
|
||||
|
||||
|
||||
def test_atomic_write_fsyncs_file_before_rename(tmp_path, monkeypatch):
|
||||
# Without fsync before os.replace, we only get filename-atomicity, not
|
||||
# data durability. Catch the regression by counting fsync calls before
|
||||
# the rename happens.
|
||||
from backend.config import json_store
|
||||
|
||||
fsync_count_at_replace = []
|
||||
fsync_calls = []
|
||||
real_fsync = os.fsync
|
||||
real_replace = os.replace
|
||||
|
||||
def tracking_fsync(fd):
|
||||
fsync_calls.append(fd)
|
||||
return real_fsync(fd)
|
||||
|
||||
def tracking_replace(src, dst):
|
||||
fsync_count_at_replace.append(len(fsync_calls))
|
||||
return real_replace(src, dst)
|
||||
|
||||
monkeypatch.setattr(json_store.os, "fsync", tracking_fsync)
|
||||
monkeypatch.setattr(json_store.os, "replace", tracking_replace)
|
||||
atomic_write_json(str(tmp_path / "x.json"), {"k": "v"})
|
||||
|
||||
assert fsync_count_at_replace == [1], (
|
||||
f"expected fsync on the tempfile before os.replace, saw "
|
||||
f"{fsync_count_at_replace[0] if fsync_count_at_replace else 0} fsync calls"
|
||||
)
|
||||
|
||||
|
||||
def test_atomic_write_fsyncs_directory_after_rename(tmp_path, monkeypatch):
|
||||
# POSIX only: the rename is only durable once the parent dir is fsync'd.
|
||||
if not hasattr(os, "O_RDONLY"):
|
||||
pytest.skip("directory fsync not applicable on this platform")
|
||||
from backend.config import json_store
|
||||
|
||||
fsync_targets = []
|
||||
real_fsync = os.fsync
|
||||
|
||||
def tracking_fsync(fd):
|
||||
try:
|
||||
st = os.fstat(fd)
|
||||
fsync_targets.append("dir" if (st.st_mode & 0o170000) == 0o040000 else "file")
|
||||
except OSError:
|
||||
fsync_targets.append("unknown")
|
||||
return real_fsync(fd)
|
||||
|
||||
monkeypatch.setattr(json_store.os, "fsync", tracking_fsync)
|
||||
atomic_write_json(str(tmp_path / "x.json"), {"k": "v"})
|
||||
|
||||
assert "file" in fsync_targets, "expected fsync on the data file"
|
||||
assert "dir" in fsync_targets, "expected fsync on the parent directory"
|
||||
|
||||
|
||||
# ---------------- read_json_or_none ----------------
|
||||
|
||||
|
||||
# ---------------- read_json_or_none ----------------
|
||||
|
||||
def test_read_missing_returns_none(tmp_path):
|
||||
|
||||
Reference in New Issue
Block a user