450 lines
17 KiB
Go
450 lines
17 KiB
Go
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)
|
||
}
|
||
}
|