// 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" "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) sqlDB.SetMaxIdleConns(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{}, &AudioTrack{}, ) }