textmachine/backend/internal/store/store.go

230 lines
10 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).
//
// Семантика денег и миграций портирована из vojo store.go; сам код написан
// заново под SQLite (advisory-локов здесь нет — сериализацию даёт write-пул
// из одного соединения + BEGIN IMMEDIATE; 03-implementation-notes §2 п.5).
//
// Рецепт конкурентности (§3.6): на файл БД два пула — write (MaxOpenConns=1,
// транзакции immediate) и read (N соединений); прагмы на каждое соединение
// через DSN: WAL, busy_timeout, foreign_keys, synchronous(NORMAL — переживает
// kill -9; отключение питания вне модели угроз MVP).
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 — «один процесс
// владеет файлом проекта» здесь принуждается, а не предполагается. Без замка
// параллельный `tmctl report` во время перевода обнулил бы recovery-проходом
// живые резервы бегущего процесса и ослепил бы его потолки (находка ревью).
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. Раньше эти команды шли через Open → эксклюзивный
// flock, и оператор был заперт от status РОВНО на время живого прогона — «висит
// 300-я глава, а посмотреть нечем» (боль smoke-прогона пакета №4). WAL штатно
// допускает конкурентных читателей при живом писателе; замок нужен ТОЛЬКО из-за
// recoverReservations (обнулил бы живые резервы бегущего процесса) — а read-путь
// этот проход не выполняет, поэтому и замок ему не нужен.
//
// Гарантии: соединения жёстко read-only (PRAGMA query_only=1 — случайная запись
// падает громко, а не портит состояние живого писателя); отсутствующая БД — громкая
// ошибка (не создаём пустую); схема старше бинаря — громкая ошибка «прогоните
// write-команду» (миграции применяет только писатель). Известная честная граница:
// после краха прогона reserved_usd остаётся ненулевым до следующей WRITE-команды
// (recovery-проход только там) — status покажет этот хвост как 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)
}
// Без journal_mode(WAL): режим журнала — свойство ФАЙЛА (Open всегда создаёт
// WAL, флаг персистентен), а попытка его выставить на query_only-соединении
// была бы записью. foreign_keys/synchronous читателю нерелевантны.
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 намеренно указывает на тот же query_only-пул: заблудившаяся запись через
// read-only Store вернёт SQLITE_READONLY громко, а не nil-panic и не тихий успех.
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 (пустая/битая база?): %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 — обновите tmctl (читать новую схему старым кодом небезопасно)",
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 включительно), 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 (пакет №4 — пять
// изоморфных scan-циклов; новые read-models D15.2/аннотатора получают его даром).
// Гарантии, которые рукописный цикл забывает: rows.Err() всегда проверен (ошибка
// драйвера ПОСРЕДИ итерации не может молча усечь результат), строки
// МАТЕРИАЛИЗУЮТСЯ под op-таймаутом (ленивая итерация у вызывающего пережила бы
// defer cancel() и рвалась «context canceled» посреди чтения — находка реальной
// приёмки), rows.Close всегда закрыт.
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 из
// 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, НЕ stdout: stdout — это полезный вывод перевода/отчёта, и
// эта строка не должна засорять перенаправленный результат (находка
// ревью). Логгер сюда не прокинут (Open — до его построения).
fmt.Fprintf(os.Stderr, "store: recovered %d stale reservation row(s) from a previous run\n", n)
}
return nil
}