textmachine/backend/internal/store/store.go

148 lines
5.2 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
}
func (s *Store) Close() error {
err1 := s.w.Close()
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)
}
// 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
}