256 lines
11 KiB
Go
256 lines
11 KiB
Go
// 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
|
||
}
|
||
|
||
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
|
||
}
|