package db import ( "context" "database/sql" "errors" "fmt" "log/slog" "os" "path/filepath" "time" "github.com/mattn/go-sqlite3" ) // 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 } // Connect initializes and returns a new database connection pool. // This is the basic connection function without any automatic operations. func Connect(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)) return db, nil } // ConnectAndMigrate initializes a database connection and automatically runs migrations. // This is the main function for application startup. func ConnectAndMigrate(ctx context.Context, logger *slog.Logger, config *DBConfig) (*sql.DB, error) { db, err := Connect(ctx, logger, config) if err != nil { return nil, err } // Run migrations with retry logic err = retryOperation(ctx, logger, config, "migrations", func() error { return RunMigrations(db) }) if err != nil { db.Close() return nil, err } logger.Info("database migrations applied") return db, nil }