From a7566ed16617d6625867566cf2cd2e05ea1f7a24 Mon Sep 17 00:00:00 2001 From: Jean-Michel Tremblay Date: Fri, 19 Jun 2026 07:43:15 -0400 Subject: [PATCH] Verify DB integrity at boot; document crash recovery (spec item 12) - Open() now runs PRAGMA quick_check after WAL recovery. WAL mode already makes opening self-healing (committed writes rolled forward, an interrupted write discarded), so a crash mid-write recovers automatically; quick_check fails fast only on genuine corruption, pointing the operator at BACKUP_DIR. - spec item 12 documents the durability/recovery model and the orphan-file caveat. Co-Authored-By: Claude Opus 4.8 --- internal/storage/storage.go | 22 ++++++++++++++++++++++ internal/storage/storage_test.go | 26 ++++++++++++++++++++++++++ spec.md | 25 ++++++++++++++++++++++++- 3 files changed, 72 insertions(+), 1 deletion(-) diff --git a/internal/storage/storage.go b/internal/storage/storage.go index b7763d3..7593d75 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -78,6 +78,15 @@ func Open(path string) (*Store, error) { db.Close() return nil, fmt.Errorf("set pragmas: %w", err) } + // WAL mode makes opening the file self-healing: SQLite atomically rolls forward + // committed transactions and discards any write interrupted by a crash/kill, so + // the DB comes up at its last committed state. quick_check confirms the result + // is sound — a crash-interrupted write passes; genuine corruption (disk failure) + // fails fast so the operator restores a backup instead of running on a bad DB. + if err := integrityCheck(db); err != nil { + db.Close() + return nil, err + } if _, err := db.Exec(schema); err != nil { db.Close() return nil, fmt.Errorf("apply schema: %w", err) @@ -91,6 +100,19 @@ func Open(path string) (*Store, error) { return &Store{db: db}, nil } +// integrityCheck runs SQLite's quick_check (a fast structural integrity scan) and +// returns an error if the database is not "ok". Run at open, after WAL recovery. +func integrityCheck(db *sql.DB) error { + var result string + if err := db.QueryRow(`PRAGMA quick_check`).Scan(&result); err != nil { + return fmt.Errorf("integrity check: %w", err) + } + if result != "ok" { + return fmt.Errorf("database integrity check failed (%s); restore a backup from BACKUP_DIR or re-import a .db export", result) + } + return nil +} + func (s *Store) Close() error { return s.db.Close() } // Seed inserts the given category and person labels if they are not already diff --git a/internal/storage/storage_test.go b/internal/storage/storage_test.go index 62f2e4e..546355c 100644 --- a/internal/storage/storage_test.go +++ b/internal/storage/storage_test.go @@ -156,6 +156,32 @@ func TestSoftDeleteHidesFromCount(t *testing.T) { } } +func TestReopenAfterWritePassesIntegrity(t *testing.T) { + path := filepath.Join(t.TempDir(), "reopen.db") + s, err := Open(path) + if err != nil { + t.Fatalf("Open: %v", err) + } + if err := s.Insert(sampleReceipt(firstCategoryID(t, s))); err != nil { + t.Fatalf("Insert: %v", err) + } + s.Close() + + // Reopening runs WAL recovery + quick_check; committed data must survive. + s2, err := Open(path) + if err != nil { + t.Fatalf("reopen: %v", err) + } + defer s2.Close() + n, err := s2.CountActive() + if err != nil { + t.Fatal(err) + } + if n != 1 { + t.Errorf("CountActive after reopen = %d, want 1", n) + } +} + func TestSeededCategories(t *testing.T) { s := newTestStore(t) cats, err := s.ListCategories() diff --git a/spec.md b/spec.md index 7a58765..7dcdd6d 100644 --- a/spec.md +++ b/spec.md @@ -330,4 +330,27 @@ external cron job. Storage-root note: STORAGE_DIR is the storage ROOT (not the receipts subdir). Receipts live under /receipts// and attachments under -/attachments// (default STORAGE_DIR=./data). \ No newline at end of file +/attachments// (default STORAGE_DIR=./data). + +12. Crash recovery and durability at boot + +The database must survive an app crash or kill mid-write without corruption or a +half-written row, and recover automatically on the next boot — no manual repair. + - SQLite runs in WAL mode (PRAGMA journal_mode=WAL, set on open). Every write is + an atomic transaction, so an interrupted write is either fully applied or not at + all. Opening the file is self-healing: SQLite rolls forward committed + transactions from the WAL and discards any incomplete tail, bringing the DB up + at its last committed state. There is no custom "revert" logic — SQLite's own + recovery is the mechanism, and reimplementing it would be less safe. + - On open the app runs PRAGMA quick_check to confirm the recovered file is sound. + A crash-interrupted write passes (WAL recovery already healed it). A failure + means genuine corruption (e.g. disk failure); the app refuses to start with a + message pointing at BACKUP_DIR, so the operator restores rather than running on + a corrupt DB. + - Dual-write caveat: a receipt's (or attachment's) file is written to disk BEFORE + its DB row is inserted, so a crash in between can leave an orphan file with no + row. This is harmless — extra bytes on disk, never a row missing its data — and + is not auto-cleaned. The DB/blob is the source of truth for serving. + - Recovery from true corruption (beyond crash-consistency) is restore-from-backup: + the metadata-only daily backup (item 11) recovers the records; the full .db + export (/export/db, blobs included) recovers records + images. \ No newline at end of file