textmachine/backend/internal/store/store.go

109 lines
3.7 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"
"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
}
// Open opens (or creates) the project database at path, applies pending
// migrations and recovers stale reservations from a crashed run.
func Open(path string) (*Store, error) {
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 {
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()
return nil, fmt.Errorf("store: open read pool: %w", err)
}
r.SetMaxOpenConns(4)
s := &Store{w: w, r: r}
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()
if err1 != nil {
return err1
}
return err2
}
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 {
// The caller's logger isn't wired here; surface via the request_log-less
// path is overkill — a plain stderr note keeps it visible.
fmt.Printf("store: recovered %d stale reservation row(s) from a previous run\n", n)
}
return nil
}