235 lines
6.4 KiB
Go
235 lines
6.4 KiB
Go
package batch
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.wntrmute.dev/kyle/sift/internal/dcache"
|
|
)
|
|
|
|
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() })
|
|
return New(db, fetch, cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
|
}
|
|
|
|
func waitStatus(t *testing.T, r *Runner, id, want string) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(3 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
j, _, ok := r.Status(id)
|
|
if ok && j.Status == want {
|
|
return
|
|
}
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
j, _, _ := r.Status(id)
|
|
t.Fatalf("job %s never reached %q (now %q)", id, want, j.Status)
|
|
}
|
|
|
|
func TestSerialOrder(t *testing.T) {
|
|
var mu sync.Mutex
|
|
var calls []string
|
|
inflight, peak := 0, 0
|
|
fetch := func(_ context.Context, tgt Target) (Result, error) {
|
|
mu.Lock()
|
|
inflight++
|
|
if inflight > peak {
|
|
peak = inflight
|
|
}
|
|
calls = append(calls, tgt.URL)
|
|
mu.Unlock()
|
|
time.Sleep(time.Millisecond)
|
|
mu.Lock()
|
|
inflight--
|
|
mu.Unlock()
|
|
return Result{Content: "ok " + tgt.URL}, nil
|
|
}
|
|
r := newRunner(t, fetch, Config{})
|
|
id, reused, err := r.Submit(context.Background(), "alice", "fleet", []Target{
|
|
{URL: "https://a.example/"}, {URL: "https://b.example/"}, {URL: "https://c.example/"},
|
|
})
|
|
if err != nil || reused {
|
|
t.Fatalf("submit err=%v reused=%v", err, reused)
|
|
}
|
|
waitStatus(t, r, id, "done")
|
|
mu.Lock()
|
|
order := append([]string(nil), calls...)
|
|
p := peak
|
|
mu.Unlock()
|
|
if len(order) != 3 || order[0] != "https://a.example/" || order[1] != "https://b.example/" || order[2] != "https://c.example/" {
|
|
t.Fatalf("order %v", order)
|
|
}
|
|
if p != 1 {
|
|
t.Fatalf("peak inflight %d, want 1 (serial)", p)
|
|
}
|
|
}
|
|
|
|
func TestFailureIsolation(t *testing.T) {
|
|
fetch := func(_ context.Context, tgt Target) (Result, error) {
|
|
if tgt.URL == "https://b.example/" {
|
|
return Result{}, errors.New("upstream 500")
|
|
}
|
|
return Result{Content: "ok " + tgt.URL}, nil
|
|
}
|
|
r := newRunner(t, fetch, Config{})
|
|
id, _, err := r.Submit(context.Background(), "alice", "fleet", []Target{
|
|
{URL: "https://a.example/"}, {URL: "https://b.example/"}, {URL: "https://c.example/"},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
waitStatus(t, r, id, "partial")
|
|
j, ts, ok := r.Status(id)
|
|
if !ok {
|
|
t.Fatal("no job")
|
|
}
|
|
if j.Status != "partial" || j.TargetsDone != 3 {
|
|
t.Fatalf("job %+v", j)
|
|
}
|
|
if ts[0].Status != "done" || ts[1].Status != "failed" || ts[2].Status != "done" {
|
|
t.Fatalf("statuses %+v", ts)
|
|
}
|
|
if ts[1].Error == "" {
|
|
t.Fatal("failed target missing error")
|
|
}
|
|
if ts[0].Result == nil || ts[0].Result.Content != "ok https://a.example/" {
|
|
t.Fatalf("result %+v", ts[0].Result)
|
|
}
|
|
}
|
|
|
|
func TestIdempotentResubmit(t *testing.T) {
|
|
var mu sync.Mutex
|
|
n := 0
|
|
fetch := func(_ context.Context, tgt Target) (Result, error) {
|
|
mu.Lock()
|
|
n++
|
|
mu.Unlock()
|
|
return Result{Content: "ok"}, nil
|
|
}
|
|
r := newRunner(t, fetch, Config{})
|
|
targets := []Target{{URL: "https://a.example/"}, {URL: "https://b.example/"}}
|
|
id1, reused1, err := r.Submit(context.Background(), "alice", "fleet", targets)
|
|
if err != nil || reused1 {
|
|
t.Fatalf("first submit err=%v reused=%v", err, reused1)
|
|
}
|
|
id2, reused2, err := r.Submit(context.Background(), "alice", "fleet", targets)
|
|
if err != nil || !reused2 || id2 != id1 {
|
|
t.Fatalf("resubmit id=%q reused=%v err=%v", id2, reused2, err)
|
|
}
|
|
waitStatus(t, r, id1, "done")
|
|
mu.Lock()
|
|
calls := n
|
|
mu.Unlock()
|
|
if calls != 2 {
|
|
t.Fatalf("fetched %d times, want 2 (idempotent)", calls)
|
|
}
|
|
}
|
|
|
|
func TestTooManyTargets(t *testing.T) {
|
|
fetch := func(_ context.Context, tgt Target) (Result, error) { return Result{Content: "ok"}, nil }
|
|
r := newRunner(t, fetch, Config{TargetsPerJob: 2})
|
|
three := []Target{{URL: "https://a.example/"}, {URL: "https://b.example/"}, {URL: "https://c.example/"}}
|
|
if _, _, err := r.Submit(context.Background(), "alice", "fleet", three); !errors.Is(err, ErrTooManyTargets) {
|
|
t.Fatalf("want ErrTooManyTargets, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCallerBusy(t *testing.T) {
|
|
release := make(chan struct{})
|
|
fetch := func(_ context.Context, tgt Target) (Result, error) {
|
|
<-release // hold the job in the running state
|
|
return Result{Content: "ok"}, nil
|
|
}
|
|
r := newRunner(t, fetch, Config{})
|
|
id, _, err := r.Submit(context.Background(), "alice", "fleet", []Target{{URL: "https://a.example/"}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
waitStatus(t, r, id, "running")
|
|
if _, _, err := r.Submit(context.Background(), "alice", "fleet", []Target{{URL: "https://b.example/"}}); !errors.Is(err, ErrCallerBusy) {
|
|
t.Fatalf("want ErrCallerBusy, got %v", err)
|
|
}
|
|
close(release)
|
|
}
|
|
|
|
func TestCancel(t *testing.T) {
|
|
started := make(chan struct{}, 1)
|
|
release := make(chan struct{})
|
|
var mu sync.Mutex
|
|
var calls []string
|
|
fetch := func(_ context.Context, tgt Target) (Result, error) {
|
|
mu.Lock()
|
|
calls = append(calls, tgt.URL)
|
|
mu.Unlock()
|
|
select {
|
|
case started <- struct{}{}:
|
|
default:
|
|
}
|
|
<-release
|
|
return Result{Content: "ok"}, nil
|
|
}
|
|
r := newRunner(t, fetch, Config{})
|
|
id, _, err := r.Submit(context.Background(), "alice", "fleet", []Target{
|
|
{URL: "https://a.example/"}, {URL: "https://b.example/"}, {URL: "https://c.example/"},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
<-started // first target is in flight
|
|
if !r.Cancel(id) {
|
|
t.Fatal("cancel returned false")
|
|
}
|
|
close(release) // let the in-flight target finish
|
|
waitStatus(t, r, id, "cancelled")
|
|
mu.Lock()
|
|
n := len(calls)
|
|
mu.Unlock()
|
|
if n != 1 {
|
|
t.Fatalf("cancelled after %d fetches, want 1", n)
|
|
}
|
|
_, ts, ok := r.Status(id)
|
|
if !ok {
|
|
t.Fatal("no job")
|
|
}
|
|
if ts[0].Status != "done" {
|
|
t.Fatalf("in-flight target should be stored done: %+v", ts[0])
|
|
}
|
|
if ts[1].Status != "pending" || ts[2].Status != "pending" {
|
|
t.Fatalf("later targets must stay pending: %+v", ts[1:])
|
|
}
|
|
}
|
|
|
|
func TestPanicRecovered(t *testing.T) {
|
|
fetch := func(_ context.Context, tgt Target) (Result, error) {
|
|
if tgt.URL == "https://b.example/" {
|
|
panic("boom")
|
|
}
|
|
return Result{Content: "ok"}, nil
|
|
}
|
|
r := newRunner(t, fetch, Config{})
|
|
id, _, err := r.Submit(context.Background(), "alice", "fleet", []Target{
|
|
{URL: "https://a.example/"}, {URL: "https://b.example/"}, {URL: "https://c.example/"},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
waitStatus(t, r, id, "partial")
|
|
_, ts, _ := r.Status(id)
|
|
if ts[0].Status != "done" || ts[1].Status != "failed" || ts[2].Status != "done" {
|
|
t.Fatalf("statuses %+v", ts)
|
|
}
|
|
if ts[1].Error == "" {
|
|
t.Fatal("panic target missing error")
|
|
}
|
|
}
|