textmachine/backend/internal/store/store.go

230 lines
8.6 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"
"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.
const 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
}
// 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) {
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}
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
defer cancel()
if err := s.migrate(ctx); err != nil {
s.Close()
return nil, err
}
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 older than the binary is a loud error "run a
// write command" (only the writer applies migrations). 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()
var current int
if err := s.r.QueryRowContext(ctx, `SELECT COALESCE(MAX(version), 0) FROM schema_version`).Scan(&current); err != nil {
s.Close()
return nil, fmt.Errorf("store: read schema version of %s (empty/corrupt database?): %w", path, err)
}
if current < len(migrations) {
s.Close()
return nil, fmt.Errorf("store: %s is at schema v%d, this binary expects v%d — run a write command (`tmctl translate`/`redrive`) to migrate first (read-only open never migrates)",
path, current, len(migrations))
}
if current > len(migrations) {
s.Close()
return nil, fmt.Errorf("store: %s is at schema v%d, NEWER than this binary's v%d — update tmctl (reading a newer schema with old code is unsafe)",
path, current, 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
}
// 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("store: project database is in use by another tmctl process (lock %s): %w", path, err)
}
return 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
}