Scaling¶
TaskQ scales along three independent axes, and the knob for each lives in a different place:
- Vertical - one process, more concurrency: sized by the connection arithmetic in Derived Values and ops.md §4.
- Horizontal - more worker processes against one Postgres: managed by the workgroup supervisor (workgroups.md) and bounded by the connection budget.
- Per-queue / per-actor - caps and dispatch modes that shape throughput without adding processes.
This page is the decision map between them. It does not repeat the full knob tables; each section links the guide that owns the numbers.
1. Vertical sizing: the connection math¶
Every worker process holds up to four pools plus dedicated connections (ops.md §4):
| Resource | Count (default) | DSN | Scales with |
|---|---|---|---|
dispatcher_pool |
4 | direct | fixed default (TASKQ_DISPATCHER_POOL_SIZE) |
heartbeat_pool |
4 | direct | fixed default (TASKQ_HEARTBEAT_POOL_SIZE) |
worker_pool |
int(max_concurrency × 1.5) |
pooled (may traverse PgBouncer) | TASKQ_MAX_CONCURRENCY |
| slot pool (conditional) | max_concurrency + 1 |
direct | only when a LOOP-scope asyncpg.Connection is registered and max_concurrency > 1 (workers.md owns the decision; the loop_scope_conn_dsn_mismatch startup warning tells you which path your fleet is on); fully warmed at boot |
notify_conn |
1 | direct | fixed (LISTEN is session-scoped, cannot pool) |
leader_conn |
1 | direct | fixed (advisory-lock election, every worker) |
| leader-only monitor + cron | +2 | direct | only on the elected leader |
Two non-worker counts finish the budget: each per-TaskQ client (a web/API pod) holds one pool of at most 5 connections, and a workgroup supervisor with child health checks holds health_workers + 1. Idle, the pools open at min_size=1, so an idle worker holds 5 sessions (7 as leader) and grows to the maxima under load.
The only scaling-sensitive term is max_concurrency: raising it raises the derived worker_pool (int(max_concurrency × 1.5)), the conditional slot pool (max_concurrency + 1), and - restart-only, see ops.md §4 - nothing else. The 1.5 factor is burst headroom for terminal writes that land just after a job finishes (Derived Values).
Fleet sizing table¶
The three profiles below are computed with ops.md §4's fleet formula (direct = M × (dispatcher_pool_size + heartbeat_pool_size + 2) + 2, pooled = M × int(max_concurrency × 1.5), steady state, no per-slot pool). They are starting points, not recommendations - the derivation and its caveats live in Starting configurations.
| Profile | Workers | TASKQ_MAX_CONCURRENCY |
PG connections (steady) | Rolling-deploy peak |
|---|---|---|---|---|
| Small | 2 | 4 | 34 (22 direct + 12 pooled) | ~68 |
| Medium | 5 | 8 (default) | 112 (52 direct + 60 pooled) | ~224 |
| Large | 10 | 16 | 342 (102 direct + 240 pooled) | ~684 |
Worked example (the Large profile): direct = 10 × (4 + 4 + 2) + 2 = 102, pooled = 10 × int(16 × 1.5) = 240, total = 342 - before your application's own pools. On the per-slot path the same shape is 512.
Two budget rules that bite at fleet size:
- Rolling deploys double the fleet briefly. Orchestrators start new pods before draining old ones, so plan
max_connectionsagainst roughly 2× steady state. Omitting that term is the most common way the budget gets underestimated. - Past 80 total connections, PgBouncer is recommended (
compute_connection_budget()flags it; the worker logs it at startup). Route only theworker_poolDSN through transaction mode; dispatcher, heartbeat, notify, and leader connections need direct session mode. See the PgBouncer Configuration Pattern.
2. Horizontal scale: the workgroup model¶
Worker processes are stateless: dispatch claims rows with FOR UPDATE SKIP LOCKED, so N workers against the same database never pick up the same job, and adding processes is the primary scaling lever (deployment.md: Horizontal scaling).
One leader per schema¶
Only one worker per schema holds the maintenance leader advisory lock. The lock name is schema-qualified (taskq:maintenance_leader:<schema>, built by taskq.constants.schema_lock_name), so each schema in a shared database elects its own leader. Followers are not idle replicas of the leader: every worker dispatches and executes jobs regardless of election outcome; leadership gates only the repair loops (lock reclaim, deadline sweep, scheduled promotion, cron, prune/archive). Failover from a leader that dies without a graceful stop is bounded by leader_lease + heartbeat_interval (40 s + 10 s at defaults, renewed every heartbeat interval); a graceful stop hands the row back before the drain finishes, so a follower's next election cycle takes over while the stopping pod is still draining. See architecture.md.
The workgroup supervisor¶
When one box should host several independently-configured workers (per-queue poll intervals, isolated concurrency budgets), the workgroup supervisor spawns and manages N taskq worker subprocesses from one TOML file - see workgroups.md and deployment.md: Workgroup Deployment. Runnable references ship in the repo: examples/workgroup.toml (a high-throughput api child beside a low-concurrency background child) and examples/docker-compose.yml.
What the supervisor owns, and the floors its sizing must respect:
- Rolling release / restarts. A child exit or a failed spawn consumes the same budget: exponential backoff from 0.5 s, doubled per successive crash, capped at 30 s; 10 restarts within a rolling 60 s window latches give-up for that child (one critical log; the workgroup must be restarted to recover it). See workgroups.md: Restart policy.
- Graceful drain. On SIGTERM the supervisor forwards the signal to every child, waits
shutdown_grace, then SIGKILLs stragglers. Two floors bound that window, and the startup warning names the numbers when a config falls below either: the release floor (40 s at defaults - the cancellation 30 s + cleanup 10 s graces a child needs to land a held release) and the clean-exit floor (82 s at defaults - the graces plus the ~42 s bounded-close teardown tail, see deployment.md's Health probes section). The shippedshutdown_gracedefault of 100 s covers the clean-exit floor with ~22% margin. - Database-backed health checks. Optional per-child liveness via the
workerstable (heartbeat fresher thanstale_after, default 60 s, checked every 15 s); a hung child is killed and replaced. The supervisor's health pool is sizedhealth_workers + 1.
3. Queue-level throughput¶
Before adding processes, check whether the bottleneck is a cap or an ordering problem - ops.md §3 is the knob-by-knob reference.
Dispatch modes: strict_fifo vs round_robin¶
Queue mode is a column on the {schema}.queues table, defaulting to strict_fifo. On a strict-FIFO queue, fairness_key is accepted, stored, and ignored; round_robin is what makes it do anything - dispatch interleaves fairness cohorts (typically tenant ids) so one tenant's backlog cannot starve the others. Nothing seeds queues rows: set the mode with taskq queues set-mode <queue> round_robin (it UPSERTs; a raw UPDATE on a missing row silently affects zero rows). Jobs enqueued without a fairness_key collapse into one cohort where round-robin degenerates to plain FIFO. See workers.md: Queue dispatch modes.
Per-actor and per-queue caps¶
| Cap | Scope | Strictness | Change lands |
|---|---|---|---|
@actor(max_concurrent=N) |
per actor, fleet-wide | best-effort damper, not a hard cap | immediately - dispatch re-reads actor_config every round |
@actor(max_pending=N) |
per actor, enqueue-side | hard rejection (MaxPendingExceededError) |
within seconds - enqueue-side processes hold a TTL-bounded capacity cache (5 s default staleness) |
taskq queues set-max-concurrent |
per queue, fleet-wide | strict leased slots (physical rows) | at the next worker restart - each worker reads the cap into memory at startup |
The max_pending cache is what makes the cap cheap: a producer re-counts the actor's pending depth from the database at most once per TTL instead of per enqueue, so a live retune via taskq actor-config set propagates fleet-wide in seconds with no redeploy. The cap change asymmetry (actor caps live, queue caps restart-bound) is in deployment.md: max_concurrent and max_pending.
Dispatch oversample¶
TASKQ_DISPATCH_OVERSAMPLE (default 2) multiplies per-actor candidate gathering in the dispatch SQL: each LATERAL reads residual × oversample candidates, absorbing identity-key collisions and multi-producer contention (the default tolerates 50% dupe identities; set 1 when no identity_key is used and single-producer). A dispatch round that finds its whole candidate window locked by concurrent dispatchers expands it geometrically, up to 8×, while claimable rows remain - the setting governs the steady state so the common case never pays the expansion round trip. Size it at or above the number of dispatchers that routinely poll the same actor+queue.
The rate-limit admission path¶
Worker-side rate-limit acquires (token bucket / sliding window) run on the worker pool - the pooled DSN - after the claim and before the actor body: the consumer's denial path snoozes an already-claimed job (dispatch.py hands claimed jobs to the consumer with worker_pool=; the acquire rides that pool, consumer.py's _acquire_for_actor_with_denial_retry). They execute under bounded row-lock budgets (TASKQ_TOKEN_BUCKET_LOCK_TIMEOUT_MS / TASKQ_SLIDING_WINDOW_LOCK_TIMEOUT_MS, 5000 ms each by default); widening a budget past its default re-derives the command timeout bound upward (budget / 0.8). Placement consequence: admission row-lock traffic traverses the SAME pooled capacity as the job queries - size the pooled DSN for both, and do not budget the direct DSN for admission (the dispatcher pool carries no admission acquires). See Derived Values. When Redis is unavailable, admission falls back to Postgres (TASKQ_RATE_LIMIT_PG_FALLBACK_ENABLED=true by default): a Redis outage funnels every acquire through those row locks, which is why the bounds exist. Keyed reservations and keyed rate limits add per-key granularity with per-process cardinality guardrails (TASKQ_MAX_KEYED_RESERVATIONS / TASKQ_MAX_KEYED_RATE_LIMITS, 10 000 each by default) - the guardrail is per process and does not scale with the replica count. See rate-limiting.md and rate-limiting.md: Queue-level concurrency cap.
4. Storage at scale¶
Postgres is the only job store; Redis is optional (real-time progress fanout for the admin UI) and never holds job state. Everything below is Postgres-side capacity planning.
Connection budgeting¶
See §1 - the short version: split TASKQ_PG_DSN_DIRECT (session mode; dispatcher, heartbeat, notify, leader) from TASKQ_PG_DSN_POOLED (transaction-mode PgBouncer; worker_pool only), count the fleet with ops.md §4's formula, and keep the rolling-deploy doubling plus your application's own pools inside max_connections.
Event volume: TimescaleDB hypertables¶
job_events records every state transition and feeds the crash-reclaim feed (poll_reclaim_events()), so its volume tracks fleet activity. Vanilla Postgres is the default and fully supported; with TASKQ_TIMESCALEDB_HYPERTABLES=true, the three retention tables (job_events, jobs_archive, job_attempts_archive) become hypertables and retention becomes whole-chunk drops. Chunk intervals derive from the retention settings: retention / 4, clamped to 1 day - 30 days, so a retention window spans a handful of chunks and a chunk drop stays bounded work. See timescaledb.md.
Sweep and archival throughput¶
Every repair loop writes in bounded, individually committed batches: sweeps 1-2 transition at most 2 × event_writer_batch_size rows per call (each arm LIMITs at the batch size), drained across at most TASKQ_SWEEP_DRAIN_BATCHES (default 8) batches per 30 s tick. That bound is the fleet's crash-recovery throughput ceiling - the full derivation, the ratchet and watermark failure modes it prevents, and the worked 50 000-row backlog example are in maintenance-sweeps.md §1. Do not scale out a fleet to escape a stalled sweep: extra workers consume pending jobs faster but promote and reclaim nothing.
Retention defaults¶
| Data | Default retention | Sweep cadence |
|---|---|---|
job_events |
7 d (TASKQ_EVENT_RETENTION_PERIOD) |
leader sweep loop; one 10 000-row batch per tick, deliberately not drained |
| Keyed orphaned rows | 1 h idle (TASKQ_KEYED_ROW_RECLAIM_PERIOD) |
leader sweep loop; one 256-row batch per table per tick |
Succeeded jobs (hot jobs table) |
30 d | daily prune, 03:00 UTC → jobs_archive |
| Cancelled jobs (hot) | 30 d (TASKQ_PRUNE_RETENTION_CANCELLED) |
daily prune, 03:00 UTC — under bulk-cancel-heavy workloads this is the dominant hot-table dwell class |
| Failed / abandoned jobs (hot) | 90 d | daily prune, 03:00 UTC |
jobs_archive |
365 d | daily archive-expiry sweep, 04:00 UTC |
Per-actor retention_days metadata can shorten the hot-table dwell for high-volume actors; the row-count arithmetic for sizing the hot table is the storage-planning note in configuration.md.
5. One big worker or N small ones?¶
Horizontal scale is not free - each added process pays fixed costs that a single fat process pays once:
| Signal | Favour one big worker (higher TASKQ_MAX_CONCURRENCY, fewer processes) |
Favour N small workers |
|---|---|---|
| Shutdown/drain tails | Every process pays the ~42 s bounded-close teardown tail on shutdown (8 sequential closes × 5 s + 2 s publish drain). A batch/broadcast workload with a fleet-wide stop pays N tails and herds N restarts; one fat worker pays one. | Long-running or crash-prone actors: one wedged child takes its concurrency down, not the fleet's. |
| Postgres failover restart herds | the worker self-terminates on the (max_heartbeat_failures + 1)-th consecutive failed beat (the 4th at the default of 3 - the isolate decision is strictly greater-than) - at defaults roughly ten seconds of failed beats (2.5 s failed-tick pacing) ends every worker at once, and the restart herd is sized by your replica count. Fewer replicas, smaller herd. |
Fault isolation matters more than herd size (noisy neighbours, memory pressure, deploy blast radius). |
| Connection budget | One process at max_concurrency=16 holds the same pooled count as two at 8 with fewer fixed direct connections; the fleet formula's +2 leader extras belong to the ONE elected leader per schema (not per process); the pool floors are the per-process constants. |
Independent queue subscriptions and rolling deploys per group; a workgroup on one box gets both with one supervisor. |
| Lease/heartbeat capacity | The lock_lease floor is the failed-beat cascade: max(heartbeat_interval, heartbeat_command_timeout) + (max_heartbeat_failures + 1) × (heartbeat_interval + heartbeat_command_timeout) = 58 s at defaults against the 60 s lease. That floor is per-process-fixed, so it does not shrink with replica count - it constrains how far heartbeat timing can be tuned on any worker, however many you run. |
Cross-region or loaded Postgres raises heartbeat_command_timeout, which raises the floor; spreading workers changes nothing about that math. |
| Co-tenancy / density | Sub-second timing budgets (a tightened idle_settle_window, watchdog budgets) need headroom: on a dense host, co-tenant CPU and cache effects contaminate every timed interval, which is why the defaults err heavily towards missing a hang instead of tripping on load ("raise budgets, don't lower them"). A fat worker on an undersized box is the dense-host failure shape. |
Pinning busy work to dedicated low-concurrency workers keeps the host - and every timing budget on it - quiet. |
Two density lessons from the repo's own history make the co-tenancy row concrete: the coverage leg runs -n 2 because halving the traced tree's resident footprint per worker is what bounded its memory (oom-419-verdict.md), and the timing-scaling rules in the debugging playbook exist because co-tenant CPU made growth-ratio gates lottery tickets. Budget headroom before you budget width.
For the until-idle flavour of drain: the settle window (2 s default) exists to close the race where a producer enqueues between the drain check and the shutdown trigger - shrinking it towards zero on a shared queue converts a settle budget into flaky early exits. See workers.md: Until-idle mode.
Scaling checklist¶
Work the list top-down; each step is cheaper than the one below it.
- Read the bottleneck before adding anything. Utilisation (
taskq.worker.active_jobs / taskq.worker.max_concurrency) besidetaskq.jobs.oldest_pending_age_seconds: high utilisation with a climbing age means capacity; low utilisation with a climbing age means a cap, an unserved queue, or a promotion stall - adding workers changes nothing. The decision table is ops.md §3: When to add workers. - Knobs in order of cost: the actor's
max_concurrentdamper (taskq actor-config set, live) → queue mode /fairness_keyfor head-of-line blocking →TASKQ_DISPATCH_OVERSAMPLEwhen many dispatchers poll one actor+queue →TASKQ_MAX_CONCURRENCY(restart-only; raises the derivedworker_pooland, on the per-slot path, the slot pool) → more replicas. ops.md §12 routes symptoms to knobs. - Read
taskq.queue.depth(pending + scheduled per queue; the leader samples every 15 s, the 100 deepest queues keep their series) againsttaskq.queue.live_workers(sampled in the same tick): depth with no live workers is an unserved queue, not a capacity shortage. Sweep health comes from thetaskq.maintenance_leader.*family -sweep_last_success_secondsstaleness,sweep_timeoutsrates, andsweep_batch_sizevs_configured(a latched breaker) - before concluding the fleet is too small. See observability.md. - Re-run the connection arithmetic at every fleet change - workers × (pools + 2) plus pooled plus your app's pools, at rolling-deploy peak (~2× steady) - and check it against
max_connections. Route the pooled DSN through PgBouncer once the total crosses the 80-connection threshold. The formula and the worked examples are in ops.md §4. - Shard by schema when a database boundary is the real need. The advisory-lock namespace is
taskq:{purpose}:{schema}(election, cron, prune, archive-expiry, migrate locks are all schema-qualified), and Redis keys carry the schema too, so one Postgres database can host independent fleets per schema - each electing its own leader. Two deployments on the same schema name are one fleet by design. The isolation boundary and its limits are in maintenance-sweeps.md §4 and architecture.md.
Related documentation¶
- ops.md §4: Sizing - the connection-budget derivation
- configuration.md: Derived Values - the pool formulas behind §1
- workgroups.md - the supervisor's full configuration reference
- maintenance-sweeps.md - the batch bounds and their failure modes
- deployment.md: Scaling Considerations - platform-level scaling (Kubernetes, Compose)