fix(storage): make save and export crash-safe (fixes #282)#296
fix(storage): make save and export crash-safe (fixes #282)#296sshekhar563 wants to merge 6 commits into
Conversation
Replace delete-before-move with backup-then-swap pattern. Adds: - _replace_with_backup(): atomic swap with rollback on failure - write_bytes_atomic(): temp file + fsync + atomic replace for exports - _fsync_directory(): platform-aware dir fsync (no-op on Windows) - Regression tests for failed replacements and temp cleanup
prakashUXtech
left a comment
There was a problem hiding this comment.
Strong work on the mechanism. You create the temp in the target's parent so os.replace always runs on the same filesystem (that's the atomic-write bug most people miss, and you got it right), you fsync the files and the directory before the swap, and moving the old directory to .bak first is the correct way to make the directory case work on Windows. For an in-process failure like a full disk, the original is correctly restored, and I verified that path.
Two things need resolving before this is crash-safe in the way the title promises.
1. The power-loss case still loses the soul. _replace_with_backup swaps in two steps: move the target to .bak, then move the new copy into place. Between those two steps the canonical path doesn't exist. If the process dies there, the good data is sitting at <path>.bak, but no loader knows to look there, so the soul reads as "not found." Worse, the next save sees the target missing, hits the if backup.exists(): remove branch and deletes the .bak, and writes no new backup because the target wasn't there to back up, so the one good copy is gone. Could you add load-time recovery: when the canonical path is missing and a sibling .bak exists, promote the .bak? And avoid deleting a .bak when the target is absent. A test that sets up target-absent plus .bak-present and asserts the soul still loads would pin this down.
2. save_soul_flat now replaces the whole directory, which silently changes a documented contract. The old flat save merged per-item specifically to preserve other files in the folder (it's in the wiki as intentional). Since save_local points at a user-supplied path, the wholesale replace can delete unmanaged files a user keeps alongside their soul (notes, a .git/, an earlier keys/ or trust_chain/ the current save doesn't regenerate). Either keep the preserve-existing-files behavior, or make the wholesale replace deliberate with a test and a wiki update.
Two smaller, non-blocking notes. Every successful export() now leaves a .soul.bak next to the archive, which doubles disk for large souls and can leave a previous soul's full memory sitting next to a file you meant to share, so consider scrubbing it after a successful export. And for the file case specifically, os.replace already overwrites atomically in one step, so moving to .bak first is actually less atomic than a plain replace would be; hardlinking or copying to .bak before the replace keeps a backup with zero window (the move-first is only needed for the directory case).
Also add the Updated: … (#282) header lines and update the atomic-writes wiki page, since the mechanism it describes has changed.
This takes the issue from "a crash destroys the only copy" to "a crash needs manual recovery," which is a real improvement. Closing the load-time recovery gap gets it the rest of the way.
…review) Address both blocking review items from prakashUXtech: 1. Power-loss recovery: Added _recover_backup() that auto-promotes a sibling .bak when the canonical path is missing. Called at load_soul_full() time so a crash between the target->.bak and source->target renames is automatically recovered on next load. Also: _replace_with_backup() no longer deletes .bak when the target is absent (the .bak may be the only surviving copy). 2. save_soul_flat() now merges per-file into the existing directory instead of doing a wholesale directory replace. This preserves unmanaged user files (NOTES.md, .git/, old keys/, etc.) that the previous approach would have deleted. Non-blocking: export() cleans up .bak after successful write to avoid doubling disk usage. Added Updated: header to file.py. New tests: - test_load_soul_full_recovers_from_bak (power-loss scenario) - test_replace_with_backup_does_not_delete_orphan_bak - test_save_soul_flat_preserves_user_files
…at save test (qbtrix#282) 1. Revert .bak cleanup after export — existing tests (test_cleanup_forget_safety, test_export_overwrite_keeps_previous_file_backup) depend on .bak persisting. This was a non-blocking suggestion anyway. 2. Update test_save_soul_flat failure test for per-file merge: the old test monkeypatched the wholesale directory replace. The new test monkeypatches a per-file os.replace on soul.json and verifies user files survive the partial failure.
Address 3 issues found during Codex verification:
P1: Soul.awaken() now calls _recover_backup() BEFORE the path existence
check, so power-loss recovery actually runs instead of raising
SoulFileNotFoundError.
P1: save_soul_flat() now cleans up stale managed files (memory/_layout.json,
old nested directories) after merging new files. Prevents loader from
reading stale nested data when layout changes to flat.
P2: _recover_backup() is now race-safe: if two loaders race and the second
gets FileNotFoundError from os.replace (because the first already
promoted the .bak), it checks if the canonical path now exists and
returns True instead of crashing.
New tests:
- test_save_soul_flat_removes_stale_layout_json
- test_recover_backup_race_safe
prakashUXtech
left a comment
There was a problem hiding this comment.
This is a real step up from the last round. The blocker is genuinely fixed now, end to end. Soul.awaken and load_soul_full both call _recover_backup() before the not-found check, so a soul left in a .bak by a power cut actually comes back, and there's a real test that sets up target-absent plus .bak-present and asserts the soul loads and the canonical path is restored. That was the exact test missing last time. The "delete the only copy" footgun is gated away too. My other concern is resolved as well: save_soul_flat now merges per file and scopes cleanup to memory, trust_chain, and keys, so a user's unmanaged files (a NOTES.md, a .git/) survive. Nicely done.
A few things remain, mostly polish.
-
The single-file write path is less atomic than it needs to be.
write_bytes_atomic(and thereforeexport) routes through the move-old-to-.bak-first swap, which creates the exact absent-path window that_recover_backupthen has to patch. For one file,os.replace(tmp, target)after fsync is atomic on its own: the path always resolves to old or new, never missing, no recovery needed. Since crash-safety is the whole point, the file path should be the plain temp + fsync + replace, and if you still want a previous-version backup, make it by copy before the replace rather than by moving the original aside. The directory path genuinely needs the backup dance (you can't swap a directory's contents in one syscall), so keep that as is. -
One new edge case: the
keys/cleanup can deletekeys/private.key. If someone runssave_local(include_keys=False)after a prior save that included keys, the currentmemory_data["keys"]has noprivate.key, so cleanup prunes it. The old per-item merge left it alone. Could you excludekeys/private.keyfrom pruning, or confirm the scrub is intended? -
Two of the new tests don't exercise what their names claim.
test_recover_backup_race_safedefines asimulate_racehelper but never uses it, so theFileNotFoundErrorbranch (the actual race-safety code) is never hit. Andtest_replace_with_backup_does_not_delete_orphan_bakonly checks the target has new data, which passes even with the old buggy unconditional delete, so it doesn't guard the regression. The code behind both is correct; the tests just need to trigger the failure path (monkeypatchos.replaceto raise, then assert).
Non-blocking: every successful export() leaves a .soul.bak next to the archive, so for a file you mean to share, the previous soul's full memory sits beside it. The issue did suggest keeping a backup, so it's a judgment call, but for export specifically I'd lean toward defaulting keep_backup=False or scrubbing after. And load_soul / FileStorage.load don't route through recovery, so a short note that awaken and load_soul_full are the crash-safe entry points would help.
The P1 win is the real story here, and it's solid. This is the last mile.
…sts, export backup 1. write_bytes_atomic: use copy+os.replace instead of move-to-bak dance. The path never goes absent now (no recovery window for single files). 2. Exclude keys/private_key from flat-save pruning so include_keys=False doesn't accidentally delete the user's signing key. 3. Fix test_recover_backup_race_safe: monkeypatch os.replace to raise FileNotFoundError, actually hitting the race-safety branch. 4. Fix test_replace_with_backup_does_not_delete_orphan_bak: fail the source->target replace and verify .bak survives. 5. Export defaults to keep_backup=False — .bak next to shared archives would leak the previous soul's full memory. 6. Added crash-safety note to load_soul() docstring. 7. New test: test_save_soul_flat_preserves_private_key.
prakashUXtech
left a comment
There was a problem hiding this comment.
Two of the three are genuinely fixed. The single-file write path is now a plain os.replace after fsync with a copy-based backup, so the canonical file is never missing, and the move-first dance is correctly confined to the directory case. Export passes keep_backup=False so it no longer litters a .soul.bak. And the two previously-vacuous tests now actually monkeypatch os.replace to raise and assert the right thing. Good iteration.
The one that still blocks is the private-key guard, and it's a real data-loss path that currently looks fixed but isn't. The prune-protected set names keys/private_key and keys/private.pem, but the actual on-disk file is keys/private.key with a dot (it's PRIVATE_KEY_FILENAME in crypto/keystore.py:36, and your own file header says keys/private.key). Since Path("keys/private.key") isn't in that set, save_local(include_keys=False) after a prior key-bearing save still unlinks the private signing key, which is irreversible: the soul becomes verification-only. And test_save_soul_flat_preserves_private_key creates keys/private_key (the wrong name), so it passes green while the real file stays unprotected, the same false-green shape as the two tests you just fixed.
The fix is small: import PRIVATE_KEY_FILENAME from crypto.keystore and protect Path("keys") / "private.key" (the private_key and private.pem entries are dead code, nothing writes them), and retarget the test to the real filename so it fails until the guard is right.
Non-blocking notes: FileStorage.load still doesn't route through recovery, but FileStorage.save writes in place and never makes a .bak, so that path is simply outside this PR's crash-safety scope, worth one line saying so. And there's a redundant local import shutil at file.py:182 that's already imported at the top.
Description
Fixes #282 — Soul save and export are not crash-safe; a crash can destroy the only copy.
The Bug
save_soul_full()andsave_soul_flat()usedshutil.rmtree()/unlink()to delete the existing soul directory before moving new data into place. A crash between the delete and the move destroys the only readable copy.Soul.export()usedPath.write_bytes()directly, which truncates the file on open — a mid-write crash leaves a corrupt, partial archive.The Fix
save_soul_full()rmtree→move_replace_with_backupsave_soul_flat()delete→moveSoul.export()Path.write_bytes()write_bytes_atomic()(temp + fsync + swap)New crash-safe helpers in
storage/file.py:_replace_with_backup(source, target)— moves existing target to.bak, swaps source into place, restores.bakif swap failswrite_bytes_atomic(path, data)— writes to temp file,fsync, atomic replace via_replace_with_backup_fsync_directory(path)— platform-aware dir fsync (no-op on Windows whereos.open()on dirs raisesPermissionError)_fsync_tree(path)— fsyncs all files and directories under a generated soul directoryTests Added