Files
Cyrene_v2/internal/db/db.go
T

143 lines
5.0 KiB
Go

// Package db manages the SQLite connection and schema migrations for Cyrene v2.
//
// SQLite is the default embedded store. The pool is limited to a single open
// connection (SetMaxOpenConns(1) plus SetMaxIdleConns(1)) so all writes are
// serialized and can never contend on SQLite's single-writer file lock. A
// busy_timeout of 5000ms and WAL journal mode for file-backed databases
// further reduce lock contention.
//
// Migrations live in internal/db/migrations/*.sql, are embedded into the
// binary, and are applied in timestamp order inside transactions, tracked in a
// schema_migrations table. See MigrateUp and MigrateDown.
//
// PostgreSQL + pgvector is an optional enhancement that is intentionally out of
// scope for milestone M0. It will be added when the runtime reaches milestone M4
// (see DevDocs/开发时间线.md); until then the connection is SQLite only.
package db
import (
"context"
"database/sql"
"errors"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"strings"
_ "modernc.org/sqlite" // pure-Go SQLite driver, registers driver name "sqlite"
)
// Package-level sentinel errors. Each is wrapped with %w at the call site so
// callers can use errors.Is.
var (
// ErrInvalidPath is returned when the database path is empty or blank.
ErrInvalidPath = errors.New("db: invalid database path")
// ErrNoEmbedded is returned when no migration files are embedded.
ErrNoEmbedded = errors.New("db: no migration files found in embed")
// ErrDownMissing is returned when a migration has an up file but no matching
// down file.
ErrDownMissing = errors.New("db: down migration file missing")
// ErrInvalidSteps is returned when MigrateDown receives a steps count < 1.
ErrInvalidSteps = errors.New("db: migration steps must be >= 1")
)
// Open opens (or creates) the SQLite database at path and returns a *sql.DB.
//
// path may be a filesystem path (relative or absolute) or ":memory:" for an
// isolated in-memory database, which tests use. Under ":memory:" each returned
// handle owns exactly one private, non-shared database, so parallel tests are
// isolated by construction.
//
// The pool is configured for write serialization: SetMaxOpenConns(1) and
// SetMaxIdleConns(1) prevent multiple writers from contending on the single-
// writer lock. busy_timeout is set to 5000ms; for file-backed databases the
// journal mode is switched to WAL. ":memory:" keeps the default memory journal,
// because WAL has no meaning there.
//
// log is a *slog.Logger; a nil logger is replaced with a discard logger so the
// function can always log safely. ctx is used for the connection ping and the
// WAL pragma. The caller must Close the returned DB when done.
func Open(ctx context.Context, path string, log *slog.Logger) (*sql.DB, error) {
if strings.TrimSpace(path) == "" {
return nil, fmt.Errorf("db: open: %w", ErrInvalidPath)
}
log = discardLogger(log)
// Ensure the parent directory exists for file-backed databases so a custom
// DATA_DIR (e.g. ./data) works on first run without a pre-made directory.
if !isMemory(path) {
if dir := filepath.Dir(path); dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("db: create dir %q: %w", dir, err)
}
}
}
db, err := sql.Open("sqlite", buildDSN(path))
if err != nil {
return nil, fmt.Errorf("db: open %q: %w", path, err)
}
// Write serialization: a single connection avoids writer-lock contention.
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
if !isMemory(path) {
if _, err := db.ExecContext(ctx, "PRAGMA journal_mode=WAL"); err != nil {
_ = db.Close()
return nil, fmt.Errorf("db: enable WAL on %q: %w", path, err)
}
}
if err := db.PingContext(ctx); err != nil {
_ = db.Close()
return nil, fmt.Errorf("db: ping %q: %w", path, err)
}
log.Info("sqlite opened", "path", path, "max_open_conns", 1)
return db, nil
}
// buildDSN converts a user-supplied path into a modernc.org/sqlite DSN.
//
// ":memory:" is returned as-is: the driver then gives that handle a private,
// non-shared in-memory database, which with SetMaxOpenConns(1) is stable for
// the lifetime of the handle. Any other path becomes a file: URI so the
// busy_timeout pragma is honored; pre-built file: URIs are preserved and only
// have the pragma appended.
func buildDSN(path string) string {
if path == ":memory:" || strings.Contains(path, "mode=memory") {
return path
}
dsn := path
if !strings.HasPrefix(path, "file:") {
dsn = "file:" + path
}
sep := "?"
if strings.Contains(dsn, "?") {
sep = "&"
}
return dsn + sep + "_pragma=busy_timeout(5000)"
}
// isMemory reports whether path refers to an in-memory SQLite database, in
// which case the WAL journal mode is skipped.
func isMemory(path string) bool {
return strings.Contains(strings.ToLower(path), ":memory:") ||
strings.Contains(strings.ToLower(path), "mode=memory")
}
// discardLogger returns log if non-nil, otherwise a logger that writes nowhere,
// so callers can always log safely.
func discardLogger(log *slog.Logger) *slog.Logger {
if log != nil {
return log
}
return slog.New(slog.NewTextHandler(io.Discard, nil))
}