423 lines
16 KiB
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)
|
|
}
|
|
}
|
|
}
|