|
| 1 | +## Problem |
| 2 | + |
| 3 | +When workers are idle, pollers hit the database at a fixed interval (default 200ms). With many pollers this creates significant unnecessary DB load. For example, 20 pollers at 200ms = 100 queries/sec per queue, even when there are no tasks. |
| 4 | + |
| 5 | +In production scenarios with multiple queues and higher poller counts (e.g., 150 pollers total), this translates to ~750 queries/sec of pure overhead during idle periods. |
| 6 | + |
| 7 | +## Current workaround |
| 8 | + |
| 9 | +The only option today is to manually increase `PollingInterval` (e.g., from 200ms to 1-2s) and reduce poller count. This reduces idle load but introduces a permanent trade-off: task pickup latency is always slower, even under load when fast response matters. |
| 10 | + |
| 11 | +```go |
| 12 | +// Current: pick one fixed interval — can't optimize for both idle and busy |
| 13 | +WorkflowPollingInterval: 1 * time.Second, // low DB load, but 1s pickup latency even when busy |
| 14 | +WorkflowPollingInterval: 200 * time.Millisecond, // fast pickup, but high DB load when idle |
| 15 | +``` |
| 16 | + |
| 17 | +There's no way to get fast pickup under load AND low DB pressure when idle with a fixed interval. |
| 18 | + |
| 19 | +## Solution |
| 20 | + |
| 21 | +Add optional exponential backoff on consecutive empty polls. The interval resets immediately when a task is found, ensuring fast pickup under load. |
| 22 | + |
| 23 | +This gives the best of both worlds: |
| 24 | +- Under load: pollers operate at full speed (base interval), tasks are picked up immediately |
| 25 | +- When idle: pollers gradually slow down, dramatically reducing database pressure |
| 26 | + |
| 27 | +### New options |
| 28 | + |
| 29 | +| Option | Default | Description | |
| 30 | +|--------|---------|-------------| |
| 31 | +| `MaxPollingInterval` | `0` (disabled) | Upper bound for backoff. 0 = no backoff (current behavior) | |
| 32 | +| `BackoffMultiplier` | `2.0` | Growth factor per empty poll | |
| 33 | + |
| 34 | +These are exposed for both workflow and activity workers: |
| 35 | + |
| 36 | +- `MaxWorkflowPollingInterval` / `WorkflowPollingBackoffMultiplier` |
| 37 | +- `MaxActivityPollingInterval` / `ActivityPollingBackoffMultiplier` |
| 38 | + |
| 39 | +### Usage |
| 40 | + |
| 41 | +```go |
| 42 | +worker.New(backend, &worker.Options{ |
| 43 | + WorkflowWorkerOptions: worker.WorkflowWorkerOptions{ |
| 44 | + WorkflowPollers: 8, |
| 45 | + WorkflowPollingInterval: 200 * time.Millisecond, |
| 46 | + MaxWorkflowPollingInterval: 2 * time.Second, |
| 47 | + WorkflowPollingBackoffMultiplier: 2.0, |
| 48 | + }, |
| 49 | + ActivityWorkerOptions: worker.ActivityWorkerOptions{ |
| 50 | + ActivityPollers: 8, |
| 51 | + ActivityPollingInterval: 200 * time.Millisecond, |
| 52 | + MaxActivityPollingInterval: 2 * time.Second, |
| 53 | + ActivityPollingBackoffMultiplier: 2.0, |
| 54 | + }, |
| 55 | +}) |
| 56 | +``` |
| 57 | + |
| 58 | +### Behavior |
| 59 | + |
| 60 | +``` |
| 61 | +Empty poll: 200ms -> 400ms -> 800ms -> 1.6s -> 2s (capped at MaxPollingInterval) |
| 62 | +Task found: immediately resets to 200ms (base PollingInterval) |
| 63 | +``` |
| 64 | + |
| 65 | +Each poller maintains its own independent interval. No shared state, no mutex, no coordination overhead. |
| 66 | + |
| 67 | +## Benchmark results |
| 68 | + |
| 69 | +``` |
| 70 | +goos: windows |
| 71 | +goarch: amd64 |
| 72 | +cpu: Intel(R) Core(TM) Ultra 5 225U |
| 73 | +
|
| 74 | +BenchmarkPolling_WithoutBackoff-14 1212 1000521 ns/op 732.0 polls |
| 75 | +BenchmarkPolling_WithBackoff-14 1196 999492 ns/op 28.00 polls |
| 76 | +``` |
| 77 | + |
| 78 | +| Metric | Without backoff | With backoff | Reduction | |
| 79 | +|--------|----------------|--------------|-----------| |
| 80 | +| Polls per second (1 poller) | 732 | 28 | 96% | |
| 81 | +| Projected DB queries (20 pollers) | 14,640/sec | 560/sec | 96% | |
| 82 | +| Projected DB queries (150 pollers) | 109,800/sec | 4,200/sec | 96% | |
| 83 | + |
| 84 | +Task pickup latency when busy is unchanged (immediate `continue` on task found, same as before). |
| 85 | + |
| 86 | +## Backward-compatible |
| 87 | + |
| 88 | +- `MaxPollingInterval = 0` (default) preserves exact current behavior |
| 89 | +- No changes needed for existing users |
| 90 | +- No new dependencies |
| 91 | +- All existing tests pass without modification |
| 92 | + |
| 93 | +## Implementation details |
| 94 | + |
| 95 | +- Replaced fixed `time.Ticker` with `time.Timer` + manual `Reset()` to support variable intervals |
| 96 | +- `backoff()` is a simple pure function: `next = min(current * multiplier, max)` |
| 97 | +- Per-poller goroutine state only (no shared mutable state between pollers) |
| 98 | +- The `continue` fast-path on task found is preserved, no regression in throughput |
| 99 | + |
| 100 | +## Changes |
| 101 | + |
| 102 | +| File | Change | |
| 103 | +|------|--------| |
| 104 | +| `internal/worker/worker.go` | Adaptive poller loop + `backoff()` helper, new fields in `WorkerOptions` | |
| 105 | +| `worker/options.go` | New public options for workflow + activity workers | |
| 106 | +| `worker/worker.go` | Pass-through new options to internal worker | |
| 107 | +| `internal/worker/worker_test.go` | 6 unit tests + 2 benchmarks for adaptive polling | |
| 108 | + |
| 109 | +## Test coverage |
| 110 | + |
| 111 | +- Backoff increases interval on consecutive empty polls |
| 112 | +- Backoff caps at MaxPollingInterval |
| 113 | +- Default multiplier (2.0) when not specified |
| 114 | +- Custom multiplier values |
| 115 | +- Interval resets immediately when task is found |
| 116 | +- No backoff when MaxPollingInterval is 0 (backward compat) |
| 117 | +- Measurably fewer polls with backoff enabled |
| 118 | +- All existing tests pass unchanged |
0 commit comments