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()
|
db.Close()
|
||||||
return nil, fmt.Errorf("set pragmas: %w", err)
|
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 {
|
if _, err := db.Exec(schema); err != nil {
|
||||||
db.Close()
|
db.Close()
|
||||||
return nil, fmt.Errorf("apply schema: %w", err)
|
return nil, fmt.Errorf("apply schema: %w", err)
|
||||||
|
|
@ -91,6 +100,19 @@ func Open(path string) (*Store, error) {
|
||||||
return &Store{db: db}, nil
|
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() }
|
func (s *Store) Close() error { return s.db.Close() }
|
||||||
|
|
||||||
// Seed inserts the given category and person labels if they are not already
|
// 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) {
|
func TestSeededCategories(t *testing.T) {
|
||||||
s := newTestStore(t)
|
s := newTestStore(t)
|
||||||
cats, err := s.ListCategories()
|
cats, err := s.ListCategories()
|
||||||
|
|
|
||||||
25
spec.md
25
spec.md
|
|
@ -330,4 +330,27 @@ external cron job.
|
||||||
|
|
||||||
Storage-root note: STORAGE_DIR is the storage ROOT (not the receipts subdir).
|
Storage-root note: STORAGE_DIR is the storage ROOT (not the receipts subdir).
|
||||||
Receipts live under <STORAGE_DIR>/receipts/<year>/ and attachments under
|
Receipts live under <STORAGE_DIR>/receipts/<year>/ and attachments under
|
||||||
<STORAGE_DIR>/attachments/<year>/ (default STORAGE_DIR=./data).
|
<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