- Set SQL connection idle limit to 1 to reduce resource usage - Explicitly define AppConfig table name to prevent GORM pluralization issues
114 lines
4.3 KiB
Go
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{},
|
|
)
|
|
}
|