Files
claude-mnemonic/internal/db/gorm/store.go
T
lukaszraczylo a274f1bc1e fix: add SQLite busy_timeout to prevent database locked errors
Set PRAGMA busy_timeout=5000 (5 seconds) to allow SQLite to retry
when the database is locked instead of failing immediately.

This fixes race conditions when multiple goroutines try to write
simultaneously, particularly in tests where StoreObservation spawns
async cleanup goroutines.

Root cause:
- StoreObservation launches goroutine -> CleanupOldObservations
- Multiple concurrent cleanups caused "database is locked" errors
- Without busy_timeout, SQLite fails immediately on lock contention

Solution:
- Add 5-second busy timeout for automatic retry on lock
- Standard practice for concurrent SQLite usage
- Works with existing WAL mode configuration

Fixes TestObservationStore_CleanupOldObservations in CI.
2026-01-07 21:48:55 +00:00

123 lines
3.7 KiB
Go

// Package gorm provides GORM-based database operations for claude-mnemonic.
package gorm
import (
"database/sql"
"fmt"
sqlite_vec "github.com/asg017/sqlite-vec-go-bindings/cgo"
_ "github.com/mattn/go-sqlite3" // Import SQLite driver with FTS5 support
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// Store represents the GORM database connection with sqlite-vec support.
type Store struct {
DB *gorm.DB
sqlDB *sql.DB // For FTS5 and sqlite-vec operations that require raw SQL
}
// Config holds database configuration.
type Config struct {
Path string // Path to SQLite database file
MaxConns int // Maximum number of open connections (default: 4)
LogLevel logger.LogLevel // GORM log level (logger.Silent for production)
}
// NewStore creates a new Store with WAL mode enabled and sqlite-vec registered.
// CRITICAL: WAL mode and foreign keys are enabled via pragmas for concurrent reads.
func NewStore(cfg Config) (*Store, error) {
// 1. Register sqlite-vec extension (must be done before opening database)
sqlite_vec.Auto()
// 2. Build connection string (foreign keys enabled in DSN)
// Use sqlite3 driver (mattn/go-sqlite3) which has FTS5 support
dsn := cfg.Path + "?_foreign_keys=ON"
// 3. Open raw database connection with mattn/go-sqlite3 (has FTS5 support)
sqlDB, err := sql.Open("sqlite3", dsn)
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
}
// 4. Wrap with GORM using existing connection
db, err := gorm.Open(sqlite.Dialector{
Conn: sqlDB,
}, &gorm.Config{
Logger: logger.Default.LogMode(cfg.LogLevel),
// PrepareStmt enables prepared statement caching for performance
PrepareStmt: true,
// Disable default timestamp fields (we manage created_at manually)
NowFunc: nil,
})
if err != nil {
_ = sqlDB.Close() // Explicitly ignore close error during cleanup
return nil, fmt.Errorf("open gorm: %w", err)
}
// 5. Configure connection pool (same settings as current implementation)
maxConns := cfg.MaxConns
if maxConns <= 0 {
maxConns = 4
}
sqlDB.SetMaxOpenConns(maxConns)
sqlDB.SetMaxIdleConns(maxConns)
sqlDB.SetConnMaxLifetime(0) // Never expire (SQLite connections are cheap)
// 6. Verify connection
if err := sqlDB.Ping(); err != nil {
return nil, fmt.Errorf("ping database: %w", err)
}
store := &Store{
DB: db,
sqlDB: sqlDB,
}
// 7. Run migrations FIRST (before PRAGMA commands)
if err := runMigrations(db, sqlDB); err != nil {
return nil, fmt.Errorf("run migrations: %w", err)
}
// 8. CRITICAL: Set WAL mode and synchronous mode via raw SQL
// Use raw sqlDB to avoid GORM transaction issues
if _, err := sqlDB.Exec("PRAGMA journal_mode=WAL"); err != nil {
return nil, fmt.Errorf("set WAL mode: %w", err)
}
if _, err := sqlDB.Exec("PRAGMA synchronous=NORMAL"); err != nil {
return nil, fmt.Errorf("set synchronous mode: %w", err)
}
// Set busy timeout to 5 seconds to handle concurrent writes
// This allows SQLite to retry when database is locked instead of failing immediately
if _, err := sqlDB.Exec("PRAGMA busy_timeout=5000"); err != nil {
return nil, fmt.Errorf("set busy timeout: %w", err)
}
return store, nil
}
// Close closes the database connection.
func (s *Store) Close() error {
return s.sqlDB.Close()
}
// Ping verifies the database connection is alive.
func (s *Store) Ping() error {
return s.sqlDB.Ping()
}
// GetRawDB returns the underlying *sql.DB for operations GORM can't handle.
// Use this for:
// - FTS5 full-text search queries (MATCH operator)
// - sqlite-vec vector operations
// - Complex raw SQL queries
func (s *Store) GetRawDB() *sql.DB {
return s.sqlDB
}
// GetDB returns the GORM DB instance for standard queries.
func (s *Store) GetDB() *gorm.DB {
return s.DB
}