Files
radiantwave-legacy/internal/db/DB.go
T
NeilClack 6fd5ec919c fix(db): stabilize connection settings and explicitly define AppConfig table name
- Set SQL connection idle limit to 1 to reduce resource usage
- Explicitly define AppConfig table name to prevent GORM pluralization issues
2026-06-26 22:49:51 -05:00

114 lines
4.3 KiB
Go

// 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)
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{},
&AudioFile{},
)
}