textmachine/backend/internal/store/store.go

290 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package store is the durable state of a book project: schema migrations,
// the spend ledger (reserve/settle), chunk checkpoints and the request_log —
// one SQLite file per project (modernc.org/sqlite, CGO-free, no native
// extensions — Р3/Р6).
//
// The money and migration semantics are ported from vojo store.go; the code itself
// is rewritten for SQLite (no advisory locks here — serialization comes from a
// single-connection write pool + BEGIN IMMEDIATE; 03-implementation-notes §2 item 5).
//
// Concurrency recipe (§3.6): two pools per DB file — write (MaxOpenConns=1,
// immediate transactions) and read (N connections); pragmas on every connection
// via DSN: WAL, busy_timeout, foreign_keys, synchronous(NORMAL — survives
// kill -9; power loss is outside the MVP threat model).
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"net/url"
"os"
"syscall"
"time"
_ "modernc.org/sqlite"
)
// opTimeout bounds every store operation: SQLite is a local file and
// effectively never blocks; the cap keeps a wedged filesystem from hanging a
// pipeline goroutine forever.
//
// It is a PER-OPERATION budget and only holds while every operation under it is O(1) in the size of the
// book — which is what the migration chain and the recovery pass are (metadata-only DDL and one UPDATE).
// A caller's own work must therefore never be charged to it; see the seam in migrate.
//
// A var, not a const, so a test can shrink it: the property "the seam is not charged to this budget" is
// otherwise only testable by sleeping ten real seconds in the battery.
var opTimeout = 10 * time.Second
// Store is one project database.
type Store struct {
w *sql.DB // single-connection write pool; all mutations go through it
r *sql.DB // read pool
lock *os.File
// applied is the schema transition this open performed — the zero value on a read-only open, which
// never migrates. It is recorded because a write open is the only thing that MAY migrate, so it is
// also the only thing that knows what it migrated (`tmctl migrate` reports it, row 174).
applied Migration
}
// Open opens (or creates) the project database at path, applies pending
// migrations and recovers stale reservations from a crashed run.
//
// Ownership: Open takes an EXCLUSIVE flock on <path>.lock — "one process
// owns the project file" is enforced here, not assumed. Without the lock a
// concurrent `tmctl report` during a translation would zero a running process's live
// reservations via the recovery pass and blind its ceilings (review finding).
func Open(path string) (*Store, error) { return open(path, nil) }
// open is Open with the migration seam exposed: beforeApply, when non-nil, runs after the lock is held
// and the pending set is known, and before the first step is applied. See Migrate.
func open(path string, beforeApply func(Migration) error) (*Store, error) {
lock, err := acquireLock(path + ".lock")
if err != nil {
return nil, err
}
dsn := func(txlock string) string {
v := url.Values{}
v.Add("_pragma", "journal_mode(WAL)")
v.Add("_pragma", "busy_timeout(5000)")
v.Add("_pragma", "foreign_keys(1)")
v.Add("_pragma", "synchronous(NORMAL)")
if txlock != "" {
v.Set("_txlock", txlock)
}
return "file:" + path + "?" + v.Encode()
}
w, err := sql.Open("sqlite", dsn("immediate"))
if err != nil {
releaseLock(lock)
return nil, fmt.Errorf("store: open write pool: %w", err)
}
// One writer connection: SQLite has a single writer per file anyway;
// serializing in Go turns SQLITE_BUSY storms into a plain queue.
w.SetMaxOpenConns(1)
r, err := sql.Open("sqlite", dsn(""))
if err != nil {
w.Close()
releaseLock(lock)
return nil, fmt.Errorf("store: open read pool: %w", err)
}
r.SetMaxOpenConns(4)
s := &Store{w: w, r: r, lock: lock}
// Each phase takes its OWN budget rather than sharing one across the whole open. The migration is
// the only phase that can host caller work of unbounded size (the seam below), and a single shared
// deadline made that work eat the budget of the steps it was called to protect: on a large project
// the restore point finished and the first step then failed «context deadline exceeded» — an exit 1
// outside the refusal band, after an expensive write, with every retry copying the database again
// (round-2 review, reproduced on a 2.6 GB project).
if err := s.migrate(path, beforeApply); err != nil {
s.Close()
return nil, err
}
ctx, cancel := opContext()
defer cancel()
if err := s.recoverReservations(ctx); err != nil {
s.Close()
return nil, err
}
return s, nil
}
// OpenReadOnly opens an EXISTING project database for the $0 read-only projections
// (`tmctl status`/`report`, D15.3/D20.4) WITHOUT the exclusive flock, migrations or
// the reservation-recovery pass. These commands used to go through Open → the exclusive
// flock, and the operator was locked out of status for EXACTLY the duration of a live run — "chapter
// 300 is hanging and there's nothing to inspect" (the package №4 smoke-run pain). WAL routinely
// allows concurrent readers while a writer is live; the lock is needed ONLY because of
// recoverReservations (it would zero a running process's live reservations) — and the read path
// does not run that pass, so it does not need the lock either.
//
// Guarantees: connections are strictly read-only (PRAGMA query_only=1 — an accidental
// write fails loudly rather than corrupting a live writer's state); a missing DB is a loud
// error (we don't create an empty one); a schema this binary does not match is a TYPED refusal naming
// `tmctl migrate` (only a write open applies migrations — see SchemaMismatchError). A known, honest
// boundary: after a run crashes, reserved_usd stays non-zero until the next WRITE command
// (the recovery pass runs only there) — status will show this leftover as reserved.
func OpenReadOnly(path string) (*Store, error) {
if _, err := os.Stat(path); err != nil {
return nil, fmt.Errorf("store: project database %s does not exist yet — run `tmctl translate` first: %w", path, err)
}
// No journal_mode(WAL): the journal mode is a property of the FILE (Open always
// creates WAL, the flag is persistent), and trying to set it on a query_only connection
// would be a write. foreign_keys/synchronous are irrelevant to a reader.
v := url.Values{}
v.Add("_pragma", "busy_timeout(5000)")
v.Add("_pragma", "query_only(1)")
r, err := sql.Open("sqlite", "file:"+path+"?"+v.Encode())
if err != nil {
return nil, fmt.Errorf("store: open read-only pool: %w", err)
}
r.SetMaxOpenConns(4)
// w deliberately points at the same query_only pool: a stray write through a
// read-only Store returns SQLITE_READONLY loudly, not a nil panic and not a silent success.
s := &Store{w: r, r: r, lock: nil}
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
defer cancel()
current, err := schemaVersion(ctx, s.r)
if err != nil {
s.Close()
return nil, fmt.Errorf("store: read schema version of %s (not a database?): %w", path, err)
}
// A version this binary does not match is a TYPED refusal, in both directions: the caller's repair
// differs ("migrate this project" versus "upgrade this binary"), and while both arrived as prose and
// exit 1 an automated caller could act on neither — which is what turned a binary upgrade into a
// deadlock across every existing book (row 174, SchemaMismatchError).
if current != len(migrations) {
s.Close()
return nil, &SchemaMismatchError{Path: path, Found: current, Expected: len(migrations)}
}
return s, nil
}
func (s *Store) Close() error {
err1 := s.w.Close()
var err2 error
if s.r != s.w {
err2 = s.r.Close()
}
releaseLock(s.lock)
if err1 != nil {
return err1
}
return err2
}
// ErrLocked is "another process owns this project right now". It is a distinct sentinel because it is
// the one open failure that is neither the caller's fault nor a broken project: the right answer is to
// come back later, and an automated caller that cannot tell it from "this book is corrupt" acts on the
// book instead of on the clock (PD-196).
var ErrLocked = errors.New("store: project database is in use by another tmctl process")
// acquireLock takes a non-blocking exclusive flock. Kernel releases it on
// process death (kill -9 included), so a crashed run never wedges the
// project.
func acquireLock(path string) (*os.File, error) {
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o644)
if err != nil {
return nil, fmt.Errorf("store: open lock file: %w", err)
}
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
f.Close()
return nil, fmt.Errorf("%w (lock %s): %w", ErrLocked, path, err)
}
return f, nil
}
// ProjectLock is the project's exclusive flock held WITHOUT opening the database.
//
// It exists for the $0 commands that mutate a project's files but not its rows — `tmctl bank-apply`,
// which rewrites the two owner-decision YAMLs. They need the same arbiter a run uses (one process owns
// the project) and must not have the side effects a write Open carries: applying pending migrations and
// zeroing the reservations of a crashed run are decisions of their own, and a decisions verb is not the
// place to take them.
//
// Non-blocking, like every other taker of this lock: a caller that would wait is a caller that has
// already lost the answer it wanted, and "come back later" is a distinct exit code precisely so it can
// be retried rather than queued (ExitProjectLocked / D39.134 п.2а).
type ProjectLock struct{ f *os.File }
// LockProject takes the exclusive flock of the project whose database is at dbPath. It returns ErrLocked
// (wrapped) when another process holds it. The lock FILE is created if absent, exactly as Open does —
// a project that has never been run has no database and no lock file yet, and it can still be decided
// about.
func LockProject(dbPath string) (*ProjectLock, error) {
f, err := acquireLock(dbPath + ".lock")
if err != nil {
return nil, err
}
return &ProjectLock{f: f}, nil
}
// Release drops the lock. Safe on a nil receiver so a caller can defer it unconditionally.
func (l *ProjectLock) Release() {
if l == nil {
return
}
releaseLock(l.f)
l.f = nil
}
func releaseLock(f *os.File) {
if f == nil {
return
}
_ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
_ = f.Close()
}
func opContext() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), opTimeout)
}
// queryAll runs a read query and scans every row into a slice (package №4 — five
// isomorphic scan loops; new read-models for D15.2/the annotator get it for free).
// Guarantees a hand-written loop forgets: rows.Err() is always checked (a driver
// error MID-iteration cannot silently truncate the result), the rows are
// MATERIALIZED under the op timeout (lazy iteration at the caller would outlive
// defer cancel() and break with "context canceled" mid-read — a real acceptance
// finding), rows.Close is always called.
func queryAll[T any](db *sql.DB, query string, scan func(*sql.Rows) (T, error), args ...any) ([]T, error) {
ctx, cancel := opContext()
defer cancel()
rows, err := db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []T
for rows.Next() {
v, err := scan(rows)
if err != nil {
return nil, err
}
out = append(out, v)
}
return out, rows.Err()
}
// recoverReservations zeroes reserved_usd left over by a crashed process. The
// project file is owned by ONE process at a time (CLI), so any reservation
// present at open belongs to a run that never settled — the recovery pass from
// implementation-notes §3.3.
func (s *Store) recoverReservations(ctx context.Context) error {
res, err := s.w.ExecContext(ctx, `UPDATE spend SET reserved_usd = 0 WHERE reserved_usd > 0`)
if err != nil {
return fmt.Errorf("store: recover reservations: %w", err)
}
if n, _ := res.RowsAffected(); n > 0 {
// stderr, NOT stdout: stdout is the useful translation/report output, and
// this line must not pollute a redirected result (review
// finding). No logger is threaded in here (Open runs before it is built).
fmt.Fprintf(os.Stderr, "store: recovered %d stale reservation row(s) from a previous run\n", n)
}
return nil
}