textmachine/backend/internal/store/migrate_test.go

450 lines
17 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
import (
"database/sql"
"errors"
"os"
"path/filepath"
"testing"
"time"
)
// migrate_test.go pins the mechanics `tmctl migrate` stands on (backlog row 174): a database an OLDER
// binary wrote converges to head and unlocks the read-only path, the MONEY it holds survives the
// conversion untouched, a database a NEWER binary wrote is refused before anything is written, and a
// step that fails leaves nothing of itself behind.
// atVersion runs fn with the migration chain TRUNCATED to v steps, so anything opened inside it sees
// exactly the database a binary of that vintage would have written and worked with.
//
// The fixture truncates the chain instead of deleting rows from schema_version because those are not the
// same thing: rolling the counter back leaves HEAD's tables in place and makes the next open re-apply
// the newest step, which backlog row 49а says is not idempotent for an ALTER. Truncation also keeps the
// fixture correct when a new migration lands — it never names a version or a table.
//
// It mutates a package-level var, which is safe because these tests do not run in parallel (nothing in
// this package calls t.Parallel).
func atVersion(t *testing.T, v int, fn func()) {
t.Helper()
full := migrations
migrations = migrations[:v]
defer func() { migrations = full }()
fn()
}
// openAtVersion creates a project database exactly as a binary with v migrations would have left it.
func openAtVersion(t *testing.T, path string, v int) {
t.Helper()
atVersion(t, v, func() {
s, err := Open(path)
if err != nil {
t.Fatal(err)
}
if err := s.Close(); err != nil {
t.Fatal(err)
}
})
}
// rawVersion reads MAX(schema_version) through its own connection, deliberately NOT through the
// product's own reader: the state a migration left behind must be asserted independently of the code
// that reports it, and this way it can also be read from a database the product is currently refusing.
func rawVersion(t *testing.T, path string) int {
t.Helper()
db, err := sql.Open("sqlite", "file:"+path)
if err != nil {
t.Fatal(err)
}
defer db.Close()
var v int
if err := db.QueryRow(`SELECT COALESCE(MAX(version), 0) FROM schema_version`).Scan(&v); err != nil {
t.Fatal(err)
}
return v
}
func TestMigrateBringsAnOlderProjectToHeadAndUnlocksTheReadPath(t *testing.T) {
head := SchemaHead()
path := filepath.Join(t.TempDir(), "book.db")
openAtVersion(t, path, head-1)
// The deadlock itself: the read-only projections the platform calls before every spawn refuse a
// database an older binary wrote, and they never migrate it.
_, err := OpenReadOnly(path)
var mismatch *SchemaMismatchError
if !errors.As(err, &mismatch) {
t.Fatalf("a read-only open of an older schema must return the typed refusal, got %v", err)
}
if mismatch.Found != head-1 || mismatch.Expected != head || !mismatch.Stale() {
t.Fatalf("found=%d expected=%d stale=%v, want %d/%d/true", mismatch.Found, mismatch.Expected, mismatch.Stale(), head-1, head)
}
applied, err := Migrate(path, nil)
if err != nil {
t.Fatalf("migrate: %v", err)
}
if applied.From != head-1 || applied.To != head {
t.Fatalf("transition %+v, want v%d -> v%d", applied, head-1, head)
}
if got := rawVersion(t, path); got != head {
t.Fatalf("schema is at v%d after migrating, want v%d", got, head)
}
ro, err := OpenReadOnly(path)
if err != nil {
t.Fatalf("the read-only path must work after a migration: %v", err)
}
ro.Close()
}
func TestMigrateIsANoOpOnAProjectAlreadyAtHead(t *testing.T) {
head := SchemaHead()
path := filepath.Join(t.TempDir(), "book.db")
openAtVersion(t, path, head)
for i := range 2 {
applied, err := Migrate(path, nil)
if err != nil {
t.Fatalf("call %d: %v", i, err)
}
if applied.From != head || applied.To != head {
t.Fatalf("call %d: transition %+v, want a no-op at v%d", i, applied, head)
}
}
}
func TestMigrateCreatesAProjectThatWasNeverRun(t *testing.T) {
// The deploy sweep runs over every book, including one that has been accepted but never translated.
// Creating it at head is what every other write open already does on first touch, and it keeps the
// sweep uniform instead of making the caller ask which books exist yet.
path := filepath.Join(t.TempDir(), "fresh.db")
applied, err := Migrate(path, nil)
if err != nil {
t.Fatal(err)
}
if applied.From != 0 || applied.To != SchemaHead() {
t.Fatalf("transition %+v, want v0 -> v%d", applied, SchemaHead())
}
ro, err := OpenReadOnly(path)
if err != nil {
t.Fatalf("a created project must be readable at head: %v", err)
}
ro.Close()
}
// TestMigrateKeepsCommittedMoneyAndZeroesOnlyTheStaleReservation is the money test.
//
// It is the one property the platform's ratified ceiling formula rests on (PD-158: the argument is
// committed + increment, WITHOUT reserved, precisely because a write open zeroes leftover reservations
// and a read-only status does not). A migration is a write open, so it performs that recovery — and it
// must move `committed` by not one cent while doing it, or every ceiling computed from a status taken
// after a deploy would be wrong.
func TestMigrateKeepsCommittedMoneyAndZeroesOnlyTheStaleReservation(t *testing.T) {
head := SchemaHead()
path := filepath.Join(t.TempDir(), "book.db")
caps := Ceilings{BookUSD: 10, DayUSD: 10}
// A book an older binary ran: one settled call, and one reservation whose process died before it
// settled — exactly the state a crashed or killed run leaves behind.
atVersion(t, head-1, func() {
s, err := Open(path)
if err != nil {
t.Fatal(err)
}
defer s.Close()
job := mustSnapshotAndJob(t, s)
res, verdict, err := s.Reserve("book", 0.10, caps)
if err != nil || verdict != ReserveOK {
t.Fatalf("reserve: %v %v", verdict, err)
}
cp := Checkpoint{RequestHash: "h1", JobID: job.ID, Stage: "draft", Role: "translator",
ModelRequested: "m", ModelActual: "m", ResponseText: "текст", UsageJSON: "{}", CostUSD: 0.04}
if err := s.SettleWithCheckpoint(res, 0.04, cp, nil); err != nil {
t.Fatal(err)
}
if _, verdict, err = s.Reserve("book", 0.25, caps); err != nil || verdict != ReserveOK {
t.Fatalf("stale reserve: %v %v", verdict, err)
}
committed, reserved, err := s.SpentUSD("book")
if err != nil || committed != 0.04 || reserved != 0.25 {
t.Fatalf("fixture: committed=%v reserved=%v err=%v", committed, reserved, err)
}
})
if _, err := Migrate(path, nil); err != nil {
t.Fatalf("migrate: %v", err)
}
// Twice: the second call is the idempotency half — a deploy sweep that runs over a book it already
// migrated must not move money either.
for i := range 2 {
if i == 1 {
if _, err := Migrate(path, nil); err != nil {
t.Fatalf("repeat migrate: %v", err)
}
}
ro, err := OpenReadOnly(path)
if err != nil {
t.Fatalf("call %d: %v", i, err)
}
committed, reserved, err := ro.SpentUSD("book")
ro.Close()
if err != nil {
t.Fatal(err)
}
if committed != 0.04 {
t.Fatalf("call %d: committed moved to %v — a migration must not touch settled money", i, committed)
}
if reserved != 0 {
t.Fatalf("call %d: reserved=%v, want 0 — the write open owes the recovery pass", i, reserved)
}
}
if got := rawVersion(t, path); got != head {
t.Fatalf("schema v%d after migrating, want v%d", got, head)
}
// The checkpoint the money is anchored to is still there: `committed == SUM(checkpoints)` is the
// invariant the platform settles from, so a migration that kept the number and lost the row would
// pass the assertion above and still have broken the book.
ro, err := OpenReadOnly(path)
if err != nil {
t.Fatal(err)
}
defer ro.Close()
cp, err := ro.GetCheckpoint("h1")
if err != nil || cp == nil || cp.CostUSD != 0.04 {
t.Fatalf("checkpoint after migration: %+v err=%v", cp, err)
}
}
// TestOpenRefusesAProjectNewerThanTheBinary: the write path used to accept a database it could not
// understand and would then have written through pre-dating code. It is refused in both open modes, and
// the refusal happens BEFORE the recovery pass — a live reservation still standing afterwards is the
// proof that nothing of the refused process reached the file.
func TestOpenRefusesAProjectNewerThanTheBinary(t *testing.T) {
head := SchemaHead()
path := filepath.Join(t.TempDir(), "book.db")
s, err := Open(path)
if err != nil {
t.Fatal(err)
}
if _, verdict, err := s.Reserve("book", 0.25, Ceilings{BookUSD: 10, DayUSD: 10}); err != nil || verdict != ReserveOK {
t.Fatalf("reserve: %v %v", verdict, err)
}
s.Close()
db, err := sql.Open("sqlite", "file:"+path)
if err != nil {
t.Fatal(err)
}
if _, err := db.Exec(`INSERT INTO schema_version (version) VALUES (?)`, head+7); err != nil {
t.Fatal(err)
}
db.Close()
for name, open := range map[string]func(string) (*Store, error){"write": Open, "read-only": OpenReadOnly} {
st, err := open(path)
if err == nil {
st.Close()
t.Fatalf("%s open of a newer schema must be refused", name)
}
var mismatch *SchemaMismatchError
if !errors.As(err, &mismatch) {
t.Fatalf("%s open: %v, want a typed SchemaMismatchError", name, err)
}
if mismatch.Found != head+7 || mismatch.Expected != head || mismatch.Stale() {
t.Fatalf("%s open: found=%d expected=%d stale=%v", name, mismatch.Found, mismatch.Expected, mismatch.Stale())
}
}
if _, err := Migrate(path, nil); !errors.As(err, new(*SchemaMismatchError)) {
t.Fatalf("migrate of a newer schema must be refused with the typed error, got %v", err)
}
// Nothing was written: the reservation the fixture left standing is still standing, so the recovery
// pass never ran on a schema this binary does not know.
ro, err := sql.Open("sqlite", "file:"+path)
if err != nil {
t.Fatal(err)
}
defer ro.Close()
var reserved float64
if err := ro.QueryRow(`SELECT COALESCE(SUM(reserved_usd), 0) FROM spend WHERE book_id = 'book'`).Scan(&reserved); err != nil {
t.Fatal(err)
}
if reserved != 0.25 {
t.Fatalf("reserved=%v after three refused opens, want the fixture's 0.25 untouched", reserved)
}
}
// TestAFailedMigrationStepLeavesTheDatabaseAtItsPreviousVersion answers, by execution, the question
// `tmctl migrate` raises: is APPLYING a step atomic? It is — DDL is transactional in SQLite and the
// version row is inserted in the SAME transaction as the step — so a process that dies mid-migration
// leaves a database at its previous version rather than a half-applied one whose ALTER steps (row 49а)
// will not converge on the next try. The migration bodies themselves are untouched by this pack.
func TestAFailedMigrationStepLeavesTheDatabaseAtItsPreviousVersion(t *testing.T) {
head := SchemaHead()
path := filepath.Join(t.TempDir(), "book.db")
// A step whose FIRST statement succeeds and whose second does not: if application were not atomic,
// `probe` would outlive the failure. Restored with defer, so a panic inside Open cannot leave the
// package-level chain rewritten for whatever runs next.
full := migrations
defer func() { migrations = full }()
migrations = append(append([]string(nil), migrations...), `CREATE TABLE probe (x INTEGER); THIS IS NOT SQL;`)
s, err := Open(path)
if err == nil {
s.Close()
t.Fatal("a migration step that does not execute must fail the open")
}
if got := rawVersion(t, path); got != head {
t.Fatalf("schema recorded v%d after a failed step, want the previous v%d", got, head)
}
db, err := sql.Open("sqlite", "file:"+path)
if err != nil {
t.Fatal(err)
}
defer db.Close()
var probes int
if err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'probe'`).Scan(&probes); err != nil {
t.Fatal(err)
}
if probes != 0 {
t.Fatal("the failed step's first statement survived: applying a migration is not atomic")
}
}
// TestADatabaseWithNothingAppliedIsAVersionAndNotAnError: a file that exists but has no
// schema_version table — a process killed between creating one and committing the first migration —
// used to fail the read path with prose and exit 1, i.e. as an unrepairable project. It is version 0:
// the SAME typed refusal as any other mismatch, so the same "caught it → migrate → retry" repairs it.
func TestADatabaseWithNothingAppliedIsAVersionAndNotAnError(t *testing.T) {
dir := t.TempDir()
empty := filepath.Join(dir, "empty.db")
if err := os.WriteFile(empty, nil, 0o644); err != nil {
t.Fatal(err)
}
_, err := OpenReadOnly(empty)
var mismatch *SchemaMismatchError
if !errors.As(err, &mismatch) {
t.Fatalf("a database with nothing applied must be the typed refusal, got %v", err)
}
if mismatch.Found != 0 || mismatch.Expected != SchemaHead() || !mismatch.Stale() {
t.Fatalf("found=%d expected=%d stale=%v, want 0/%d/true", mismatch.Found, mismatch.Expected, mismatch.Stale(), SchemaHead())
}
if _, err := Migrate(empty, nil); err != nil {
t.Fatalf("and a migration must repair it: %v", err)
}
ro, err := OpenReadOnly(empty)
if err != nil {
t.Fatalf("read path after the repair: %v", err)
}
ro.Close()
// A file that is not a database at all must NOT read as "version 0, migrate me".
junk := filepath.Join(dir, "junk.db")
if err := os.WriteFile(junk, []byte("this is not a database, it is a text file"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := OpenReadOnly(junk); err == nil || errors.As(err, &mismatch) {
t.Fatalf("a corrupt file must fail loudly and NOT as a schema mismatch, got %v", err)
}
}
// TestTheMigrationSeamRunsUnderTheLockAndOnlyWhenAStepWillRun pins the placement `tmctl migrate` takes
// its restore point in. The three properties are what make a backup a backup: it runs BEFORE the first
// step (a copy taken afterwards is not a restore point), only when there IS a step (else a repeat call
// litters, and its second-precision stamp collides), and inside the lock (else a project a live run
// owns is copied in full for a migration that is refused anyway — once per retry).
func TestTheMigrationSeamRunsUnderTheLockAndOnlyWhenAStepWillRun(t *testing.T) {
head := SchemaHead()
path := filepath.Join(t.TempDir(), "book.db")
openAtVersion(t, path, head-1)
var seen []Migration
versionAtHook := -1
applied, err := Migrate(path, func(m Migration) error {
seen = append(seen, m)
versionAtHook = rawVersion(t, path)
// The lock is ours while the hook runs: a second opener must be turned away, which is what
// makes "back up here" different from "back up before calling".
if s, err := Open(path); !errors.Is(err, ErrLocked) {
if err == nil {
s.Close()
}
t.Errorf("the seam must run INSIDE the project lock; a concurrent open got %v", err)
}
return nil
})
if err != nil {
t.Fatal(err)
}
if len(seen) != 1 || seen[0] != (Migration{From: head - 1, To: head}) {
t.Fatalf("the seam saw %+v, want exactly one v%d -> v%d", seen, head-1, head)
}
if versionAtHook != head-1 {
t.Fatalf("the database was at v%d when the seam ran, want the PRE-migration v%d", versionAtHook, head-1)
}
if applied.From != head-1 || applied.To != head {
t.Fatalf("transition %+v", applied)
}
// Nothing left to apply → the seam does not run at all.
seen = nil
if _, err := Migrate(path, func(m Migration) error { seen = append(seen, m); return nil }); err != nil {
t.Fatal(err)
}
if len(seen) != 0 {
t.Fatalf("the seam ran %d time(s) on a project already at head", len(seen))
}
}
// TestTheSeamIsNotChargedToTheStoreOperationBudget: the seam's work is the CALLER's (a restore point
// whose cost scales with the book), and opTimeout bounds operations that are O(1) in the size of the
// book. While the two shared one deadline, a large project failed the migration on the copy it had just
// paid for — «context deadline exceeded» on the first step, exit 1 outside the refusal band, and a
// retry that copied the whole database again (round-2 review, reproduced on 2.6 GB).
//
// The budget is shrunk instead of the seam sleeping ten real seconds: same property, no dead time in
// the battery. It is restored on the way out and nothing here runs in parallel.
func TestTheSeamIsNotChargedToTheStoreOperationBudget(t *testing.T) {
head := SchemaHead()
defer func(original time.Duration) { opTimeout = original }(opTimeout)
opTimeout = 150 * time.Millisecond
path := filepath.Join(t.TempDir(), "book.db")
openAtVersion(t, path, head-1)
applied, err := Migrate(path, func(Migration) error {
time.Sleep(3 * opTimeout) // a restore point on a book far larger than the budget
return nil
})
if err != nil {
t.Fatalf("a slow seam must not fail the migration it protects: %v", err)
}
if applied.From != head-1 || applied.To != head {
t.Fatalf("transition %+v, want v%d -> v%d", applied, head-1, head)
}
if got := rawVersion(t, path); got != head {
t.Fatalf("schema at v%d, want v%d", got, head)
}
}
// TestASeamFailureAbortsTheMigrationWithNothingApplied: a caller that cannot take its restore point is
// telling the engine not to migrate — the answer is to stop with the database exactly as it was, not to
// proceed unprotected.
func TestASeamFailureAbortsTheMigrationWithNothingApplied(t *testing.T) {
head := SchemaHead()
path := filepath.Join(t.TempDir(), "book.db")
openAtVersion(t, path, head-1)
refuse := errors.New("no room for a restore point")
if _, err := Migrate(path, func(Migration) error { return refuse }); !errors.Is(err, refuse) {
t.Fatalf("the seam's error must abort the migration, got %v", err)
}
if got := rawVersion(t, path); got != head-1 {
t.Fatalf("schema moved to v%d after a refused seam, want v%d", got, head-1)
}
// And the lock is released, so the next attempt is not locked out by the failed one.
if _, err := Migrate(path, nil); err != nil {
t.Fatalf("a retry after a refused seam: %v", err)
}
}