fix: make the restore journal and component renames power-loss durable - #133
Conversation
The restore transaction is journaled well enough to recover from a process that dies between two system calls, but `os.replace()` alone does not put a directory entry on stable storage. A machine or power loss could therefore leave the journal and the component renames it describes in different transaction phases after reboot -- a phase recorded whose rename was lost, or a rename that survived while the phase recording it did not. Restore now uses the rename-durability convention `backend/fs_durability.py` introduced, rather than a second fsync abstraction: - journal: flush the contents, replace the journal, sync the journal directory, and only then treat the phase as persisted; - component: persist "rename starting", rename, sync every directory the rename changed, and only then persist "rename completed". Every restore rename crosses directories (live storage <-> the rollback tree, the staging tree -> live storage), so `fsync_directories()` collects the source and destination parents of a batch of moves, collapses repeats and answers once for the whole set. `fsync_open_file()` gives a temporary this process is still holding the same barrier `fsync_file_path()` gives one it is not -- which on macOS is F_FULLFSYNC, not `os.fsync()`. Both are additions to the existing module; restore has no ad-hoc `os.fsync()` calls left. The three ways a boundary can be missed stay distinct, and none of them is reported as a persisted phase: - `restore_dir_not_durable` -- a directory the next rename needs could not be made durable; nothing has been moved; - `rename_not_durable` -- the rename happened but its directory entries are not confirmed, so the journal does not claim the move completed and the transaction rolls back; - `journal_not_durable` -- the phase itself is not on stable storage, so nothing proceeds as though recovery could read it. A directory fsync the platform does not implement is none of those: `fsync_directory()` reports it as durable because nothing stronger exists to ask for, and Windows, which has no directory handle to flush, is unaffected. Recovery stays fail-safe. When rollback's own moves are not durable, the journal and the rollback tree are kept instead of cleaned away, so the next startup replays them -- replay is non-destructive -- rather than removing the last known-good copy while a crash could still undo the move that restored it. Tests: `tests/test_restore_durability.py` spies on the calls and pins the ordering, the same-directory and cross-directory cases, the refusal paths and the recovery state they leave behind; `tests/test_backup_restore.py` adds the whole-transaction case. The existing process-crash injection tests are unchanged and still pass. Fixes #110 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014SNZ6ukWrT4M4tUFDB44mV
|
Navigate logical layers of code changes, visualize relationships, and explore their blast radius. No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (3)
Included review availability: Your plan provides up to 10 included reviews per hour; 9 remain after this review. 📝 WalkthroughWalkthroughThe restore transaction now enforces filesystem durability for staged payloads, journal writes, directory creation, component renames, rollback, and recovery. Tests cover ordering, refusal paths, cross-directory moves, containment, and replay behavior. ChangesRestore durability
Priority: ⬆️ High Estimated code review effort: 4 (Complex) | ~45 minutes Change: Bug fix · Severity of issue fixed: High Suggested reviewers: Sequence Diagram(s)sequenceDiagram
participant RestoreTransaction
participant fs_durability
participant Filesystem
participant Recovery
RestoreTransaction->>fs_durability: sync journal or component boundaries
fs_durability->>Filesystem: fsync files and affected directories
Filesystem-->>RestoreTransaction: success or durability refusal
RestoreTransaction->>Recovery: preserve replay state on refusal
Recovery->>Filesystem: retry rollback operations
Merge Risk: ⚪ Minimal · up to Restore durability and recovery behavior is covered by the reported implementation changes and focused tests, with no current merge-blocking risk identified. 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 2📝 Generate docstrings 💡
🛠️ Fix failing CI checks 💡
🧪 Generate unit tests (beta)
Comment |
PR Summary by QodoMake restore renames durable across power loss
AI Description
Diagram
High-Level Assessment
Files changed (5)
|
|
…aries
CodeQL (path injection), Qodo (two high) and Greptile (P1) each found a real
hole in the first pass. All four are the same shape: a boundary that was
claimed but not established.
Containment for every directory sync. `_replace_durably()` derived the
directories to fsync with `dirname()` of the move paths, and those trace back
to the request's staging token and the journal's transaction id, so the new
`os.open()` carried user-provided data. Every directory sync in this module
now goes through `_fsync_dirs_under(root, paths)`, which resolves each
candidate and proves it is the root or inside it, inline on the value that is
opened -- the containment pattern `work_pdf_replace` already uses for its
`os.replace` sink. A crafted token or a damaged journal can no longer point a
restore fsync outside the tree it belongs to, and a candidate that falls
outside is a durability failure rather than an open.
Staged payload contents. The extraction wrote each payload file with ordinary
buffered writes, so installing it renamed a durable name onto contents that
had never been flushed -- half (1) of the convention, missing exactly where
this change claims to follow it. `_verify_backup_inner()` now flushes each
extracted file while its handle is open, and syncs every directory the
extraction wrote into plus each one up to the extraction root: renaming
`tree/files/pdfs` makes that entry durable, not the entries inside it. Both
are hard requirements at staging time, which is the safe place to fail
(`staging_not_durable`, nothing installed).
The commit record. `_mark("committed")` replaced the journal and then raised
on a refused directory sync, so rollback started while a surviving `committed`
journal could describe a half-rolled-back library. The commit transition now
reports instead of raising: it does not roll back, keeps the journal and the
rollback tree, warns, and lets the next start resolve whichever phase actually
survived. Every other journal write still raises, because there the next step
is a rename whose recovery depends on the phase being readable.
The consumed-rollback replay. The partially-applied branch reported durable
after handling sidecars alone, so recovery could remove the journal and the
rollback tree while the earlier pass's live entry was unconfirmed -- and that
replay is the last chance to confirm it. It now re-syncs both ends of the move
that pass already made, skipping a rollback parent that is already gone.
Tests: 8 new (32 in `tests/test_restore_durability.py`, 102 in
`tests/test_backup_restore.py`) covering containment, staged-payload flushing
and its refusal, the commit record, and the replay re-sync.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014SNZ6ukWrT4M4tUFDB44mV
…eQL accepts The containment guard added for the path-injection alert used `normpath(realpath(path))` with one compound condition. `work_pdf_replace` already carries the shape CodeQL recognizes here, and says why: rebuild the path against the trusted base with join+normpath, then a standalone `startswith(base)` immediately before the sink, plus a second standalone check for a sibling whose name merely extends the base -- so what reaches the sink is the value that was proven rather than a helper return. `_fsync_dirs_under()` now follows that same pattern. The containment semantics are unchanged: outside the root still answers False and is never opened, and the four `TestSyncContainment` cases (inside, the root itself, outside, the extending sibling) still pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014SNZ6ukWrT4M4tUFDB44mV
|
CI status on
It never analyzed the diff — it failed before producing any finding — and its own
Rather than wait on an ambiguous check, Everything else is green on Generated by Claude Code |
…tually is Alert #143 is not on the directory fsync this branch added. Running the query locally (`codeql/python-queries` Security/CWE-022/PathInjection.ql) against this branch and against master shows what it is: master backend/backup_restore.py:2484 os.replace(staged, live) branch backend/backup_restore.py:2371 os.replace(src, dest) The same flow, and the same single sink -- master carries it too, in `_install_new_component()`. Pulling the rename into `_replace_durably()` moved the line, and a relocated alert counts as new on a pull request, which is why the check went red. The staged side is built from a request's staging token, so the flow is real either way. `_replace_durably()` now sanitizes both ends at the sink, the way `services/work_pdf_replace.atomic_replace_managed_pdf_bytes()` documents for this exact sink: rebuild each path against the trusted root with join+normpath, prove it is inside with a standalone startswith before any rename, and re-check immediately before `os.replace` so what the sink receives is the value that was proven. `abspath`, not `realpath`: resolving the last component would rename what a symlink points at instead of the symlink, and symlink containment is the verified-subroot helpers' job. A move that reaches outside the root is refused before anything is renamed, so it raises `restore_dir_not_durable` -- the "nothing has been moved" reason -- rather than reporting a rename that never happened as non-durable. Locally the query now finds 26 sinks on this branch against master's 27: the new alert is gone and master's own `os.replace` alert is gone with it. The five remaining in this file are pre-existing on master, untouched here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014SNZ6ukWrT4M4tUFDB44mV
Fooftilly
left a comment
There was a problem hiding this comment.
Two merge-blocking durability/compatibility issues remain at the current head.
…tainment Two review findings at the current head, both reproduced by a test that fails without the fix. A batch of renames left earlier moves with no barrier. `_replace_durably()` applied every `os.replace()` and only then synced. A database and its WAL sidecars move as one batch, so a sidecar rename that raises left the completed database rename on disk with nothing behind it while the error unwound past the helper. The syncs now run in `finally`, so whatever leaves that loop -- the last move, an early refusal, or a rename that raised partway -- the entries already written get their barrier before anything else happens, and the original error still propagates. The successful path costs the same one pass. The containment root rejected a legitimate move under a symlinked `PRKS_STORAGE`. `os.path.abspath(root)` kept the link in the root while `_maintenance_subroot()` anchors the rollback tree to `realpath(config.root)`, so with `/mnt/prks-link -> /srv/prks` the live side and the rollback side were in different namespaces and `live -> rollback` looked like an escape. Both sides are now canonicalized through their *parent* -- never the last component, because resolving the leaf would rename what a symlink points at instead of the symlink -- and compared against the resolved root. The `normpath(join(root, ...))` + standalone `startswith` shape the sink needs is unchanged; the local CodeQL run still reports 26 sinks against master's 27. Tests: `test_a_move_that_fails_partway_still_syncs_what_already_moved` (both parents of the completed move are synced, original error propagates, the moved copy is where rollback will look for it), `test_a_symlinked_storage_root_is_ not_mistaken_for_an_escape`, plus two whole-transaction cases -- a failing sidecar rename leaving the previous database and its sidecar in place, and a full restore through a symlinked storage root. All four fail without the fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014SNZ6ukWrT4M4tUFDB44mV
…lity-rtmdqv fix: publish managed PDF bytes under the platform durability barrier (F_FULLFSYNC) Completes the durability contract started by #129 and extended by #133: both canonical managed-PDF write paths now flush contents through fsync_open_file() instead of a bare os.fsync(). Fixes #134 (EF-031).
EF-031 / #134. PRKS has one durability convention, in `backend/fs_durability.py`: file contents are flushed with the strongest barrier the platform offers before a durable name can point at them, and the directory entry is flushed after the rename that created it. #129 established it and #133 added `fsync_open_file()` for a file this process still holds open. The two paths that publish *canonical* managed-PDF bytes were only half converted. Their directory sync already went through the module, but their content sync was still a bare `os.fsync(fp.fileno())`. On macOS that is not a durability barrier -- it returns once the write reaches the drive, without waiting for the drive's own cache -- so a save or upload could report success over bytes that never reached stable storage. Linearization, which flushes through `fsync_file_path`, was the only step applying the strong barrier, and it is optional (`PRKS_PDF_LINEARIZE=0`, no `qpdf`, a rewrite it declines): an optimization must never be what makes the first write durable. Both sites now call `fsync_open_file()`. The ordering and the fail-closed semantics are unchanged and now rest on the shared primitive: the temporary is durable before `os.replace()` publishes it, and a refused sync removes the temporary and propagates, leaving the previous canonical PDF in place; the exclusive create is durable before its name is returned, and a refused sync removes the partial file and raises `ManagedPdfStoreError`. No platform logic is duplicated here -- `F_FULLFSYNC` stays owned by `fs_durability`. The two halves of the convention stay distinct rather than converging on one state machine. Content durability is fatal. Directory durability is best-effort, because the operation does not exist everywhere and the rename has already happened by then, so `fsync_managed_pdf_parent()`'s boolean is now recorded in a metadata-only log line instead of being dropped. Tests: tests/test_managed_pdf_write_durability.py pins the call order (`fsync_open_file` strictly before `os.replace` / before success), the fail-closed behaviour and its cleanup, that `F_FULLFSYNC` is what the content sync reaches for when the platform has one, that the plain `os.fsync` fallback still runs where it does not, and -- statically -- that no bare `os.fsync` or platform branch comes back to this module. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QLLQeu89khtoaZtom35buk
Fixes #110 (EF-017).
What was wrong
The restore transaction is journaled well enough to recover from a process that dies between two system calls — the
RestoreCrashinjection tests prove that — butos.replace()alone does not put a directory entry on stable storage._atomic_write_json()fsynced the journal's temporary and then replaced the journal name without syncing the journal directory;_move_old_component(),_install_new_component()and_restore_rollback_component()renamed canonical, rollback and staged paths without syncing either affected parent.So the recovery state machine's assumption — that a persisted phase and the rename it describes are consistent after a restart — held for a process crash but not for a machine/power/filesystem crash, which can lose an unsynced directory entry and leave the journal and the component in different phases.
Verified against current
master(664cb8e) before changing anything: both gaps are still present there.The ordering, and how it reuses #129
No second fsync abstraction.
backend/fs_durability.py— the convention #129 introduced for rename-based replacement (sync the contents before the replace, sync the directory after it; content durability raises, directory durability is best-effort and returns whether the platform could establish it) — is generalized with two additions:fsync_directories(*paths)— every restore rename crosses directories (live storage ↔ the rollback tree, the staging tree → live storage), and a rename changes an entry at both ends. This collects the source and destination parents of a batch of moves, collapses repeats so a database and its WAL sidecars cost one sync per directory, attempts every directory even after one refuses, and answers once for the whole set.fsync_open_file(fd)— thefsync_file_path()guarantee for a file this process is still holding. On macOS that isF_FULLFSYNC, notos.fsync().backend/backup_restore.pynow has no ad-hocos.fsync()calls left.Restore then follows three orderings:
Journal —
fsync contents→replace journal→fsync journal directory→ phase persisted._write_journal()raises rather than returning when the directory sync is refused: the step that follows is a rename whose correct recovery depends on the phase being readable.Component —
persist "rename starting"→rename→fsync every directory the rename changed→persist "rename completed". This covers live → rollback, staged → live, and rollback → live (rollback runs the same rename in the opposite direction, so it owns the same boundary). A fresh rollback directory also has its own entry synced before anything is moved into it. A batch that fails partway — a database whose sidecar rename raises — still syncs what already moved before the error unwinds, so no completed rename is ever left with no barrier behind it.Staged payload — each extracted file is flushed while its handle is open, and every directory the extraction wrote into is synced up to the extraction root, before anything can rename it onto a canonical name. Renaming
tree/files/pdfsmakes that entry durable, not the entries inside it.The four outcomes, kept distinct
restore_dir_not_durablerename_not_durablejournal_not_durablestaging_not_durablefsync_directory()reports it durable because nothing stronger exists to ask forNo phase is ever recorded as persisted when its boundary was not established.
Containment
Both filesystem sinks this PR touches prove containment inline, on the value that reaches them, in the pattern
services/work_pdf_replace.atomic_replace_managed_pdf_bytes()documents — the paths are built from a request's staging token and a journal's transaction id, so a crafted token or a damaged journal must not be able to point a restore rename or fsync outside the tree it belongs to:_replace_durably()canonicalizes both ends of every move through their parent, rebuilds them against the resolved root withjoin+normpath, proves each is inside with a standalonestartswithbefore any rename, and re-checks immediately beforeos.replace. A move reaching outside is refused before anything is renamed, so it raises the "nothing has been moved" reason._fsync_dirs_under(root, paths)is the one place this module reaches the directory convention, and applies the same rebuild-and-prove to every directory it syncs.The parent is resolved but never the last component: resolving the leaf would rename what a symlink points at instead of the symlink. The parent must be resolved, because
PRKS_STORAGEmay itself be a symlink while the maintenance tree anchors to_resolved_storage_root()— a live path under the link and a rollback path under the target have to land in one comparable namespace.Portability
Unchanged behaviour on Windows:
fsync_directory()returns True on non-POSIX because there is no directory handle to flush, sofsync_directories()and_mkdir_owner_durable()always succeed there and restore is not made unusable.Fail-safe recovery
A durability failure never deletes the last known-good copy:
live → rollbackleaves the previous component in the rollback tree, which is exactly what rollback needs to put it back.staged → livestill hasnew_install_startedpersisted, so rollback removes the installed copy and restores the previous one._rollback_from_journal()reports it and bothapply_restore()andrecover_incomplete_restore()keep the journal and the rollback tree instead of cleaning them away. The next startup replays them — replay is already non-destructive — and that replay re-syncs both ends of the move the refused pass already made, because cleanup follows it.committedjournal describing a half-rolled-back library. Restore instead keeps the journal and the rollback tree, warns, and lets the next start resolve whichever phase survived —keep_restoredif the entry is there,restored_previousif it is not.Scope
_atomic_write_json()has two users. The journal treats the write as a boundary and refuses to advance; staging metadata does not (losing it costs a re-upload, not a library) and logs the weaker guarantee instead of failing a verified backup. No unrelated filesystem cleanup was broadened.pdf_linearizeandwork_pdf_replaceonly gained shared helpers alongside the ones they already use. No [Audit Finding] Make post-delete cleanup failures recoverable #91 delete-cleanup work here.Review rounds
Bots, on the first commit — four findings, all verified and fixed in
b321362(detail in the resolved threads): Greptile's P1 unsynced staged payloads (→ the staged-payload ordering above), Qodo's two high findings on the commit record and the consumed-rollback replay (→ the two fail-safe rules above), and the CodeQL path-injection alert.CodeQL took a third commit,
386a738, because my first two attempts aimed at the wrong sink. Running the query locally (codeql/python-queriesSecurity/CWE-022/PathInjection.ql, CLI 2.27.0) against this branch and againstmastershowed what it actually is:Same flow, same single sink —
mastercarries this alert too, in_install_new_component(). Pulling the rename into the shared_replace_durably()moved the line, and a relocated alert counts as new on a PR. The flow is real either way, so386a738sanitizes it at the sink. The query now reports 26 sinks on this branch against master's 27: the new alert is gone and master's own goes with it. The five that remain in this file (isfile(meta_path),open(meta_path),isdir(staged),lexists(staged),isfile(staged_db)) are pre-existing on master, outside what #110 asked for, and untouched here.Maintainer review — two merge-blocking findings, both reproduced and fixed in
4fbf860:finally;abspath, which broke a legitimatelive → rollbackmove whenPRKS_STORAGEis a symlink — a regression386a738introduced. Containment is now canonical on both sides, as described above.Each of the four new tests for these fails without the fix; the symlink one reproduces the reported
restore_dir_not_durable / move_containmentrefusal exactly.Tests
tests/test_restore_durability.py(35 tests) spies on the calls rather than pretending CI can stage a power loss:fsync-file → replace → fsync-dir, same-directory replace costs exactly one sync, phase refused on a failed directory sync, journal not replaced when the contents cannot be synced, unsupported errno still counts as persisted whileEIOdoes not, staging metadata reports the weaker guarantee without raising;old_moved/new_installedunset with the material still recoverable, and the rollback directory refused before anything moves into it;fs_durabilityadditions: dedupe, both-parent ordering, every-directory-attempted, unsupported vs. failed, andfsync_open_file()success/raise.tests/test_backup_restore.py(104 tests) adds the whole-transaction cases: a refused directory sync during a realapply_restore()stops it and the nextrecover_incomplete_restore()returns the previous library intact; an unconfirmed commit record does not roll back a finished restore; a failing sidecar rename leaves the previous database and its sidecar byte-for-byte in place; a full restore runs through a symlinked storage root; every staged payload file's inode is flushed during staging; and a staged tree whose directory entries cannot be synced fails staging without touching the live library.Commands actually run
python3.12 run_tests.py— full unit/API/Node/static suite, 2290 tests, OK; dependency preflight OKpython3.12 -m unittest tests.test_backup_restore— 104 tests, OK (existingRestoreCrashinjection tests unchanged and passing)python3.12 -m unittest tests.test_restore_durability— 35 tests, OKcodeql database analyze … Security/CWE-022/PathInjection.ql(CLI 2.27.0) on this branch and onmaster— 26 sinks vs. 27, re-run after each change to the sinkruff check .— clean on every touched file (6 pre-existingB023errors in untouched files, identical before and after this branch)pyright— 0 errors, 0 warningspython3.12 tests/e2e/run.py --jobs 4 --no-pointer-capture --affected— selected smoke (9 tests), PASS. The full E2E gate was not run: this is backend-only with no UI or API contract change.The one red check
github-advanced-securityis GitHub's own Copilot code-scanning reviewer failing at session creation withCAPIError: 400 The requested model is not supported— it never analyzed the diff, its file-exclusion list excludes*.py, and it has failed identically on every head of this PR. Nothing in this PR can change it; see the comment for the log.🤖 Generated with Claude Code
https://claude.ai/code/session_014SNZ6ukWrT4M4tUFDB44mV
Summary by CodeRabbit