// 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 .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 }