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:
parent
885fae06ec
commit
a7566ed166
3 changed files with 72 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
23
spec.md
|
|
@ -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.
|
||||
Loading…
Reference in a new issue