feat(storage): sqlite online snapshot and disaster recovery rehearsal #67
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/issue-36-backup"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Motivation
The journal runs in WAL mode, so a committed transaction lives in
life.sqlite3-waluntil a checkpoint writes it back into the main file. Copying the livelife.sqlite3alone is therefore not a backup: it loses recently committed transactions at best and yields an inconsistent database at worst. Issue #36 (scenarios D10, D11, D12) requires the supported snapshot procedure, provenance for it, and a restore gate that verifies before a restored node serves anything.There was no backup verb, no manifest, no restore path and no rehearsal. A restore also has a second half that is easy to miss: a restored journal stopped observing at its capture time, so the node must reconcile unresolved operations, drop the sync cursors the restore invalidated, and record the interval it never observed instead of presenting restored state as continuous history (
docs/technical/operations-security.md#backup-and-restore,docs/technical/timeline-consistency.md#external-reconciliation).Changes
src/history/backup.rs(new) — the snapshot engine, manifest type, verification and restore gate:create_snapshotrunsVACUUM INTO ?1through the singleDatabaseWorkerthat owns the process's one connection; SQLite serializes the snapshot against the live connection's committed view, so a commit made just before the snapshot is in the file. No second writer or competing connection is opened, and a staging directory inside the source data directory is refused before anything is created.PRAGMA integrity_check,PRAGMA foreign_key_check, recorded schema version) and removed when it fails.SnapshotManifestrecords manifest format version, application + binary version, backend + engine version, snapshot file name,sha256:<hex>, capture time and journal schema version, and is written atomically (temp + rename).validate_snapshotverifies manifest presence/shape, file-name agreement, capture-time plausibility, checksum, integrity/foreign keys, schema agreement, and refuses a schema newer than this binary supports (never a destructive downgrade).restore_intorefuses a target that is not isolated (an exclusive service lock is taken on the target, an existing target database is refused, unrelated-wal/-shmsidecars are refused), copies to a temp path, verifies the copy's checksum, and renames it into place. It returns the still-held lock so the caller rebaselines before releasing it.snapshot_statusreports capture age, size and whether the file still matches its manifest — a stale or failing snapshot is visible.src/history/disaster.rs(new) —reconcile_after_restore: reusesrecover_pending_operations(the one recovery pass, no remote replay), then in one transaction drops every collection's sync cursor and advances its generation (cursor = NULL,generation + 1,complete = 0, coverageincomplete), and appends oneobservation_gapevent (confirmed_service) whoseafter_json.coveragecovers[snapshot_capture_time, restore_started_at]. No creation or deletion event is synthesized for anything that changed across the gap; the summary reports whether the rebaseline is complete so a caller can refuse to serve.src/cli.rs—life backup snapshot --out <dir>,life backup status --out <dir> [--max-age-seconds <n>],life backup restore --manifest <path> [--snapshot <path>] --into <dir> [--database <path>]. Snapshot/restore take the#34exclusive data-directory lock, so a runninglife serveowns the directory and is refused rather than joined: snapshotting is an explicit operator action, not a hidden daemon behaviour. Restore verifies before writing, and reconciles when--configis given; without one the rebaseline is left to the next start.src/api/mod.rs—serve()completes a pending restore (complete_pending_restore) before the listener accepts a request: a directory restored from a snapshot is rebaselined (same recovery + cursor invalidation + gap) and then its marker cleared. An unreadable marker or a rebaseline that cannot be recorded aborts startup, leaving the marker for explicit resolution.src/history/mod.rs,src/history/recovery.rs— module registration and re-exports;RecoverySummaryderivesSerializeso a rebaseline can report it.tests/disaster_recovery_rehearsal.rs(new) — 9 tests covering D10, D11, D12, the isolation refusals, the marker/daemon gate, and the full rehearsal with known markers.Rejected procedure, recorded as required: copying
life.sqlite3alone while WAL is active is unsafe and is not a supported backup. Uselife backup snapshot(SQLiteVACUUM INTOthrough the owning connection).Verification
nix develop --command cargo test— all suites green (543 tests, including the 9 new ones).nix develop --command cargo clippy --all-targets -- -D warningsandcargo fmt --check— clean.life backup snapshotwrote a snapshot + manifest;life backup statusreported age/size/checksum: ok;life backup restore --into <isolated dir>restored, verified, and leftlife.restore-pending.jsonfor the next start; a missing manifest aborted withmanifest_missing.d10_snapshot_carries_wal_commits_that_a_main_file_copy_loses): 50 operations and events committed with automatic checkpoints off; the-walfile is non-empty; the snapshot carries all 50 rows and passesintegrity_check, while a copy of the main file alone does not hold the newest operation.d11_restore_against_a_differently_timed_calendar_reconciles_and_records_the_gap): a snapshot captured 15 minutes before the restore, restored against a calendar holding states the service never wrote. Integrity and foreign keys pass on the restored file; all three unresolved operations are reconciled on evidence (two toneeds_reconciliation, one observed asobserved_externalwith explicit attribution uncertainty); the cursor is dropped and the generation set; the gap event covers exactly[capture, restore]; the event kinds are exactly the seeded fact plus the observation plus the gap, so nothing was fabricated; and the backend received no write at all.d12_corrupt_incomplete_or_unsupported_snapshots_abort_before_restoring): flipped bytes abort withChecksumMismatch; damage that also survives a regenerated manifest aborts withCorruptedDatabase; a missing manifest aborts withMissingManifest; a manifest describing another file aborts withInvalidManifest; a newer schema aborts withUnsupportedSchemaVersion. No target database and no marker are left behind.rehearsal_recovers_operations_timeline_proposals_outbox_and_idempotency_keys): operations, timeline events, proposals and outbox rows come back with their marker values; replaying a restored operation key returnsReplayand a different request hash under the same key is stillIdempotencyConflict; the gap is reported and states that history is not complete through it.daemon_completes_a_pending_restore_before_it_servesstarts the real binary over a restored directory and observes the gap recorded, the cursor invalidated and the unresolved operation reconciled by the time/health/liveanswers, with no backend write;daemon_refuses_to_serve_a_restore_it_cannot_rebaselineshows an unreadable marker aborting startup and being preserved.Deployment inputs deliberately left out
life sync).Related Issues
Closes #36
Coordinator review — APPROVED
Verified
b783b05myself:cargo test566 passed / 0 failed (9 disaster-recovery tests),cargo clippy --all-targets -- -D warnings0 errors,cargo fmt --checkclean.What I checked beyond the summary:
VACUUM INTO ?1atsrc/history/backup.rs:335, documented in the module header and at the call site), and the rejection of the naive copy is evidence-backed rather than asserted: withwal_autocheckpoint=0the test commits rows that stay in-wal, then shows the snapshot holds the final row while a plainlife.sqlite3copy does not. That is exactly the D10 pair, and it is the difference between a documented rule and a justified one.life_eventsforkind LIKE '%delete%' OR '%tombstone%' OR '%removed%', asserts the event-kind set is exactly{task_created, operation_recovery_observed, observation_gap}, and pins the gap's window (from= capture time,to= restore start, with a duration floor). Reconciling without fabricating creation or deletion events cannot be shown any other way.reconcile_after_restorecallsrecover_pending_operations(so the #6 bound,deferredandunreadablesemantics still apply) and then, in one transaction, invalidates sync cursors (cursor=NULL,generation+1,complete=0, coverageincomplete) and appends the single observation gap.-wal/-shmsidecars abort, then the copy's checksum is verified before the rename — so a half-restore cannot land.serve()runscomplete_pending_restorebefore admitting a request, which is what makes "verified before traffic" true rather than intended.life backup statusreports age, size, schema and backend version,is_stale, and a recomputed checksum so a modified file reportschecksum_ok=false), covering the "monitor snapshot age/failures" half of the operations contract.Deployment inputs correctly left out (encryption, off-host destination, frequency, retention, key custody) and disclosed; both sides of the
src/api/mod.rsconflict were kept with #24's/v1/next. No new dependency.Merging. Phase 5's operational half is now complete except the agent-portability work (#31–#33).
feat(storage): sqlite online snapshot and disaster recovery rehearsal (#36)to feat(storage): sqlite online snapshot and disaster recovery rehearsal