mirror of
https://github.com/lukaszraczylo/claude-mnemonic.git
synced 2026-06-11 00:09:28 +00:00
a274f1bc1e
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.
123 lines
3.7 KiB
Go
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
|
|
}
|