fix(io): write graph.json and manifest.json atomically

graph.json (the clustered `to_json` write and the `--no-cluster`/merge raw
dumps) and manifest.json were written with a direct `open()`/`write_text`, so a
crash, kill, or disk-full mid-write left a truncated, unparseable file that the
next load or `detect_incremental` then failed on.

Add `write_text_atomic`/`write_json_atomic` in graphify.paths (temp file in the
same directory + `os.replace`; JSON is streamed into the temp, not materialized
as one string) and route the graph.json writers (export.to_json,
cli._prune_graph_json_sources, the merge driver) plus detect.save_manifest
through them. The helper preserves the destination's mode (an atomic replace
never tightens 0644 to mkstemp's 0600), writes through a symlinked destination
(shared-output setups), and falls back to copy-then-delete on a Windows
os.replace lock — matching graphify.cache's existing atomic writer. On failure
the previous file is left intact and the temp removed. Not a power-loss
durability guarantee (no fsync, consistent with the rest of the codebase).
This commit is contained in:
tpateeq authored and safishamsi committed 2026-07-16 23:42:47 +01:00
1 parent 7d53a02704
commit f38e98012d
5 files changed
+181 -6

No files matched your search

+3 -2
View File
@@ -315,8 +315,9 @@ def to_json(G: nx.Graph, communities: dict[int, list[str]], output_path: str, *,
commit = built_at_commit if built_at_commit is not None else _git_head()
if commit:
data["built_at_commit"] = commit
with open(output_path, "w", encoding="utf-8") as f: # nosec
json.dump(data, f, indent=2)
from graphify.paths import write_json_atomic
# Atomic write: a crash/ENOSPC mid-write must not truncate a good graph.json.
write_json_atomic(output_path, data, indent=2)
return True