Dispatch modes
Poll vs event dispatch — how the Flux server assigns work to workers, when to switch to event mode, and the LISTEN/NOTIFY mechanics behind it.
The server assigns executions to workers through one of two dispatch strategies, selected by [flux.dispatch] mode. The default, poll, works everywhere including SQLite. event is the scalable mode for large worker fleets on PostgreSQL. Workers are oblivious to the choice — either way, work arrives as SSE events and is claimed the same way.
[flux.dispatch]
mode = "poll" # "poll" (default) | "event"
batch_size = 64 # max executions claimed per dispatcher wakeup (event mode)
fallback_interval = 15.0 # safety-net tick in seconds (event mode)
Env vars: FLUX_DISPATCH__MODE, FLUX_DISPATCH__BATCH_SIZE, FLUX_DISPATCH__FALLBACK_INTERVAL.
Poll mode (default)
The legacy per-worker query loop: each connected worker’s dispatch path polls the database for claimable work — roughly 5 queries per worker every 0.5 s. That cost is per worker, so idle database load grows linearly with fleet size and degrades superlinearly under contention: at around 500 workers the query volume saturates the server’s database executor and submission latency climbs into minutes.
Poll mode’s virtue is universality. It has no PostgreSQL dependency, so it is the only mode that works on SQLite, and it needs no notification plumbing. For a handful of workers it is perfectly fine.
Event mode
FLUX_DISPATCH__MODE=event replaces the per-worker loops with one dispatcher task per server replica (flux/dispatcher.py). The dispatcher sleeps until woken, then batch-claims up to batch_size executions in a single SELECT ... FOR UPDATE SKIP LOCKED query and routes them to connected workers with free capacity slots.
Three things wake it:
- Local in-process signals — work submitted through this replica wakes its dispatcher immediately.
- PostgreSQL
LISTEN/NOTIFY— cross-replica wakeups. A replica that accepts new work firesNOTIFY flux_work; a checkpoint landing on one replica firesNOTIFY flux_execto wake sync/stream callers held open on another. Notifications are wakeups only — they carry no payload; the woken dispatcher re-queries the database for the actual work. - The fallback tick — every
fallback_intervalseconds (default 15) the dispatcher wakes unconditionally, covering any missed notification (a dropped LISTEN connection, a notify racing a restart). Nothing is ever lost to a missed NOTIFY; it is at worst delayed by one tick.
Database load becomes a function of work volume, not fleet size: one dispatcher per replica, one batched claim query per wakeup.
Event mode is also where the two soft placement signals live — poll mode ignores both:
- Scoring policies. After the hard filters (labels, resources, runner, health, free slots), a workflow that declares
routing=score(...)has its eligible workers ranked by the policy instead of defaulting to least-loaded. See Dynamic routing. - The sticky relay hint. Relayed
call()s tag child executions with the calling worker’s name (theX-Flux-Preferred-Workerheader). The dispatcher honors the hint only when that worker is eligible right now — connected, healthy, a free capacity slot, and matching any runner/label constraints — and falls back to least-loaded otherwise. A workflow with a scoring policy owns its ranking entirely; the hint then participates only through an explicitsticky()term.
Multi-replica behavior
Event mode is what makes multiple server replicas coordinate cleanly:
- Every replica’s dispatcher batch-claims with
FOR UPDATE SKIP LOCKED, so replicas never double-assign an execution — no leader election needed for dispatch. - A worker’s SSE stream and dispatch queue live on the replica it connected to, so the load balancer needs connection affinity for
/workers/{name}/connect(see Running the server). - Sync and streaming callers held open on replica A are woken by checkpoints landing on replica B via
NOTIFY flux_exec, with a 30 s poll fallback as the safety net.
Choosing and tuning
poll | event | |
|---|---|---|
| Database support | SQLite and PostgreSQL | PostgreSQL (falls back to tick-only wakeups without LISTEN/NOTIFY) |
| Idle DB load | ~5 queries per worker per 0.5 s | One dispatcher per replica, woken on demand |
| Dispatch latency | Sub-second (poll cadence) | Immediate on wakeup; worst case one fallback_interval |
| Scales to | Small fleets | Hundreds of workers per replica |
Tuning rarely matters beyond the mode switch. Raise batch_size if a single wakeup regularly finds more than 64 claimable executions (high-volume bursts); lower fallback_interval only if you cannot tolerate a 15 s worst case on a missed notification, and remember each tick is a query per replica.
What’s next
- Running the server — replicas, sticky routing, and supervision.
- Dynamic routing — the scoring policies the event dispatcher evaluates.
- Storage backends — why PostgreSQL is a prerequisite for the scalable path.
- Capacity and drain — the per-worker slot limit dispatch respects.
- Server and worker settings — the
[flux.dispatch]reference entries.