package db import ( "context" "database/sql" _ "embed" "errors" "fmt" "log/slog" "os" "path/filepath" "time" "github.com/mattn/go-sqlite3" ) //go:embed schema.sql var ddl string // DBConfig holds database configuration. type DBConfig struct { DSN string // Data Source Name for SQLite MaxOpenConns int // Maximum number of open connections MaxIdleConns int // Maximum number of idle connections ConnMaxLifetime time.Duration // Maximum lifetime of connections ConnMaxIdleTime time.Duration // Maximum idle time for connections MaxRetries int // Maximum number of connection retry attempts RetryDelay time.Duration // Delay between retry attempts } // DefaultDBConfig returns a DBConfig with sensible defaults for SQLite. func DefaultDBConfig(dsn string) *DBConfig { return &DBConfig{ DSN: dsn, MaxOpenConns: 25, MaxIdleConns: 10, ConnMaxLifetime: 30 * time.Minute, ConnMaxIdleTime: 5 * time.Minute, MaxRetries: 3, RetryDelay: time.Second, } } // isRetryableError checks if an error is worth retrying. // This checks SQLite error codes for transient locking/busy conditions. func isRetryableError(err error) bool { if err == nil { return false } // Check if it's a sqlite3.Error var sqliteErr sqlite3.Error if errors.As(err, &sqliteErr) { switch sqliteErr.Code { case sqlite3.ErrBusy, sqlite3.ErrLocked: return true } // Check extended error codes for more specific busy conditions switch sqliteErr.ExtendedCode { case sqlite3.ErrBusyRecovery, sqlite3.ErrBusySnapshot: return true } } return false } // retryOperation executes an operation with retry logic for transient SQLite errors. func retryOperation(ctx context.Context, logger *slog.Logger, config *DBConfig, operationName string, operation func() error) error { for attempt := 0; attempt <= config.MaxRetries; attempt++ { if attempt > 0 { logger.Info("retrying operation", slog.String("operation", operationName), slog.Int("attempt", attempt), slog.Int("max_retries", config.MaxRetries)) select { case <-ctx.Done(): return fmt.Errorf("context canceled during %s retry: %w", operationName, ctx.Err()) case <-time.After(config.RetryDelay): } } err := operation() if err != nil { if isRetryableError(err) && attempt < config.MaxRetries { logger.Warn("retryable error during operation", slog.String("operation", operationName), slog.Any("error", err), slog.Int("attempt", attempt)) continue } return fmt.Errorf("failed to %s: %w", operationName, err) } return nil } // This should never be reached due to the loop condition, but for safety return fmt.Errorf("unexpected retry loop exit for %s", operationName) } // openAndConfigureDB opens the database connection and configures the connection pool. func openAndConfigureDB(config *DBConfig) (*sql.DB, error) { // Ensure the directory for the SQLite file exists dbDir := filepath.Dir(config.DSN) if err := os.MkdirAll(dbDir, 0755); err != nil { return nil, fmt.Errorf("failed to create database directory %s: %w", dbDir, err) } // Open database connection db, err := sql.Open("sqlite3", config.DSN+"?_foreign_keys=on") // Enable foreign key constraints if err != nil { return nil, fmt.Errorf("failed to open database: %w", err) } // Configure connection pool db.SetMaxOpenConns(config.MaxOpenConns) db.SetMaxIdleConns(config.MaxIdleConns) db.SetConnMaxLifetime(config.ConnMaxLifetime) db.SetConnMaxIdleTime(config.ConnMaxIdleTime) return db, nil } // NewDB initializes and returns a new database connection pool and runs migrations. func NewDB(ctx context.Context, logger *slog.Logger, config *DBConfig) (*sql.DB, error) { db, err := openAndConfigureDB(config) if err != nil { return nil, err } // Test the actual connection with retry logic err = retryOperation(ctx, logger, config, "database connection", func() error { return db.PingContext(ctx) }) if err != nil { db.Close() return nil, err } logger.Info("database connection established", slog.String("dsn", config.DSN), slog.Int("max_open_conns", config.MaxOpenConns), slog.Int("max_idle_conns", config.MaxIdleConns)) // Execute schema with retry logic err = retryOperation(ctx, logger, config, "schema execution", func() error { _, err := db.ExecContext(ctx, ddl) return err }) if err != nil { db.Close() return nil, err } logger.Info("database schema applied") return db, nil }