Skip to content

Commit 5e7316d

Browse files
committed
feat: add adaptive polling backoff for workers
1 parent 339368f commit 5e7316d

5 files changed

Lines changed: 452 additions & 19 deletions

File tree

PR_DESCRIPTION.md

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
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

internal/worker/worker.go

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,17 @@ type WorkerOptions struct {
4545

4646
PollingInterval time.Duration
4747

48+
// MaxPollingInterval is the upper bound for adaptive polling backoff.
49+
// When set (> 0), the polling interval increases on consecutive empty polls
50+
// and resets to PollingInterval when a task is found.
51+
// Must be >= PollingInterval (clamped automatically if lower).
52+
// 0 means no backoff (fixed PollingInterval, default behavior).
53+
MaxPollingInterval time.Duration
54+
55+
// BackoffMultiplier controls how fast the interval grows on empty polls.
56+
// Defaults to 2.0 if MaxPollingInterval is set.
57+
BackoffMultiplier float64
58+
4859
Queues []workflow.Queue
4960
}
5061

@@ -61,6 +72,11 @@ func NewWorker[Task, TaskResult any](
6172
options.Queues = append(options.Queues, core.QueueSystem)
6273
}
6374

75+
// Ensure MaxPollingInterval is at least PollingInterval
76+
if options.MaxPollingInterval > 0 && options.MaxPollingInterval < options.PollingInterval {
77+
options.MaxPollingInterval = options.PollingInterval
78+
}
79+
6480
return &Worker[Task, TaskResult]{
6581
tw: tw,
6682
options: options,
@@ -100,12 +116,9 @@ func (w *Worker[Task, TaskResult]) WaitForCompletion() error {
100116
func (w *Worker[Task, TaskResult]) poller(ctx context.Context) {
101117
defer w.pollersWg.Done()
102118

103-
var ticker *time.Ticker
104-
105-
if w.options.PollingInterval > 0 {
106-
ticker = time.NewTicker(w.options.PollingInterval)
107-
defer ticker.Stop()
108-
}
119+
currentInterval := w.options.PollingInterval
120+
timer := time.NewTimer(currentInterval)
121+
defer timer.Stop()
109122

110123
for {
111124
select {
@@ -134,23 +147,44 @@ func (w *Worker[Task, TaskResult]) poller(ctx context.Context) {
134147
w.taskQueue.release()
135148
}
136149
}
150+
// Got task — reset to base interval
151+
currentInterval = w.options.PollingInterval
137152
continue // check for new tasks right away
138153
} else {
139154
// Did not use the reserved slot, release
140155
w.taskQueue.release()
156+
157+
// Back off on empty poll if adaptive polling is enabled
158+
if w.options.MaxPollingInterval > 0 {
159+
currentInterval = w.backoff(currentInterval)
160+
}
141161
}
142162

143-
// Optionally wait between unsuccessful polling attempts
144-
if w.options.PollingInterval > 0 {
163+
// Wait before next poll attempt
164+
if currentInterval > 0 {
165+
timer.Reset(currentInterval)
145166
select {
146-
case <-ticker.C:
167+
case <-timer.C:
147168
case <-ctx.Done():
148169
return
149170
}
150171
}
151172
}
152173
}
153174

175+
func (w *Worker[Task, TaskResult]) backoff(current time.Duration) time.Duration {
176+
multiplier := w.options.BackoffMultiplier
177+
if multiplier <= 0 {
178+
multiplier = 2.0
179+
}
180+
181+
next := time.Duration(float64(current) * multiplier)
182+
if next > w.options.MaxPollingInterval {
183+
next = w.options.MaxPollingInterval
184+
}
185+
return next
186+
}
187+
154188
func (w *Worker[Task, TaskResult]) dispatcher() {
155189
var wg sync.WaitGroup
156190

0 commit comments

Comments
 (0)