fix(snapshot): live Copy-mode backup; bound crash-child fixtures - #951
Conversation
Capture Grafeo crash images from a child that exits without a clean close, snapshot live SQLite families through the online backup API, and drop payload creator handles before size admission so Windows lock 33 cannot masquerade as recovery proof. Durability fixtures unset TRACEDECAY_SQLITE_UNSAFE_FAST rather than treating it as production durability. Refs #933. Co-authored-by: Zack Jackson <ScriptedAlchemy@users.noreply.github.com>
|
Satisfy clippy::doc_markdown on the live-backup and unsafe-fast comments. Co-authored-by: Zack Jackson <ScriptedAlchemy@users.noreply.github.com>
…sor/crash-snapshot-fixtures-933-fdd6
…sor/crash-snapshot-fixtures-933-fdd6
Performance Comparison
|
ScriptedAlchemy
left a comment
There was a problem hiding this comment.
Architecture review — HOLD for production snapshot and fixture-lifecycle gates
Reviewed 8518e088005fcada58f71fffeb9f8ba893b20f68; source review only, exact-head CI pending. Child reaches durable phase -> exits without clean close -> parent joins -> copy abandoned image is the correct crash fixture. Closing the oversized payload's creator before testing size admission is also correct.
P1: sqlite_read_snapshot.rs changes production Copy-mode semantics, not just tests. Keep this change visibly separate in the PR's claims and prove a snapshot of a concurrently written WAL database is coherent, cancellation/deadline interrupts Busy/Locked and page-copy work, partial scratch is retired, and the caller never receives an incompletely backed-up database. Preserve the Foreign-source no-mutation contract for the source main/WAL/SHM family; SQLITE_OPEN_READ_ONLY alone is not the entire acceptance proof. Exercise absent/cleaned sidecars and Windows live-writer behavior. No immutable=1 on a changing source to bypass locking.
P1 test isolation: the new refuse_sqlite_unsafe_fast performs process-global unsafe environment mutation in ordinary library tests. Prefer .env_remove on the controlled crash child and remove the audited-unused CI export, rather than mutating shared libtest environment to prove an inert flag is absent. Verify the real runtime journal/sync policy directly. A comment calling an unread variable a speed knob is misleading.
Bound and reap crash child processes on every failure path; a child that never publishes the durable-phase marker must fail the fixture, not hang the suite or leave locks behind. Keep subprocess-crash proof distinct from a claim about machine power-loss durability. Native Windows run and the affected snapshot/backup suites on the #707 merge tree remain required.
Copy-mode now stages an online backup and publishes only a complete DELETE-journal file. Crash fixtures reap a timed child and no longer mutate process-global SQLite env from library tests. Co-authored-by: Zack Jackson <ScriptedAlchemy@users.noreply.github.com>
…plan-reopened' into cursor/crash-snapshot-fixtures-933-fdd6
…plan-reopened' into cursor/crash-snapshot-fixtures-933-fdd6
ScriptedAlchemy
left a comment
There was a problem hiding this comment.
System-level re-review at f24ab494b — two concrete fixes required
The production snapshot change is not merely a fixture repair. I read the online-backup implementation and the new backup tests. Removing the unused shard-wide unsafe environment setting and separating the crash child from the live parent are improvements. This review does not claim to have run the Rust suites.
P1 — cleanup must not delete a pre-existing destination
sqlite_read_snapshot.rs::backup_live_sqlite_database_with unconditionally removes destination whenever run_online_backup errors. That includes the very first cancellation checkpoint or failure to open the source, before this attempt has created or published a destination. The public backup_live_sqlite_database path has no new-destination precondition or ownership receipt, so an unreadable/missing source can delete an existing valid backup. publish_complete_backup likewise unlinks the destination before renaming and deletes it again on rename failure, so failed replacement need not preserve the prior snapshot.
Delete only scratch material created by this attempt. Either explicitly enforce create-only destination semantics without touching an existing path, or retain the old destination until an authorized atomic replacement succeeds. Use attempt-owned staging (the fixed .backup-partial name also cannot distinguish two public helper calls), and reject source/destination aliases. Test an existing destination with source-open failure, cancellation before first step, failure after copying and publication failure; its bytes and identity must survive each failed attempt. A scratch-copy caller whose destination is always new can keep that narrower contract, but it must not make the public helper's cleanup destructive.
P2 — the new Busy/Locked test does not actually establish reader contention
sqlite_read_snapshot_backup_tests.rs::live_backup_deadline_interrupts_busy_locked_retries uses WAL mode plus BEGIN EXCLUSIVE on another connection, then expects read-side backup to time out. SQLite's transaction contract makes EXCLUSIVE equivalent to IMMEDIATE in WAL mode; this does not exclude snapshot readers. An isolated mechanism check here with SQLite 3.46.1 copied the fixture successfully in 0.143 ms while the writer transaction remained open (backup status DONE, both pages copied). This is not execution of the pinned rusqlite test, but it confirms the fixture premise is wrong, in agreement with SQLite's Transaction and Online Backup API documentation.
Use an actual rollback-journal exclusive-reader conflict, or a controlled backup-step boundary, and prove a Busy/Locked observation occurs before testing deadline/cancellation. Otherwise a fast runner fails expect_err, while a loaded runner can accidentally pass by using up 50 ms on unrelated work. Keep the WAL-concurrent-writer test separately; there success is the desired behavior.
Preserve the system contract
The copied image must be transactionally coherent and its later attach token must still validate the source generation; a successful backup alone does not authorize a freshness claim after the source changes. Keep checkpoint/deadline checks around the cooperative page loop and never use immutable=1 on the live source. Crash-child evidence proves process-crash recovery, not power-loss durability. The returned exact-head CI/hotpath-coverage are pending and SDK/profile runs queued.
Failed live backups retire only attempt-owned staging. Rollback-journal EXCLUSIVE is probed for Busy/Locked before the deadline retry test. Co-authored-by: Zack Jackson <ScriptedAlchemy@users.noreply.github.com>
ScriptedAlchemy
left a comment
There was a problem hiding this comment.
Follow-up on the fix pushed during review — a3e902b7a
The final inventory showed this newer head, so I re-read the backup implementation and the new tests before carrying the earlier findings forward.
Resolved in source: source-open and cooperative cancellation failures now retire the attempt's staging path rather than deleting the pre-existing destination. The new tests assert preserved destination bytes and file identity. The Busy/Locked fixture now uses DELETE journal mode, and explicitly observes a Busy/Locked step before testing the deadline. Withdraw the earlier WAL-EXCLUSIVE fixture objection. The WAL concurrent-writer test correctly remains a success path. I did not execute these repository tests.
Remaining Windows publication gap: replace_existing_destination under cfg(not(unix)) first renames the existing destination to displaced, then renames staging to destination. If the second move fails, restoration is attempted with let _ = fs::rename(&displaced, destination). A failed restoration is silently discarded; the function returns the staging-rename error without reporting that the old snapshot is no longer at its original path. A process exit between those two moves likewise leaves the old file displaced with no recovery record. Thus the implementation still does not establish the documented promise that the old destination stays in place until replacement succeeds. The current forced-publish-failure test exits before any displacement and cannot exercise this branch.
Use a supported atomic replacement authority, or reject replacing an existing destination where that contract cannot be provided. If non-atomic displacement is an intentional requirement, give it explicit recoverable ownership and report restoration failure rather than discarding it. Add native Windows failure injection after displacement and on restoration itself; prove recovery of the exact old file, not merely that some bytes remain at an unreported scratch name. This is distinct from the now-fixed early-error deletion.
Also make scratch ownership real at creation: PID/counter naming alone is not exclusive creation, and Connection::open_with_flags(...CREATE) can open a stale same-name file. Reserve the staging file exclusively before treating it as this attempt's deletable material, or refuse collisions. Do not delete a colliding file on an early checkpoint/source-open error.
The original findings have therefore been narrowed, not left stale: early failure cleanup and the lock-test premise are corrected; safe replacement and scratch ownership remain the publication boundary to finish.
Staging is create_new-owned before it is deletable. Windows rejects an existing destination instead of displace/restore that can lose the old file. Co-authored-by: Zack Jackson <ScriptedAlchemy@users.noreply.github.com>
…plan-reopened' into cursor/crash-snapshot-fixtures-933-fdd6
…33-fdd6' into cursor/crash-snapshot-fixtures-933-fdd6 # Conflicts: # crates/tracedecay-runtime-core/src/sqlite_read_snapshot.rs # crates/tracedecay-runtime-core/src/sqlite_read_snapshot_backup_tests.rs
…plan-reopened' into cursor/crash-snapshot-fixtures-933-fdd6
…plan-reopened' into cursor/crash-snapshot-fixtures-933-fdd6
…plan-reopened' into cursor/crash-snapshot-fixtures-933-fdd6
Two distinct changes
This PR still fixes #933, stacked on
codex/tracedecay-total-redesign-plan-reopened(PR #707). Do not undraft or merge #707.The production snapshot path and the crash fixtures are separate claims.
1. Production: Copy-mode live snapshot (not a test-only change)
SnapshotMode::Copyandbackup_live_sqlite_databaseuse the SQLite online backup API. They do notfs::copya locked Windows main/WAL/SHM family, and they do not open a changing source withimmutable=1.Publication boundary
create_new(owner-only on Unix). A name collision is refused; the colliding file is not deleted.rename(2)atomically replaces an existing destination. Failure leaves the old file at its original path.AlreadyExists). Displace/restore is not used; it cannot keep the old snapshot at its path until replace succeeds. Scratch and LCM callers already publish to a new path.Acceptance in
sqlite_read_snapshot_backup_tests.rs:fs::copystill fails with OS 32/33.2. Fixtures: subprocess unclean-exit, not power-loss
Grafeo crash/reopen tests spawn a child that reaches the durable WAL phase and exits without a clean close. The parent bounds that process (45s), kills and reaps it on timeout, and fails if the durable-phase marker is missing. Only then is the abandoned container + sidecar copied.
This proves unclean process exit recovery. It does not prove machine power-loss durability.
The oversized LCM payload test drops the creator handle before size admission.
3.
TRACEDECAY_SQLITE_UNSAFE_FASTRemoved the unused Windows CI export. Crash children still
.env_removeit. Writer policy is checked directly (WAL/synchronous=NORMAL/wal_autocheckpoint=0).Test plan
cargo test -p tracedecay-runtime-core --lib sqlite_read_snapshot::— 32 passed-D warningson runtime-core lib/testsChecklist
CHANGELOG.mdupdated (under[Unreleased]if no version bump).envfiles included