New package internal/batch holding the runner core with no workers yet (task 03 adds them; until then every job stays queued, so a queued job counts as live for the caps). batch.go defines the public surface: Options/Target/Result/TargetError, FetchFunc, Job/TargetStatus, Hooks and Config with its defaults (TTL 24h, 32 targets/job, 1 job/caller, 8 total, worker clamped to 2, 64 MiB, 2 s / 10 m retry). Submit runs in the given order: empty → ErrNoTargets; idempotency via hex SHA-256 of `c.Name + NUL + json.Marshal(targets)` (a live BatchJobByIDem hit returns the id reused, touching no caps); then TargetsPerJob / JobsPerCaller / JobsTotal; then it stores the job queued (random 16-byte hex id, CreatedAt = Lease = now) and its targets (pending, Options = json of t.Options) before returning. The submitting caller is held in memory keyed by job id, never persisted (jobs never resume after a restart). Status maps the rows and decodes Result from the result column without writing; Touch moves the lease; Cancel sets a queued job cancelled at once and leaves its targets pending; Close is idempotent. Store errors are Warn-logged with the job id, never a URL or host. Co-Authored-By: ornith-1.5-35b-a3b <ornith-1.5-35b-a3b@llama.cpp.invalid>
183 lines
6.3 KiB
Go
183 lines
6.3 KiB
Go
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")
|
|
}
|
|
}
|