160 lines
4.6 KiB
Go
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
|
||
|
|
}
|