textmachine/platform/internal/jobs/jobs_test.go

81 lines
3.6 KiB
Go

package jobs
import (
"context"
"errors"
"fmt"
"testing"
"github.com/riverqueue/river"
)
// Two properties of the queue that are one line each to write and expensive to get wrong, because
// both fail SILENTLY: a renamed kind orphans every job already in the table (River dispatches by
// that string, and a job whose kind no worker claims simply waits forever), and a MaxAttempts above
// one turns "start a run" into "start it again" — the reflex this zone deliberately refuses, since
// a retry here spawns a SECOND engine rather than finishing lost work.
func TestTheQueuesKindsAndRetryPolicyAreWhatTheRestOfTheSystemAssumes(t *testing.T) {
cases := []struct {
name string
kind string
opts river.InsertOpts
}{
{"spawn", SpawnArgs{}.Kind(), SpawnArgs{}.InsertOpts()},
{"parse", ParseArgs{}.Kind(), ParseArgs{}.InsertOpts()},
{"export", ExportArgs{}.Kind(), ExportArgs{}.InsertOpts()},
}
want := map[string]string{"spawn": "tm_spawn_run", "parse": "tm_parse_book", "export": "tm_build_export"}
for _, tc := range cases {
if tc.kind != want[tc.name] {
t.Errorf("%s kind is %q, want %q — a renamed kind orphans the jobs already queued under the old one",
tc.name, tc.kind, want[tc.name])
}
if tc.opts.MaxAttempts != 1 {
t.Errorf("%s MaxAttempts is %d, want 1: recovery is the reconciler's job, not the queue's",
tc.name, tc.opts.MaxAttempts)
}
if tc.opts.Queue != river.QueueDefault {
t.Errorf("%s queue is %q, want the default one the client works", tc.name, tc.opts.Queue)
}
}
}
// stubParser answers with whatever the case wants, so the worker's own decision is what is judged.
type stubParser struct{ err error }
func (p stubParser) Parse(context.Context, string) error { return p.err }
// A pass that established nothing gets its job BACK; anything else the pass says is the job's answer.
//
// ⛔ The distinction is the whole of it, and it exists because MaxAttempts is 1 (above). A returned
// error consumes the single attempt outright, and the book then waits out the intake sweep's whole
// grace — twenty minutes — over a host that was busy for a moment. A snooze does not increment the
// attempt, so it is the SAME recovery coming back rather than the second mechanism this queue's
// policy refuses. Nothing witnessed the mapping until this test: the service's own tests end at the
// error it returns, and the queue's end at the options it declares.
func TestAPassThatEstablishedNothingGetsItsJobBackInsteadOfSpendingIt(t *testing.T) {
failed := errors.New("the store could not be reached")
for _, c := range []struct {
what string
err error
snooze bool
}{
{"a pass that established nothing", fmt.Errorf("%w: the host is busy", ErrTryAgainLater), true},
{"a pass that failed for its own reasons", failed, false},
{"a pass that finished", nil, false},
} {
w := &parseWorker{svc: stubParser{err: c.err}}
got := w.Work(t.Context(), &river.Job[ParseArgs]{Args: ParseArgs{BookID: "bk_1"}})
var snoozed *river.JobSnoozeError
switch {
case c.snooze && !errors.As(got, &snoozed):
t.Errorf("%s: the worker answered %v, want a snooze — this job is the book's only one, and spending it costs the sweep's whole grace", c.what, got)
case c.snooze && snoozed.Duration != RetryDelay:
t.Errorf("%s: the job comes back in %v, want %v", c.what, snoozed.Duration, RetryDelay)
case !c.snooze && errors.As(got, &snoozed):
t.Errorf("%s: the worker snoozed, so a job that is genuinely finished or genuinely broken would be offered again forever", c.what)
case !c.snooze && !errors.Is(got, c.err):
t.Errorf("%s: the worker answered %v, want the pass's own %v", c.what, got, c.err)
}
}
}