// 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 .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(¤t); 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 }