|
| 1 | +// @spec system-job-queue |
| 2 | +// |
| 3 | +// Bounded-concurrency drain: the in-process worker runs up to N claim/process |
| 4 | +// loops at once so a fleet of queued jobs does not drain one at a time. SKIP |
| 5 | +// LOCKED gives each loop a disjoint job; the per-host advisory lock (covered by |
| 6 | +// scan_worker_test.go) still serializes same-host scans. |
| 7 | + |
| 8 | +package worker |
| 9 | + |
| 10 | +import ( |
| 11 | + "context" |
| 12 | + "testing" |
| 13 | + "time" |
| 14 | + |
| 15 | + "github.com/Hanalyx/openwatch/internal/correlation" |
| 16 | + "github.com/Hanalyx/openwatch/internal/db/dbtest" |
| 17 | + "github.com/Hanalyx/openwatch/internal/queue" |
| 18 | + "github.com/google/uuid" |
| 19 | +) |
| 20 | + |
| 21 | +// blockingDiscovery signals each invocation on started, then blocks until |
| 22 | +// release is closed. host.discovery is a convenient vehicle: it has no per-host |
| 23 | +// lock, so it isolates the worker's fan-out from the scan path's serialization. |
| 24 | +type blockingDiscovery struct { |
| 25 | + started chan struct{} |
| 26 | + release chan struct{} |
| 27 | +} |
| 28 | + |
| 29 | +func (b *blockingDiscovery) RunDiscovery(_ context.Context, _ uuid.UUID) error { |
| 30 | + b.started <- struct{}{} |
| 31 | + <-b.release |
| 32 | + return nil |
| 33 | +} |
| 34 | + |
| 35 | +// @ac AC-12 |
| 36 | +// AC-12: with WithConcurrency(N) and N+1 blocking jobs, exactly N run at once |
| 37 | +// and the (N+1)th waits for a free loop. |
| 38 | +func TestWorker_BoundedConcurrency(t *testing.T) { |
| 39 | + t.Run("system-job-queue/AC-12", func(t *testing.T) { |
| 40 | + pool := dbtest.Pool(t) |
| 41 | + const n = 3 |
| 42 | + d := &blockingDiscovery{started: make(chan struct{}, 16), release: make(chan struct{})} |
| 43 | + w := New(pool).WithDiscovery(d).WithConcurrency(n) |
| 44 | + |
| 45 | + // Enqueue N+1 host.discovery jobs for distinct hosts. |
| 46 | + for i := 0; i < n+1; i++ { |
| 47 | + ctx := correlation.Set(context.Background(), correlation.Generate("test")) |
| 48 | + // Pass the map directly — Enqueue marshals it (passing pre-marshaled |
| 49 | + // bytes would double-encode into a JSON string). |
| 50 | + body := map[string]string{"host_id": uuid.NewString()} |
| 51 | + if _, err := queue.Enqueue(ctx, pool, "host.discovery", body); err != nil { |
| 52 | + t.Fatalf("enqueue %d: %v", i, err) |
| 53 | + } |
| 54 | + } |
| 55 | + |
| 56 | + w.Start(context.Background()) |
| 57 | + defer w.Stop() |
| 58 | + |
| 59 | + // Exactly N jobs enter RunDiscovery concurrently. |
| 60 | + for i := 0; i < n; i++ { |
| 61 | + select { |
| 62 | + case <-d.started: |
| 63 | + case <-time.After(5 * time.Second): |
| 64 | + t.Fatalf("only %d/%d jobs started concurrently", i, n) |
| 65 | + } |
| 66 | + } |
| 67 | + // The (N+1)th must NOT have started — fan-out is bounded at N. |
| 68 | + select { |
| 69 | + case <-d.started: |
| 70 | + t.Fatal("more than N jobs ran at once — concurrency not bounded") |
| 71 | + case <-time.After(400 * time.Millisecond): |
| 72 | + // good: bounded |
| 73 | + } |
| 74 | + |
| 75 | + // Free the in-flight loops; the (N+1)th now claims a freed loop. |
| 76 | + close(d.release) |
| 77 | + select { |
| 78 | + case <-d.started: |
| 79 | + case <-time.After(5 * time.Second): |
| 80 | + t.Fatal("the (N+1)th job never ran after release — a freed loop did not pick it up") |
| 81 | + } |
| 82 | + }) |
| 83 | +} |
| 84 | + |
| 85 | +// @ac AC-12 |
| 86 | +// AC-12 (clamp): a concurrency < 1 clamps to 1 so the default worker stays |
| 87 | +// strictly serial; a positive value is kept. |
| 88 | +func TestWorker_WithConcurrencyClamps(t *testing.T) { |
| 89 | + t.Run("system-job-queue/AC-12", func(t *testing.T) { |
| 90 | + cases := map[int]int{0: 1, -5: 1, 1: 1, 8: 8} |
| 91 | + for in, want := range cases { |
| 92 | + if got := New(nil).WithConcurrency(in).concurrency; got != want { |
| 93 | + t.Errorf("WithConcurrency(%d) = %d, want %d", in, got, want) |
| 94 | + } |
| 95 | + } |
| 96 | + // New defaults to serial. |
| 97 | + if got := New(nil).concurrency; got != 1 { |
| 98 | + t.Errorf("New default concurrency = %d, want 1", got) |
| 99 | + } |
| 100 | + }) |
| 101 | +} |
0 commit comments