Files
verdande/internal/store/store.go
T
Kristian WindandClaude Opus 5 40a679edb6 fix(store): write transactions take the lock at BEGIN, not partway through
Two people saving at the same moment got a 500 after two milliseconds.

The DSN has set busy_timeout since the first commit, and it covered none
of the contention it looks like it covers. With a plain BEGIN a write
transaction starts as a reader and asks for the write lock at its first
UPDATE — and SQLite refuses that upgrade with SQLITE_BUSY immediately,
without applying busy_timeout to it. Backing off cannot help: the
transaction is already holding a read snapshot the other writer is
invalidating.

_txlock=immediate takes the lock at BEGIN instead, where the timeout does
apply and the second writer waits its turn. The driver leaves read-only
transactions deferred, so readers still run concurrently, and every one
of the twenty callers of db.Tx is a write.

Found by opening a task and saving a description, a sub-task and a
comment at once — three writes, one of which came back a 500. The test
fails without the flag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 10:56:33 +02:00

178 lines
5.3 KiB
Go

// Package store owns the SQLite database: connection settings, schema migrations,
// and the query helpers the rest of verdande builds on.
package store
import (
"context"
"database/sql"
"embed"
"fmt"
"io/fs"
"path/filepath"
"runtime"
"sort"
"strings"
"time"
_ "modernc.org/sqlite" // pure-Go driver: no cgo, so the binary stays static
)
//go:embed migrations/*.sql
var migrationFS embed.FS
type DB struct {
*sql.DB
path string
}
// Open prepares the database at path and brings the schema up to date.
//
// SQLite is a single file, but it is being driven by an HTTP server with concurrent
// readers and a WebSocket fan-out, so the pragmas below matter more than usual:
// WAL lets readers run while a write is in flight, busy_timeout turns the "database
// is locked" race into a short wait, and foreign_keys is off by default in SQLite
// and has to be asked for on every connection.
func Open(path string) (*DB, error) {
dsn := "file:" + filepath.ToSlash(path) + "?" + strings.Join([]string{
"_pragma=journal_mode(WAL)",
"_pragma=busy_timeout(5000)",
"_pragma=foreign_keys(ON)",
"_pragma=synchronous(NORMAL)",
// Every write transaction takes the write lock up front.
//
// Without this, `BEGIN` is deferred: a transaction starts as a reader and
// asks for the write lock at its first UPDATE. SQLite refuses that upgrade
// with SQLITE_BUSY *immediately* and does not apply busy_timeout to it —
// backing off cannot help, because the transaction is already holding a
// read snapshot that the other writer is invalidating. So the timeout above
// looks like it covers writer contention and covers none of it: two people
// saving at the same moment get a 500 in two milliseconds.
//
// `immediate` takes the lock at BEGIN instead, where busy_timeout does
// apply and the second writer waits its turn. The driver leaves read-only
// transactions deferred, so readers still run concurrently.
"_txlock=immediate",
// Keep the temp b-trees FTS5 and ORDER BY build in memory rather than
// spilling them into the data volume.
"_pragma=temp_store(MEMORY)",
}, "&")
sqlDB, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
// SQLite serialises writes whatever the pool does, so this is sized for readers.
// In WAL mode they run concurrently with a write in flight, and busy_timeout
// absorbs the contention between writers.
//
// Not 1. A single connection makes the pool a lock, and any code that runs a
// query while an earlier *sql.Rows is still open then waits forever for a
// connection it is itself holding — a deadlock rather than a slow query, and
// one that only appears once a list has rows in it. The store closes its result
// sets before querying again, and this leaves room for the mistake to be slow
// instead of fatal if it is ever made again.
sqlDB.SetMaxOpenConns(max(4, runtime.NumCPU()))
sqlDB.SetMaxIdleConns(4)
sqlDB.SetConnMaxLifetime(time.Hour)
if err := sqlDB.PingContext(context.Background()); err != nil {
sqlDB.Close()
return nil, fmt.Errorf("ping sqlite: %w", err)
}
db := &DB{DB: sqlDB, path: path}
if err := db.migrate(context.Background()); err != nil {
sqlDB.Close()
return nil, err
}
return db, nil
}
func (db *DB) Path() string { return db.path }
// migrate applies every embedded migration that has not run yet, in filename order,
// each inside its own transaction. A migration that fails leaves the schema at the
// last version that succeeded rather than half-applied.
func (db *DB) migrate(ctx context.Context) error {
if _, err := db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS schema_migrations (
name TEXT PRIMARY KEY,
applied_at INTEGER NOT NULL
)`); err != nil {
return fmt.Errorf("create schema_migrations: %w", err)
}
applied := map[string]bool{}
rows, err := db.QueryContext(ctx, `SELECT name FROM schema_migrations`)
if err != nil {
return fmt.Errorf("read schema_migrations: %w", err)
}
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
rows.Close()
return err
}
applied[name] = true
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
entries, err := fs.Glob(migrationFS, "migrations/*.sql")
if err != nil {
return err
}
sort.Strings(entries)
for _, entry := range entries {
name := filepath.Base(entry)
if applied[name] {
continue
}
body, err := migrationFS.ReadFile(entry)
if err != nil {
return err
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
if _, err := tx.ExecContext(ctx, string(body)); err != nil {
tx.Rollback()
return fmt.Errorf("migration %s: %w", name, err)
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO schema_migrations (name, applied_at) VALUES (?, unixepoch())`, name); err != nil {
tx.Rollback()
return fmt.Errorf("record migration %s: %w", name, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit migration %s: %w", name, err)
}
}
return nil
}
// Tx runs fn inside a transaction, rolling back on error or panic.
func (db *DB) Tx(ctx context.Context, fn func(*sql.Tx) error) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
if p := recover(); p != nil {
tx.Rollback()
panic(p)
}
}()
if err := fn(tx); err != nil {
tx.Rollback()
return err
}
return tx.Commit()
}