feat(db): SQLite 连接与迁移框架(embed 迁移/事务校验/PG 预留 M4)
This commit is contained in:
1 parent
dd16176bb7
commit
34cac241c1
6 files changed
+726
No files matched your search
@@ -0,0 +1,130 @@
|
||||
// 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"
|
||||
"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)
|
||||
|
||||
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))
|
||||
}
|
||||
Reference in new issue
Block a user