Fetchers & Workers¶
TaskRunner separates reading messages from the queue (fetching) from running your task code (execution). The two run on independent goroutine pools and scale separately. This page covers what each does, how they cooperate, and the knobs that control them.
The two pools¶
Fetchers¶
A fetcher is a goroutine that loops forever, calling queue.Receive(...) which
translates to XREADGROUP. Each fetcher reads up to BatchSize messages per
round-trip and hands each one to the worker pool via workerPool.Invoke(...).
// runner/fetcher.go (simplified)
for {
messages, err := t.queue.Receive(ctx, blockDuration, batchSize, group, consumerName)
if err != nil { t.captureError(err); continue }
for _, message := range messages {
t.workerPool.Invoke(fetchedMessage{message: message, consumer: consumerName})
}
}
Key properties:
- I/O-bound. A fetcher spends almost all its time blocked on
XREADGROUPwithBlock = BlockDuration(default 5s). You rarely need more than a few. - Consumer names are per-fetcher. The consumer name is
<group>_<host>_<fetcherID>, so a multi-fetcher instance shows up as multiple consumers inXINFO CONSUMERS. This is normal and lets Redis spread the PEL. - Self-healing. If a fetcher panics, the deferred recover logs it and a new
fetcher is spawned to keep
NumFetchersconstant (unless the context is canceled).
Workers¶
Workers run inside an ants
PoolWithFunc. Each Invoke triggers one call to TaskRunner.worker(...),
which:
- Unmarshals the message payload from JSON into a
TaskMessage. - Looks up the
*TaskbyTaskMessage.TaskNamein the registry. - Enforces
MaxRetry-- if the message's receive count exceeds it, theFailedTaskHandlerruns and the message is dropped. - Runs
task.Action(ctx, payload)in a separate goroutine (executeTask) so it can be heartbeated. - On success,
XACKs (andXDELs ifdeleteOnAck). On error, captures the error and leaves the message unacked for reclaim.
flowchart LR
subgraph Fetcher
XR["XREADGROUP<br/>(batch)"]
end
subgraph "ants PoolWithFunc"
W1[worker 1]
W2[worker 2]
WN[worker N]
end
XR -->|"Invoke per msg"| W1
XR --> W2
XR --> WN
W1 --> Action1["task.Action"]
W2 --> Action2["task.Action"]
WN --> ActionN["task.Action"]
Action1 -.heartbeat.-> HB["XCLAIM<br/>every ReservationTimeout"]
Action1 -->|"on success"| Ack["XACK + XDEL"]
Heartbeats: preventing double-processing¶
The trickiest part of any at-least-once queue is handling slow tasks. If a worker is still running a 60-second task, but the visibility timeout is 30 seconds, Redis will hand the same message to another consumer after 30s -- causing two workers to process it simultaneously.
TaskRunner's solution: while the action runs, the worker re-claims the message on a timer, resetting its idle time:
for {
select {
case result := <-resultChannel:
// action finished -> ack or fail
case <-time.After(task.ReservationTimeout):
if t.queue.RequireHeartHeartBeat() {
t.queue.HeartBeat(ctx, group, consumer, messageID) // XCLAIM
}
}
}
Because XCLAIM resets the message's idle time, the pending-reclaimer (which
looks for entries idle longer than reClaimDelay) will not steal it.
Set ReservationTimeout to a fraction of your task's worst case
If your task normally runs in 2s but occasionally in 30s, set
ReservationTimeout to ~5s. Heartbeats fire every 5s, well under the
reClaimDelay (default 5min), so the message is never stolen while the
task is genuinely running. A shorter ReservationTimeout means more
frequent Redis round-trips during long tasks; a longer one means slower
recovery if the worker actually dies.
Configuration knobs¶
| Field | Default | Effect |
|---|---|---|
NumFetchers |
= BatchSize |
Number of concurrent fetcher goroutines. |
NumWorkers |
-- | Capacity of the ants worker pool. With tuning disabled, this is fixed. |
BatchSize |
-- | Messages read per XREADGROUP per fetcher. |
BlockDuration |
5s | How long XREADGROUP blocks waiting for new messages. |
Task.MaxRetry |
1 if 0 | Max times a message is delivered before FailedTaskHandler runs. |
Task.ReservationTimeout |
-- | Heartbeat interval for long-running tasks. |
Task.FailedTaskHandler (cfg) |
nil | Called on terminal failure; can ack to stop retries. |
Adaptive batch sizing (tuning mode)¶
When worker tuning is enabled (MinWorkers/MaxWorkers/
DesiredWaitTime set), each fetcher also tracks the worker pool's free
capacity and adjusts its batch size to follow it:
shouldUpdateBatchSize := func() (bool, int) {
poolFreeCapacity := t.workerPool.Free()
if time.Since(batchSizeLastUpdate) < TuningCooldownSeconds { return false, batchSize }
if poolFreeCapacity < BatchSize { return false, batchSize }
lowerBand := poolFreeCapacity - BatchSize
upperBand := poolFreeCapacity + BatchSize
if (batchSize < lowerBand || batchSize > upperBand) && poolFreeCapacity > BatchSize {
batchSizeLastUpdate = time.Now()
return true, poolFreeCapacity
}
// ...
}
The intent: a pool that just grew to 200 free workers should pull batches of
~200, not the configured BatchSize of 10. This keeps the pool fed without
over-fetching when the pool shrinks. Updates are rate-limited by
TuningCooldownSeconds to avoid churn.
Choosing NumFetchers vs NumWorkers¶
A common mistake is to set them equal. They serve different roles:
NumFetchersis about saturating the Redis round-trip. One fetcher per core is usually plenty; 1-4 covers almost all workloads. More fetchers than that just addsXREADGROUPcontention.NumWorkersis about your task code. If each task does 200ms of CPU work and you want 1000 req/s, you need ~200 workers. Theantspool multiplexes them ontoGOMAXPROCSOS threads.
Rule of thumb: NumFetchers small, NumWorkers large. If you enable
worker tuning, set NumFetchers to a small constant
and let NumWorkers float between MinWorkers and MaxWorkers.