148 lines
5.2 KiB
Go
148 lines
5.2 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).
|
||
//
|
||
// Семантика денег и миграций портирована из 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
|
||
}
|