package batch // P6 task 02: the runner's core — types, Submit (idempotency, caps, storage), Status, Touch and // Cancel — with no workers yet: every job stays queued. The helpers here are shared with task // 03's runner_test.go. import ( "context" "errors" "io" "log/slog" "testing" "time" "git.wntrmute.dev/kyle/sift/internal/auth" "git.wntrmute.dev/kyle/sift/internal/dcache" ) var ( alice = &auth.Caller{Name: "alice", Workspace: "hermes-fleet"} bob = &auth.Caller{Name: "bob", Workspace: "hermes-fleet"} carol = &auth.Caller{Name: "carol", Workspace: "hermes-fleet"} ) func newRunner(t *testing.T, fetch FetchFunc, cfg Config) *Runner { t.Helper() db, err := dcache.Open("", 1<<20, slog.New(slog.NewTextHandler(io.Discard, nil))) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = db.Close() }) r := New(db, fetch, cfg, slog.New(slog.NewTextHandler(io.Discard, nil))) t.Cleanup(r.Close) // runs before db.Close (cleanups are LIFO) return r } func waitStatus(t *testing.T, r *Runner, id, want string) { t.Helper() deadline := time.Now().Add(5 * time.Second) for time.Now().Before(deadline) { j, _, ok := r.Status(id) if ok && j.Status == want { return } time.Sleep(2 * time.Millisecond) } j, _, _ := r.Status(id) t.Fatalf("job %s never reached %q (now %q)", id, want, j.Status) } func urls(ss ...string) []Target { var out []Target for _, s := range ss { out = append(out, Target{URL: "https://" + s + ".example/"}) } return out } // Submit stores the job queued with its targets pending, before it returns. func TestSubmitStoresQueued(t *testing.T) { r := newRunner(t, func(context.Context, *auth.Caller, Target) (Result, error) { return Result{Content: "ok"}, nil }, Config{Workers: 1}) id, reused, err := r.Submit(context.Background(), alice, dcache.ClassFleet, urls("a", "b")) if err != nil || reused || len(id) != 32 { t.Fatalf("submit id=%q reused=%v err=%v, want a 32-hex-char id", id, reused, err) } j, ts, ok := r.Status(id) if !ok || j.ID != id || j.Caller != "alice" || j.TargetsTotal != 2 || j.CreatedAt == 0 { t.Fatalf("job %+v ok=%v", j, ok) } if j.Status != "queued" && j.Status != "running" && j.Status != "done" { t.Fatalf("status %q", j.Status) } if len(ts) != 2 || ts[0].Idx != 0 || ts[0].URL != "https://a.example/" || ts[1].URL != "https://b.example/" { t.Fatalf("targets %+v", ts) } } // Cancelling a job no worker has reached: it is cancelled at once, its targets stay pending, // and the same list then starts a new job. func TestCancelQueued(t *testing.T) { release := make(chan struct{}) defer close(release) fetch := func(context.Context, *auth.Caller, Target) (Result, error) { <-release return Result{Content: "ok"}, nil } r := newRunner(t, fetch, Config{Workers: 1}) busy, _, err := r.Submit(context.Background(), bob, dcache.ClassFleet, urls("x")) // occupies the only worker once there is one if err != nil { t.Fatal(err) } _ = busy id, _, err := r.Submit(context.Background(), alice, dcache.ClassFleet, urls("a", "b")) if err != nil { t.Fatal(err) } if !r.Cancel(id) { t.Fatal("cancel returned false") } j, ts, _ := r.Status(id) if j.Status != "cancelled" || ts[0].Status != "pending" || ts[1].Status != "pending" { t.Fatalf("after cancel: %+v %+v", j, ts) } if r.Cancel(id) || r.Cancel("no-such-job") { t.Fatal("cancel of a finished or unknown job must return false") } id2, reused, err := r.Submit(context.Background(), alice, dcache.ClassFleet, urls("a", "b")) if err != nil || reused || id2 == id { t.Fatalf("resubmit after cancel id=%q reused=%v err=%v", id2, reused, err) } } // Touch keeps a job alive; Status never does. func TestTouchAndStatusLease(t *testing.T) { db, err := dcache.Open("", 1<<20, slog.New(slog.NewTextHandler(io.Discard, nil))) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = db.Close() }) release := make(chan struct{}) fetch := func(context.Context, *auth.Caller, Target) (Result, error) { <-release return Result{Content: "ok"}, nil } r := New(db, fetch, Config{Workers: 1}, slog.New(slog.NewTextHandler(io.Discard, nil))) t.Cleanup(r.Close) defer close(release) // runs before the cleanups id, _, err := r.Submit(context.Background(), alice, dcache.ClassFleet, urls("a")) if err != nil { t.Fatal(err) } time.Sleep(50 * time.Millisecond) // let a worker (task 03) take the target; it then blocks before, _ := db.BatchJob(id) time.Sleep(1100 * time.Millisecond) // the lease is in whole seconds r.Status(id) if j, _ := db.BatchJob(id); j.Lease != before.Lease { t.Fatalf("Status moved the lease %d → %d; a read must not write", before.Lease, j.Lease) } r.Touch(id) if j, _ := db.BatchJob(id); j.Lease <= before.Lease { t.Fatalf("Touch did not move the lease: %d → %d", before.Lease, j.Lease) } } func TestCaps(t *testing.T) { release := make(chan struct{}) fetch := func(context.Context, *auth.Caller, Target) (Result, error) { <-release return Result{Content: "ok"}, nil } r := newRunner(t, fetch, Config{TargetsPerJob: 2, JobsTotal: 2}) defer close(release) if _, _, err := r.Submit(context.Background(), alice, dcache.ClassFleet, urls("a", "b", "c")); !errors.Is(err, ErrTooManyTargets) { t.Fatalf("3 targets over a cap of 2: want ErrTooManyTargets, got %v", err) } if _, _, err := r.Submit(context.Background(), alice, dcache.ClassFleet, nil); !errors.Is(err, ErrNoTargets) { t.Fatalf("no targets: want ErrNoTargets, got %v", err) } if _, _, err := r.Submit(context.Background(), alice, dcache.ClassFleet, urls("a")); err != nil { t.Fatal(err) } // alice's job is live (queued or running): a second, different list is refused. if _, _, err := r.Submit(context.Background(), alice, dcache.ClassFleet, urls("z")); !errors.Is(err, ErrCallerBusy) { t.Fatalf("want ErrCallerBusy, got %v", err) } if _, _, err := r.Submit(context.Background(), bob, dcache.ClassFleet, urls("b")); err != nil { t.Fatal(err) } // Two live jobs in total: a third caller is refused. if _, _, err := r.Submit(context.Background(), carol, dcache.ClassFleet, urls("c")); !errors.Is(err, ErrBusy) { t.Fatalf("want ErrBusy, got %v", err) } } func TestStatusUnknown(t *testing.T) { r := newRunner(t, func(context.Context, *auth.Caller, Target) (Result, error) { return Result{}, nil }, Config{}) if _, _, ok := r.Status("no-such-job"); ok { t.Fatal("unknown id must be ok=false") } }