Files
odidere/internal/db/db.go
T
2026-09-20 12:27:30 +00:00

160 lines
4.6 KiB
Go

// Package db provides SQLite persistence for odidere.
package db
import (
"context"
"database/sql"
"fmt"
"regexp"
"sync"
"github.com/mattn/go-sqlite3"
)
// Config holds database connection settings.
type Config struct {
Path string
}
// SQLite holds the reader and writer connection pools.
type SQLite struct {
Writer *sql.DB
Reader *sql.DB
}
var driverOnce sync.Once
// Connect opens a SQLite database with dual connection pools.
//
// Two connection pools are used: a single-connection writer and a
// multi-connection reader. This follows the recommended pattern for
// SQLite in WAL mode with Go's database/sql.
//
// The writer pool is limited to one connection. Go serializes
// access in-process, so SQLite never sees concurrent write
// attempts. This eliminates SQLITE_BUSY on writes without needing
// a busy_timeout.
//
// The reader pool allows concurrent reads, which WAL mode supports
// without blocking the writer or each other.
func Connect(ctx context.Context, cfg Config) (*SQLite, error) {
// Ensure timestamps default to RFC3339-compatible format.
sqlite3.SQLiteTimestampFormats = []string{
"2006-01-02T15:04:05.000Z",
"2006-01-02T15:04:05.999999999-07:00",
"2006-01-02 15:04:05.999999999-07:00",
"2006-01-02 15:04:05.999999999",
"2006-01-02T15:04:05.999999999",
"2006-01-02 15:04:05",
"2006-01-02T15:04:05",
"2006-01-02 15:04",
"2006-01-02T15:04",
"2006-01-02",
}
driverOnce.Do(func() {
sql.Register("odidere_sqlite", &sqlite3.SQLiteDriver{
ConnectHook: func(conn *sqlite3.SQLiteConn) error {
if err := conn.RegisterFunc(
"regexp",
func(re, s string) (bool, error) {
return regexp.MatchString(re, s)
},
true,
); err != nil {
return fmt.Errorf("failed to register regexp: %w", err)
}
return nil
},
})
})
// Writer DSN.
// _busy_timeout=5000: wait up to 5 seconds if we encounter
// a lock. Rare in WAL mode, but possible during shutdown cleanup
// or crash recovery (see sqlite.org/wal.html §9).
// _cache_size=-16000: 16 MB page cache. Reduces disk reads for
// the single write connection that handles all mutations.
// _foreign_keys=on: enforce foreign key constraints.
// _journal_mode=WAL: write-ahead logging. Allows concurrent
// readers while writing.
// _synchronous=1: NORMAL durability. WAL mode ensures consistency
// even with NORMAL. Avoids the performance cost of FULL
// (fsync every commit).
// _txlock=immediate: acquire the write lock at BEGIN, not at the
// first write statement. Prevents deadlocks when multiple
// connections attempt deferred → write lock escalation.
writerDSN := "file:" + cfg.Path + "?" +
"_busy_timeout=5000&" +
"_cache_size=-16000&" +
"_foreign_keys=on&" +
"_journal_mode=WAL&" +
"_loc=UTC&" +
"_synchronous=1&" +
"_txlock=immediate"
w, err := sql.Open("odidere_sqlite", writerDSN)
if err != nil {
return nil, fmt.Errorf(
"failed to open sqlite writer: %v", err,
)
}
// One connection: all writes serialize in-process via the pool.
w.SetMaxOpenConns(1)
w.SetMaxIdleConns(1)
if err := w.PingContext(ctx); err != nil {
return nil, fmt.Errorf(
"failed to ping sqlite writer: %v", err,
)
}
// Reader DSN.
// mode=ro: open read-only. Prevents accidental writes through
// the reader pool.
// _busy_timeout=5000: wait up to 5 seconds if a read encounters
// a lock. Rare in WAL mode, but possible during shutdown cleanup
// or crash recovery (see sqlite.org/wal.html §9).
// _cache_size=-8000: 8 MB page cache per connection. Smaller than
// the writer since there are multiple reader connections.
// _journal_mode=WAL: must match the writer. WAL is persistent on
// the file, but setting it here ensures the connection is
// configured correctly if the database is new.
readerDSN := "file:" + cfg.Path + "?" +
"_busy_timeout=5000&" +
"_cache_size=-8000&" +
"_journal_mode=WAL&" +
"_loc=UTC&" +
"mode=ro"
r, err := sql.Open("odidere_sqlite", readerDSN)
if err != nil {
return nil, fmt.Errorf(
"failed to open sqlite reader: %v", err,
)
}
r.SetMaxOpenConns(2)
r.SetMaxIdleConns(2)
if err := r.PingContext(ctx); err != nil {
return nil, fmt.Errorf(
"failed to ping sqlite reader: %v", err,
)
}
// Keep the connections open.
w.SetConnMaxLifetime(0)
w.SetConnMaxIdleTime(0)
r.SetConnMaxLifetime(0)
r.SetConnMaxIdleTime(0)
return &SQLite{Reader: r, Writer: w}, nil
}
// Close shuts down both connection pools.
func (s *SQLite) Close() error {
if err := s.Writer.Close(); err != nil {
return fmt.Errorf("failed to close writer: %w", err)
}
if err := s.Reader.Close(); err != nil {
return fmt.Errorf("failed to close reader: %w", err)
}
return nil
}