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 <noreply@anthropic.com>
This commit is contained in:
Jean-Michel Tremblay 2026-06-19 07:43:15 -04:00
parent 885fae06ec
commit a7566ed166
3 changed files with 72 additions and 1 deletions

View file

@ -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

View file

@ -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()

23
spec.md
View file

@ -331,3 +331,26 @@ external cron job.
Storage-root note: STORAGE_DIR is the storage ROOT (not the receipts subdir).
Receipts live under <STORAGE_DIR>/receipts/<year>/ and attachments under
<STORAGE_DIR>/attachments/<year>/ (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.