From 57500308b00d763dd8509453cbb6c6fe7545ba10 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Wed, 24 Jun 2026 23:18:32 -0500 Subject: [PATCH] Handle locked database cases --- internal/db/DB.go | 112 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 internal/db/DB.go diff --git a/internal/db/DB.go b/internal/db/DB.go new file mode 100644 index 0000000..e290f79 --- /dev/null +++ b/internal/db/DB.go @@ -0,0 +1,112 @@ +// Package db provides a database connection and functions for interacting with the database. +// The DB object should be held and maintained by the application, not by this package. +// The application should be responsible for connecting to, migrating, and initial population +// of the database. This package should only provide the schema definitions and helper functions. +// Schema definitions should clone API response structures as closely as possible. + +package db + +import ( + "fmt" + "os" + "time" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +const ( + connectMaxAttempts = 5 + connectRetryDelay = 500 * time.Millisecond +) + +// Connect opens the SQLite database at dbpath with WAL mode and a hardened +// lock-recovery strategy suitable for kiosk use where the process may have +// crashed and left stale lock artefacts behind. +func Connect(dbpath string) (*gorm.DB, error) { + + // ── Stale lock recovery ─────────────────────────────────────────────── + // SQLite WAL mode leaves a .db-wal and .db-shm alongside the main file. + // If the previous process crashed, the OS releases file locks but the + // artefacts remain. SQLite recovers them automatically on the next open, + // UNLESS an OS-level advisory lock is still held (rare but possible on + // Linux with certain crash patterns). Removing them before opening is + // safe: SQLite treats absent WAL/SHM files as a clean slate and will + // recreate them. We only do this if the main DB file already exists + // (i.e. this is not a first-run) to avoid masking legitimate errors. + if _, err := os.Stat(dbpath); err == nil { + for _, ext := range []string{"-wal", "-shm"} { + artefact := dbpath + ext + if _, err := os.Stat(artefact); err == nil { + if removeErr := os.Remove(artefact); removeErr != nil { + // Non-fatal: log and continue — SQLite may still recover. + fmt.Printf("[DB] warning: could not remove stale %s: %v\n", ext, removeErr) + } else { + fmt.Printf("[DB] removed stale lock artefact: %s\n", artefact) + } + } + } + } + + // ── Connection with retry ───────────────────────────────────────────── + // Even after removing WAL/SHM, the main DB file itself may still be + // briefly locked if another process is shutting down. Retry with + // backoff before giving up. + var ( + db *gorm.DB + err error + ) + for attempt := 1; attempt <= connectMaxAttempts; attempt++ { + db, err = gorm.Open(sqlite.Open(dbpath), &gorm.Config{}) + if err == nil { + break + } + if attempt == connectMaxAttempts { + return nil, fmt.Errorf("database still locked after %d attempts: %w", connectMaxAttempts, err) + } + fmt.Printf("[DB] open attempt %d/%d failed (%v), retrying in %s\n", + attempt, connectMaxAttempts, err, connectRetryDelay) + time.Sleep(connectRetryDelay) + } + + // ── Connection pool ─────────────────────────────────────────────────── + // SQLite does not support concurrent writers. Setting MaxOpenConns(1) + // forces gorm to serialise all queries through a single connection, + // preventing "database is locked" errors from competing goroutines + // within this process. This is the single most important setting for + // SQLite in a multi-goroutine application. + sqlDB, err := db.DB() + if err != nil { + return nil, fmt.Errorf("could not get underlying sql.DB: %w", err) + } + sqlDB.SetMaxOpenConns(1) + + // ── Pragmas ─────────────────────────────────────────────────────────── + pragmas := []string{ + "PRAGMA journal_mode=WAL", + "PRAGMA synchronous=FULL", + "PRAGMA foreign_keys=ON", + // 5 s gives the serialised connection time to finish a long write + // before returning SQLITE_BUSY to the application layer. + "PRAGMA busy_timeout=5000", + } + for _, pragma := range pragmas { + if _, err := sqlDB.Exec(pragma); err != nil { + return nil, fmt.Errorf("failed to set %q: %w", pragma, err) + } + } + + return db, nil +} + +// Migrate the database schema. +func Migrate(db *gorm.DB) error { + return db.AutoMigrate( + &AppConfig{}, + &Log{}, + &Proclamation{}, + &ProclamationCategory{}, + &SolfeggioFrequency{}, + &AudioFile{}, + ) +}