textmachine/platform/internal/pgstore/exports_test.go

423 lines
16 KiB
Go

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)
}
}
}