package pgstore import ( "context" "errors" "testing" "time" ) // The creating call is ONE transaction: the export row and the queue entry that builds it either // both exist or neither does. A row with no job is a poll the stale sweep has to end minutes later // on the wrong reason; a job with no row is a worker that reads nothing. // // Mutation caught: moving the enqueue outside inTx, or ignoring its error. func TestCreatingAnExportWritesTheRowAndItsBuildTogether(t *testing.T) { s, ctx := testDB(t) seedUser(t, s, ctx, "u1") seedBook(t, s, ctx, "bk1", "u1", 10) now := time.Now().UTC().Truncate(time.Millisecond) var enqueued string e, err := s.CreateExport(ctx, "bk1", "epub", now, func(_ context.Context, _ Tx, id string) error { enqueued = id return nil }) if err != nil { t.Fatal(err) } if e.ID == "" || e.State != ExportPending || e.Format != "epub" || e.BookID != "bk1" { t.Fatalf("created %+v", e) } if enqueued != e.ID { t.Errorf("the queue was handed %q and the row is %q", enqueued, e.ID) } // A failing enqueue takes the ROW with it. boom := errors.New("the queue is unreachable") if _, err := s.CreateExport(ctx, "bk1", "txt", now, func(context.Context, Tx, string) error { return boom }); !errors.Is(err, boom) { t.Fatalf("CreateExport with a failing enqueue = %v", err) } var txt int if err := s.pool.QueryRow(ctx, `select count(*) from exports where format = 'txt'`).Scan(&txt); err != nil { t.Fatal(err) } if txt != 0 { t.Errorf("%d rows survived an admission whose queue entry failed", txt) } } // The book's revision travels with the row, in the same statement. Every book-scoped response the // canon defines carries it, and a number fetched afterwards would be from a different instant than // the row it stamps. func TestAnExportCarriesTheBooksRevisionFromTheSameInstantAsItsRow(t *testing.T) { s, ctx := testDB(t) seedUser(t, s, ctx, "u1") seedBook(t, s, ctx, "bk1", "u1", 10) exec(t, s, ctx, `update books set revision = 1841 where id = 'bk1'`) now := time.Now().UTC().Truncate(time.Millisecond) e, err := s.CreateExport(ctx, "bk1", "epub", now, func(context.Context, Tx, string) error { return nil }) if err != nil { t.Fatal(err) } if e.Revision != 1841 { t.Errorf("created export revision %d, want the book's 1841", e.Revision) } read, err := s.ReadExport(ctx, "u1", "bk1", e.ID) if err != nil { t.Fatal(err) } if read.Revision != 1841 { t.Errorf("read export revision %d, want 1841", read.Revision) } } // Ownership of an export IS ownership of its book, and the check is inside the statement. A second // account's read is answered exactly as a read of an export that does not exist — two different // answers would tell a stranger whose book it is (API1 BOLA). // // Mutation caught: dropping `b.owner_id = $3` from ReadExportForOwner, or the `e.book_id = $2` // beside it (which would let an export of one book be read through another's address). func TestAnExportIsOnlyReadableThroughItsOwnBookAndItsOwnAccount(t *testing.T) { s, ctx := testDB(t) seedUser(t, s, ctx, "u1") seedUser(t, s, ctx, "u2") seedBook(t, s, ctx, "bk1", "u1", 10) seedBook(t, s, ctx, "bk2", "u2", 10) now := time.Now().UTC() e, err := s.CreateExport(ctx, "bk1", "epub", now, func(context.Context, Tx, string) error { return nil }) if err != nil { t.Fatal(err) } for _, tc := range []struct{ user, book string }{ {"u2", "bk1"}, // another account, the right book {"u1", "bk2"}, // the right account, another book {"u2", "bk2"}, } { if _, err := s.ReadExport(ctx, tc.user, tc.book, e.ID); !errors.Is(err, ErrNoExport) { t.Errorf("ReadExport(%s, %s) = %v, want ErrNoExport", tc.user, tc.book, err) } } if _, err := s.ReadExport(ctx, "u1", "bk1", e.ID); err != nil { t.Errorf("the owner could not read its own export: %v", err) } } // The first verdict stands. Both writes carry `state = 'pending'` in their WHERE, which is what // makes the worker and the stale sweep safe together: whichever gets there first decides, and the // loser is TOLD it lost — so a build that came back late knows to delete the file nobody will serve. // // Mutation caught: dropping the `state = 'pending'` guard from either write. func TestOnlyThePendingRowCanBeSettledAndTheLoserIsToldItLost(t *testing.T) { s, ctx := testDB(t) seedUser(t, s, ctx, "u1") seedBook(t, s, ctx, "bk1", "u1", 10) now := time.Now().UTC().Truncate(time.Millisecond) mk := func() string { e, err := s.CreateExport(ctx, "bk1", "epub", now, func(context.Context, Tx, string) error { return nil }) if err != nil { t.Fatal(err) } return e.ID } won := mk() if err := s.FinishExport(ctx, won, "/a/exp.epub", 42, false, now, now.Add(time.Hour)); err != nil { t.Fatal(err) } if err := s.FailExport(ctx, won, "build_failed", now); !errors.Is(err, ErrExportSettled) { t.Errorf("failing an already published export = %v, want ErrExportSettled", err) } e, err := s.ReadExport(ctx, "u1", "bk1", won) if err != nil { t.Fatal(err) } if e.State != ExportReady || e.Path != "/a/exp.epub" || e.FailureCode != "" { t.Errorf("published export %+v", e) } if e.SizeBytes == nil || *e.SizeBytes != 42 || e.Complete == nil || *e.Complete { t.Errorf("size/complete came back as %v/%v: a marked copy must be recorded as marked", e.SizeBytes, e.Complete) } lost := mk() if err := s.FailExport(ctx, lost, "build_interrupted", now); err != nil { t.Fatal(err) } if err := s.FinishExport(ctx, lost, "/a/late.epub", 7, true, now, now.Add(time.Hour)); !errors.Is(err, ErrExportSettled) { t.Errorf("publishing over a failed export = %v, want ErrExportSettled", err) } e, err = s.ReadExport(ctx, "u1", "bk1", lost) if err != nil { t.Fatal(err) } if e.State != ExportFailed || e.FailureCode != "build_interrupted" || e.Path != "" { t.Errorf("a late build resurrected a failed export: %+v", e) } // A pending row carries neither: "nobody has looked yet" is not "the file has a hole". pending := mk() e, err = s.ReadExport(ctx, "u1", "bk1", pending) if err != nil { t.Fatal(err) } if e.SizeBytes != nil || e.Complete != nil || e.ExpiresAt != nil || e.FinishedAt != nil { t.Errorf("a pending export already claims %+v", e) } } // The GC's two questions, each against rows that should NOT move as well as rows that should. The // expiry hands back the paths so the caller can delete the bytes, and it clears `path` in the same // statement — a row that still names a file it no longer owns is how a later sweep deletes somebody // else's artifact. // // Mutation caught: dropping the `expires_at <= now` bound (every ready export is expired), dropping // `state = 'ready'` (a failed row is "expired"), or not clearing `path`. func TestTheGarbageCollectorOnlyTakesWhatHasActuallyLapsed(t *testing.T) { s, ctx := testDB(t) seedUser(t, s, ctx, "u1") seedBook(t, s, ctx, "bk1", "u1", 10) now := time.Now().UTC().Truncate(time.Millisecond) mk := func(at time.Time) string { e, err := s.CreateExport(ctx, "bk1", "epub", at, func(context.Context, Tx, string) error { return nil }) if err != nil { t.Fatal(err) } return e.ID } lapsed := mk(now) if err := s.FinishExport(ctx, lapsed, "/a/old.epub", 1, true, now, now.Add(-time.Minute)); err != nil { t.Fatal(err) } live := mk(now) if err := s.FinishExport(ctx, live, "/a/new.epub", 1, true, now, now.Add(time.Hour)); err != nil { t.Fatal(err) } fresh := mk(now) old := mk(now.Add(-time.Hour)) n, err := s.ExpireExports(ctx, now) if err != nil { t.Fatal(err) } if n != 1 { t.Fatalf("the sweep expired %d rows, want only the lapsed one", n) } e, err := s.ReadExport(ctx, "u1", "bk1", lapsed) if err != nil { t.Fatal(err) } // ⚠ The path is KEPT until the bytes are gone. Clearing it here made the unlink a one-shot act: // a failed removal left a file no later pass could name, because every other read selects `ready`. if e.State != ExportExpired || e.Path != "/a/old.epub" { t.Errorf("an expired row lost the path of the file the GC still owes an unlink: %+v", e) } if e2, _ := s.ReadExport(ctx, "u1", "bk1", live); e2.State != ExportReady { t.Errorf("a live link was expired: %+v", e2) } owed, err := s.Unlinked(ctx) if err != nil { t.Fatal(err) } if len(owed) != 1 || owed[0].ID != lapsed || owed[0].Path != "/a/old.epub" { t.Fatalf("the GC owes %v, want the one lapsed artifact", owed) } if err := s.ForgetExportPath(ctx, lapsed); err != nil { t.Fatal(err) } if again, _ := s.Unlinked(ctx); len(again) != 0 { t.Errorf("a forgotten artifact is still owed: %v", again) } ids, err := s.FailStaleExports(ctx, "build_interrupted", now.Add(-30*time.Minute), now.Add(-30*time.Minute), now) if err != nil { t.Fatal(err) } if len(ids) != 1 || ids[0] != old { t.Fatalf("the stale sweep ended %v, want only the build nobody came back for (%s)", ids, old) } if e3, _ := s.ReadExport(ctx, "u1", "bk1", fresh); e3.State != ExportPending { t.Errorf("a build that is merely slow was given up on: %+v", e3) } if e4, _ := s.ReadExport(ctx, "u1", "bk1", old); e4.State != ExportFailed || e4.FailureCode != "build_interrupted" { t.Errorf("the stale build's poll did not end with a reason: %+v", e4) } } // The two clocks, and the one that was missing. A build a worker has PICKED UP is judged on how long // it has been silent; a build still IN THE QUEUE is judged on a much longer grace, because one queue // serves spawns, parses and builds and a backlog is a wait rather than a fault. With one clock, an // export queued behind three parses is buried and its user told it was interrupted — which is false. // // Mutation caught: judging a claimed build on `requested_at`; judging a queued one on the started // clock; dropping either arm of the predicate. func TestAQueuedBuildAndAClaimedOneAreJudgedOnDifferentClocks(t *testing.T) { s, ctx := testDB(t) seedUser(t, s, ctx, "u1") seedBook(t, s, ctx, "bk1", "u1", 10) now := time.Now().UTC().Truncate(time.Millisecond) mk := func(at time.Time) string { e, err := s.CreateExport(ctx, "bk1", "epub", at, func(context.Context, Tx, string) error { return nil }) if err != nil { t.Fatal(err) } return e.ID } // Requested long ago and never picked up: a queue backlog, not a fault. queued := mk(now.Add(-40 * time.Minute)) // Requested at the same instant and CLAIMED long ago: its worker has been silent since. silent := mk(now.Add(-40 * time.Minute)) if err := s.ClaimExportBuild(ctx, silent, now.Add(-35*time.Minute)); err != nil { t.Fatal(err) } // Requested long ago and claimed just now: busy, not lost. busy := mk(now.Add(-40 * time.Minute)) if err := s.ClaimExportBuild(ctx, busy, now); err != nil { t.Fatal(err) } ids, err := s.FailStaleExports(ctx, "build_interrupted", now.Add(-20*time.Minute), // a claimed build may be silent this long now.Add(-60*time.Minute), // a queued one may wait this long now) if err != nil { t.Fatal(err) } if len(ids) != 1 || ids[0] != silent { t.Fatalf("the sweep ended %v, want only the build whose worker went silent (%s)", ids, silent) } for name, id := range map[string]string{"a queued build": queued, "a busy build": busy} { if e, _ := s.ReadExport(ctx, "u1", "bk1", id); e.State != ExportPending { t.Errorf("%s was given up on: %+v", name, e) } } // A claim against a row the sweep has already answered is refused: that is what stops a worker // building for a poll that has ended. if err := s.ClaimExportBuild(ctx, silent, now); !errors.Is(err, ErrExportSettled) { t.Errorf("claiming a settled export = %v, want ErrExportSettled", err) } // And a SECOND worker on a build the first already holds is refused too. Both would build to the // same path — it is derived from the export's id — and the loser's cleanup would delete the file // the winner had just published. Unreachable while one replica works one job of a kind at a time; // this is what makes the second replica a duplicate build rather than a lost artifact. if err := s.ClaimExportBuild(ctx, busy, now); !errors.Is(err, ErrExportSettled) { t.Errorf("a second claim on a build already in progress = %v, want ErrExportSettled", err) } } // A book that goes takes its exports with it: the rows are the only thing that can ever find the // artifacts, so an export outliving its book would be a file nothing serves and nothing expires. func TestExportsGoWithTheirBook(t *testing.T) { s, ctx := testDB(t) seedUser(t, s, ctx, "u1") seedBook(t, s, ctx, "bk1", "u1", 10) if _, err := s.CreateExport(ctx, "bk1", "epub", time.Now().UTC(), func(context.Context, Tx, string) error { return nil }); err != nil { t.Fatal(err) } exec(t, s, ctx, `delete from books where id = 'bk1'`) var left int if err := s.pool.QueryRow(ctx, `select count(*) from exports`).Scan(&left); err != nil { t.Fatal(err) } if left != 0 { t.Errorf("%d exports outlived their book", left) } } // The rollback of 00032 restores what 00002 left, PROVEN by running it rather than by reading the // file. It matters more than an ordinary down path: this migration DROPS a released table and makes // a new one, so a down that forgot to re-create the old shape would leave a rolled-back deployment // with no `exports` table at all — and goose would then have nothing to roll forward onto. // // ⚠ The whole-set rollback is covered by TestMigrationsRollBackAndReapply; what is asserted here is // the SHAPE at the version in between, which no other test can see. func TestTheExportRollbackPutsBackTheTableItReplaced(t *testing.T) { s, ctx, dsn := testDBWithDSN(t) p, closeProvider, err := newProvider(dsn) if err != nil { t.Fatal(err) } defer closeProvider() columns := func() map[string]bool { rows, err := s.pool.Query(ctx, `select column_name from information_schema.columns where table_name = 'exports'`) if err != nil { t.Fatal(err) } defer rows.Close() out := map[string]bool{} for rows.Next() { var c string if err := rows.Scan(&c); err != nil { t.Fatal(err) } out[c] = true } return out } if now := columns(); !now["state"] || now["ready"] { t.Fatalf("the migrated shape is not the canon's: %v", now) } if _, err := p.DownTo(ctx, 31); err != nil { t.Fatalf("rolling 00032 back: %v", err) } back := columns() for _, c := range []string{"id", "book_id", "format", "ready", "artifact", "created_at", "ready_at", "failed_reason"} { if !back[c] { t.Errorf("00002's column %q did not come back: a rollback that drops a released table and "+ "does not restore it leaves the deployment with nothing to roll forward onto", c) } } if back["state"] || back["expires_at"] { t.Errorf("the rolled-back table still carries this migration's own columns: %v", back) } if _, err := p.Up(ctx); err != nil { t.Fatalf("re-applying 00032 after the rollback: %v", err) } if again := columns(); !again["state"] || again["ready"] { t.Fatalf("the re-applied shape is not the canon's: %v", again) } } // The worker's read carries whether the book HAS A CHAPTER TREE, and it is a real question against // the real table rather than a constant. // // It matters because the answer decides whether the engine is spawned at all: a book nobody has cut // reaches `tmctl build` as exit 10 (no configuration yet) or exit 11 (a source that cut into // nothing), and neither of those is a word to put in front of a user — the first blames the // deployment, the second is the number the INTAKE acts on destructively. // // Mutation caught: answering a constant instead of asking (the door stops being able to tell a book // with text from one without), or joining the existence check to the wrong book. func TestTheWorkersReadKnowsWhetherTheBookWasEverCut(t *testing.T) { s, ctx := testDB(t) seedUser(t, s, ctx, "u1") seedBook(t, s, ctx, "bk_cut", "u1", 3) seedBook(t, s, ctx, "bk_raw", "u1", 0) exec(t, s, ctx, `insert into chapters (id, book_id, number, units_total) values ('ch1','bk_cut',1,2)`) now := time.Now().UTC() mk := func(book string) string { e, err := s.CreateExport(ctx, book, "epub", now, func(context.Context, Tx, string) error { return nil }) if err != nil { t.Fatal(err) } return e.ID } for book, want := range map[string]bool{"bk_cut": true, "bk_raw": false} { row, err := s.ReadExportForBuild(ctx, mk(book)) if err != nil { t.Fatal(err) } if row.HasTree != want { t.Errorf("%s: HasTree = %v, want %v", book, row.HasTree, want) } } }