299 lines
12 KiB
Go
299 lines
12 KiB
Go
package pgstore
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// The store half of the two walks this pack added: a book through intake, and a run through a stop
|
|
// the user asked for. What is asserted here is what only SQL can get wrong — the conditions on the
|
|
// updates, which are what keep two processes from finishing one walk twice.
|
|
|
|
func upload(t *testing.T, s *Store, ctx context.Context, owner string, now time.Time) Book {
|
|
t.Helper()
|
|
id := NewBookID()
|
|
b, err := s.CreateUpload(ctx, id, NewUpload{OwnerID: owner, Title: "蛊真人", SourceLang: "zh",
|
|
TargetLang: "ru", Workdir: "/srv/books/" + id, Now: now})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return b
|
|
}
|
|
|
|
func TestABookWalksThroughIntakeOnlyForwards(t *testing.T) {
|
|
s, ctx := testDB(t)
|
|
now := fundedAccount(t, s, ctx, "u1", "10")
|
|
b := upload(t, s, ctx, "u1", now)
|
|
if b.Status != "uploading" {
|
|
t.Fatalf("a created upload is %q", b.Status)
|
|
}
|
|
if _, err := s.StartParsing(ctx, b.ID, 42, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// The same call again finds no book in `uploading`: a request that finally finished must not be
|
|
// able to resurrect an upload the sweep has already given up on.
|
|
if _, err := s.StartParsing(ctx, b.ID, 42, nil); !errors.Is(err, ErrNoBook) {
|
|
t.Fatalf("a second StartParsing gave %v, want ErrNoBook", err)
|
|
}
|
|
claim, err := s.ClaimParse(ctx, b.ID, now, now.Add(-time.Hour))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// A verdict may only be written by the pass that still HOLDS the claim: an answer from a pass
|
|
// whose claim was taken over is an answer about work it is no longer doing — and for a rejection
|
|
// it would also delete the book's source.
|
|
stale := claim.At.Add(-time.Minute)
|
|
if _, err := s.FinishParse(ctx, b.ID, stale, ParsedBook{Chapters: 7}); !errors.Is(err, ErrNoBook) {
|
|
t.Fatalf("a stale claim finished the parse: %v, want ErrNoBook", err)
|
|
}
|
|
if err := s.RejectBook(ctx, b.ID, stale, ReasonForTest); !errors.Is(err, ErrNoBook) {
|
|
t.Fatalf("a stale claim rejected the book: %v, want ErrNoBook", err)
|
|
}
|
|
owed, err := s.FinishParse(ctx, b.ID, claim.At, ParsedBook{Chapters: 500, ChunkerVersion: "chunk-1"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// The parse leaves the book owing a tree, and the stamp it returns is the one the materializer's
|
|
// queue shows: a caller that materializes now discharges THIS boundary and no later one.
|
|
queued, err := s.BooksOwedReadModel(ctx, 10)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(queued) != 1 || queued[0].ID != b.ID || !queued[0].OwedAt.Equal(owed) {
|
|
t.Fatalf("the materializer's queue holds %+v, want the debt FinishParse returned (%v)", queued, owed)
|
|
}
|
|
if _, err := s.FinishParse(ctx, b.ID, claim.At, ParsedBook{Chapters: 7}); !errors.Is(err, ErrNoBook) {
|
|
t.Fatalf("a second FinishParse gave %v, want ErrNoBook", err)
|
|
}
|
|
// And a parsed book can no longer be rejected: `rejected` is an END of intake, not a state a
|
|
// later pass may drop a working book into.
|
|
if err := s.RejectBook(ctx, b.ID, claim.At, ReasonForTest); !errors.Is(err, ErrNoBook) {
|
|
t.Fatalf("rejecting a parsed book gave %v, want ErrNoBook", err)
|
|
}
|
|
got, _, err := s.GetBook(ctx, "u1", b.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.Status != "not_started" || got.ChapterCount != 500 || got.CharacterCount != 42 {
|
|
t.Fatalf("after the walk: %+v", got)
|
|
}
|
|
}
|
|
|
|
// ReasonForTest stands for one of the platform's own reject reasons; the vocabulary itself lives in
|
|
// the intake package, and this file only needs a value.
|
|
const ReasonForTest = "source_unreadable"
|
|
|
|
// The claim is what keeps two `tmctl manifest` processes off one project directory, and the grace is
|
|
// what keeps a claim from being permanent when the process holding it dies.
|
|
func TestAParseClaimIsTakenOnceAndRecoveredAfterTheGrace(t *testing.T) {
|
|
s, ctx := testDB(t)
|
|
now := fundedAccount(t, s, ctx, "u1", "10")
|
|
b := upload(t, s, ctx, "u1", now)
|
|
if _, err := s.StartParsing(ctx, b.ID, 1, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
stale := now.Add(-time.Hour)
|
|
first, err := s.ClaimParse(ctx, b.ID, now, stale)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if first.Attempts != 1 || first.Workdir == "" {
|
|
t.Fatalf("first claim: %+v", first)
|
|
}
|
|
if _, err := s.ClaimParse(ctx, b.ID, now, stale); !errors.Is(err, ErrParseClaimed) {
|
|
t.Fatalf("a second claim gave %v, want ErrParseClaimed", err)
|
|
}
|
|
// The holder is gone and its claim has aged past the grace: the next pass may take it, and the
|
|
// attempt counter — which is what bounds a broken host — keeps counting. An hour later, with the
|
|
// grace the intake actually uses, the first claim is well behind the line.
|
|
later := now.Add(time.Hour)
|
|
again, err := s.ClaimParse(ctx, b.ID, later, later.Add(-10*time.Minute))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if again.Attempts != 2 {
|
|
t.Fatalf("the attempt counter reads %d after a recovered claim", again.Attempts)
|
|
}
|
|
// ⚠ And the claim is what SPACES the retries: it stays held after a failed parse, so the book is
|
|
// not claimable again until the grace passes. Without that the sweep re-offers it on its very next
|
|
// tick — the staleness predicate falls back to `added_at`, which is already old — and the attempt
|
|
// budget burns in five ticks of the sweep instead of in the time it is meant to bound.
|
|
if _, err := s.ClaimParse(ctx, b.ID, later, later.Add(-10*time.Minute)); !errors.Is(err, ErrParseClaimed) {
|
|
t.Fatalf("a fresh claim was retakeable at once: %v, want ErrParseClaimed", err)
|
|
}
|
|
}
|
|
|
|
// DeleteUpload is narrow on purpose: it is the cure for a request that went away, and it must never
|
|
// become a way to delete a book that has cost money.
|
|
func TestOnlyAnUnfinishedUploadCanBeDeleted(t *testing.T) {
|
|
s, ctx := testDB(t)
|
|
now := fundedAccount(t, s, ctx, "u1", "10")
|
|
b := upload(t, s, ctx, "u1", now)
|
|
if _, err := s.StartParsing(ctx, b.ID, 1, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.DeleteUpload(ctx, b.ID); !errors.Is(err, ErrNoBook) {
|
|
t.Fatalf("deleting a book that is being parsed gave %v, want ErrNoBook", err)
|
|
}
|
|
// A book back in `uploading` but with a run against it stays as well: the run's money is keyed to
|
|
// it, and the cascade would take the row out from under the ledger.
|
|
if _, err := s.pool.Exec(ctx, `update books set status = 'uploading' where id = $1`, b.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: b.ID, OrderedChapters: 1,
|
|
Ceiling: 1_000_000, Now: now}, 0, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.DeleteUpload(ctx, b.ID); !errors.Is(err, ErrNoBook) {
|
|
t.Fatalf("deleting a book with a run gave %v, want ErrNoBook", err)
|
|
}
|
|
other := upload(t, s, ctx, "u1", now)
|
|
if err := s.DeleteUpload(ctx, other.ID); err != nil {
|
|
t.Fatalf("an untouched upload could not be deleted: %v", err)
|
|
}
|
|
}
|
|
|
|
// The stop intent: idempotent, first timestamp kept, and the two refusals told apart.
|
|
func TestAStopRequestIsIdempotentAndKeepsTheFirstTime(t *testing.T) {
|
|
s, ctx := testDB(t)
|
|
now := fundedAccount(t, s, ctx, "u1", "10")
|
|
fundedAccount(t, s, ctx, "u2", "10")
|
|
bookID, err := s.AddBook(ctx, NewBook{OwnerID: "u1", Title: "b", SourceLang: "zh", TargetLang: "ru",
|
|
ChapterCount: 10, Workdir: "/srv/books/b", Now: now})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
started, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: bookID, OrderedChapters: 1,
|
|
Ceiling: 1_000_000, Now: now}, 0, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, _, err := s.RequestStop(ctx, "u2", started.ID, now); !errors.Is(err, ErrNoRun) {
|
|
t.Fatalf("a stranger's stop gave %v, want ErrNoRun", err)
|
|
}
|
|
run, unit, err := s.RequestStop(ctx, "u1", started.ID, now)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if run.ID != started.ID || unit != "" {
|
|
t.Fatalf("stop returned %+v with unit %q (nothing was spawned)", run, unit)
|
|
}
|
|
if _, _, err := s.RequestStop(ctx, "u1", started.ID, now.Add(time.Hour)); err != nil {
|
|
t.Fatalf("a second stop: %v", err)
|
|
}
|
|
var at time.Time
|
|
if err := s.pool.QueryRow(ctx, `select stop_requested_at from runs where id = $1`, started.ID).Scan(&at); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !at.Equal(now) {
|
|
t.Fatalf("the stop is stamped %s, want the FIRST request at %s", at, now)
|
|
}
|
|
// Over means over: a finished run is a conflict, not a missing one.
|
|
if _, err := s.FinishRun(ctx, RunEnding{RunID: started.ID, AttemptID: started.AttemptID,
|
|
Status: "stopped", ExitResult: "success", Now: now}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, _, err := s.RequestStop(ctx, "u1", started.ID, now); !errors.Is(err, ErrRunNotLive) {
|
|
t.Fatalf("stopping a finished run gave %v, want ErrRunNotLive", err)
|
|
}
|
|
}
|
|
|
|
// A resumed run is LIVE again, and the three columns that say otherwise have to go with it: an
|
|
// unfinished run the one-live-per-book index cannot see, money marked resolved that the new attempt
|
|
// has not spent, and a stop request from the previous life that would classify this attempt's ending
|
|
// as a stop nobody asked for.
|
|
func TestReopeningARunClearsWhatSaidItWasOver(t *testing.T) {
|
|
s, ctx := testDB(t)
|
|
now := fundedAccount(t, s, ctx, "u1", "20")
|
|
bookID, err := s.AddBook(ctx, NewBook{OwnerID: "u1", Title: "b", SourceLang: "zh", TargetLang: "ru",
|
|
ChapterCount: 10, Workdir: "/srv/books/b", Now: now})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
started, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: bookID, OrderedChapters: 1,
|
|
Ceiling: 1_000_000, Now: now}, 0, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, _, err := s.RequestStop(ctx, "u1", started.ID, now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := s.FinishRun(ctx, RunEnding{RunID: started.ID, AttemptID: started.AttemptID,
|
|
Status: "stopped", ExitResult: "exit-code", Now: now}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.MarkSettled(ctx, started.ID, now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := s.RestartRun(ctx, RestartInput{RunID: started.ID, AttemptID: started.AttemptID,
|
|
UserID: "u1", BookID: bookID, Ceiling: 500_000, Now: now}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var finished, settled, stop *time.Time
|
|
var status, bookStatus string
|
|
if err := s.pool.QueryRow(ctx, `
|
|
select r.finished_at, r.settled_at, r.stop_requested_at, r.status, b.status
|
|
from runs r join books b on b.id = r.book_id where r.id = $1`, started.ID).
|
|
Scan(&finished, &settled, &stop, &status, &bookStatus); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if finished != nil || settled != nil || stop != nil {
|
|
t.Fatalf("a reopened run still says it is over: finished %v settled %v stop %v", finished, settled, stop)
|
|
}
|
|
if status != "translating" || bookStatus != "translating" {
|
|
t.Fatalf("run %q, book %q after a reopen", status, bookStatus)
|
|
}
|
|
// The verdict of the attempt that ended is NOT overwritten: how the run the user stopped actually
|
|
// ended is the one fact a resume must not erase.
|
|
var result string
|
|
if err := s.pool.QueryRow(ctx,
|
|
`select exit_result from run_attempts where id = $1`, started.AttemptID).Scan(&result); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result != "exit-code" {
|
|
t.Fatalf("the previous attempt's exit_result became %q", result)
|
|
}
|
|
}
|
|
|
|
// One counter per book, and this store is where that rule lives: every Run it hands out — from the
|
|
// card, from a stop, from a plain read — carries its BOOK's revision. A run's own column lags by
|
|
// construction (a materialized unit bumps the book and the chapter, not the run), and the contract
|
|
// tells a client to DROP a read whose revision is below one it applied, so a handle answering from
|
|
// the run's column hands back a number the client is required to throw away.
|
|
func TestEveryRunTheStoreHandsOutCarriesItsBooksRevision(t *testing.T) {
|
|
s, ctx := testDB(t)
|
|
now := fundedAccount(t, s, ctx, "u1", "10")
|
|
bookID, err := s.AddBook(ctx, NewBook{OwnerID: "u1", Title: "b", SourceLang: "zh", TargetLang: "ru",
|
|
ChapterCount: 10, Workdir: "/srv/books/b", Now: now})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
started, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: bookID, OrderedChapters: 1,
|
|
Ceiling: 1_000_000, Now: now}, 0, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Something materializes: the book's counter moves and the run's own column stays where it was.
|
|
if _, err := s.pool.Exec(ctx, `update books set revision = revision + 7 where id = $1`, bookID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
book, card, err := s.GetBook(ctx, "u1", bookID)
|
|
if err != nil || card == nil {
|
|
t.Fatalf("card: %v", err)
|
|
}
|
|
read, err := s.ReadRun(ctx, "u1", started.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
stop, _, err := s.RequestStop(ctx, "u1", started.ID, now)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for name, got := range map[string]int64{"card": card.Revision, "read": read.Revision, "stop": stop.Revision} {
|
|
if got != book.Revision {
|
|
t.Errorf("%s answered revision %d, the book is at %d", name, got, book.Revision)
|
|
}
|
|
}
|
|
}
|