Operations & Adoption¶
This is the page to read when you have been told to move your workers to TaskQ and want to know the footguns, the patterns to follow, and the antipatterns to avoid, before your first incident. It compresses the operational content of the whole guide set into one pass: what each knob actually does, how the sizing math works, how to shape fan-out jobs, and how to classify failures.
Mechanics live in the canonical guides (linked throughout); this page is about decisions.
Read order for a new adopter
- This page, top to bottom.
- retries.md:
start_to_closevsschedule_to_close - deployment.md: Production Checklist
- observability.md: setup
Know which TaskQ you are actually running
The published PyPI release can be far behind main: everything in the
upgrading guide's Breaking API changes / Silent behaviour changes /
Unreleased features sections (keyed rate-limit refs, cancel_where, enqueue tags,
actor-config live tuning, the PG rate-limit backend on the dispatch path, dozens of
operational fixes) exists only in git pins. A taskq-py>=0.2.2 range can silently resolve
to the stale release, which carries known hazards the current docs no longer describe (for
example: start_to_close failing to cancel the actor body, unique_for races, a
payload-defect retry loop, and JSON results reviving UUID-shaped strings into UUID
objects). If you pin a git rev, assert the installed distribution really resolves to it
(a three-line test against importlib.metadata); if you use the release, read the docs
for that release, not main.
Contents¶
- The operating model in five minutes
- Timeouts:
start_to_closeandschedule_to_close - Concurrency: process, actor, queue, fleet
- Sizing: workers and Postgres connections
- Fan-out at scale: chunks, cursors, idempotency
- Classifying failures: terminal, retryable, transient
- Waiting politely: rate limits,
Snooze,RetryAfter,Retry-After - Observability and alerting
- Porting workers from other queue systems
- The footgun index
- Adoption checklist
- Scaling playbook: from signal to knob
- TimescaleDB: plain Postgres or hypertables?
1. The operating model in five minutes¶
A worker is one asyncio process. It runs a producer task, exactly max_concurrency consumer
coroutines, a heartbeat loop, a LISTEN/NOTIFY connection, and a leader-election loop. Scaling is
processes, not threads: FOR UPDATE SKIP LOCKED makes concurrent workers safe against the same
tables. See workers.md.
Queues route work. Every job sits in a queue; a worker only dispatches from the queues in its
TASKQ_QUEUES. Partition fleets by queue to keep a deep backlog on one workload from starving
another. See workers.md: Queue selection, and a queue
with a deep strict_fifo backlog starves everything behind it, which is a
priority problem, not only a partitioning problem.
One leader per schema. A schema-qualified advisory lock elects one worker to run the maintenance loops: sweeps (crash reclaim, deadline enforcement), scheduled-job promotion, cron. If the leader dies, another worker takes over at the next election. Nothing needs a dedicated process.
The database clock is the only arbiter. Every lease, deadline, and scheduled_at comparison
uses Postgres clock_timestamp(). Worker clocks never decide anything, which is why absolute
per-call schedule_to_close datetimes are deprecated in favour of interval budgets.
Delivery is at-least-once. A worker that dies mid-job has its jobs reclaimed after the lock
lease expires and retried (or marked crashed if the budget is gone). Design actors to be
idempotent; use the dedup tools in §5.
worker_group is a label, not a routing key. It is emitted as the OTel
messaging.consumer.group.name span attribute and nothing else. It does not partition queues and
carries no concurrency semantics. A "workgroup" in the deployment sense is the workgroup
supervisor: a process orchestrator that spawns N child workers from one TOML file
(workgroups.md). Note for managed-identity deployments: the stock supervisor
spawns plain taskq worker subprocesses and cannot carry a --pg-credential-provider flag, so
dual-auth-mode (DSN or Entra) deployments run their own supervisor, see
managed-identities.md.
2. Timeouts: start_to_close and schedule_to_close¶
The two names sound alike and bound different things. Full mechanics: retries.md §7.
start_to_close |
schedule_to_close |
|
|---|---|---|
| Bounds | One attempt's wall-clock execution | The job's entire lifetime, all attempts |
| Type | timedelta (interval) |
interval via actor retry.time_budget |
| Enforced by | asyncio.wait_for around the actor call |
SQL guards at dispatch/retry/snooze + leader sweep |
| When it fires | Attempt is cancelled, recorded as TimeoutError, classified by the retry policy like any failure |
Job fails permanently with error_class="DeadlineExceeded", but never kills a running attempt |
| Default | Unbounded | Unset (only kind="indefinite" actors get one, via time_budget) |
Decide per workload class¶
| Workload | start_to_close |
schedule_to_close |
|---|---|---|
| Webhook / API call | 30-60 s | RetryPolicy(kind="transient", max_attempts=5), no deadline needed |
| ETL chunk (see §5) | 5-15 min | unset, or a generous time_budget (requires kind="indefinite"; time_budget is inert on any other kind) |
| Poll-until-ready (invoice, export) | short | unset: the actor Snooze loop owns timing |
| Batch finalizer | minutes | size generously; see the finalizer warning in §5 |
Set a fleet-wide safety net and override per actor:
# Every actor on this worker gets a per-attempt ceiling unless it declares its own.
export TASKQ_DEFAULT_START_TO_CLOSE=5m
from taskq import actor
@actor(start_to_close=timedelta(hours=1)) # opts this actor up
async def reindex_bucket(payload: Payload) -> None: ...
Gotchas¶
- There is no opt-out. The precedence chain is per-enqueue >
@actor(...)>TASKQ_DEFAULT_START_TO_CLOSE. An actor that needs to run longer than the worker default must declare its own largerstart_to_close; declaringNonefalls through to the worker default. Cron fires and admin "run now" currently do not propagate per-actorstart_to_close(orschedule_to_close_interval); only the fleet-wideTASKQ_DEFAULT_START_TO_CLOSEprotects those paths. - A timed-out attempt consumes budget.
attemptis incremented at dispatch, so astart_to_closetimeout counts againstmax_attemptsexactly like a raised exception. - Sync actors are not stopped by
start_to_close.defactors run viaasyncio.to_thread; the timeout cancels the await, marks the job, and frees the slot, but the thread keeps running to completion, including its side effects. Pollctx.should_abort()in sync actors (actors.md: Sync actors). The same applies to subprocess-spawning actors: aSIGKILL'd or force-exited worker leaves the child running: reap your own children. schedule_to_closenever kills a running attempt. It gates future dispatches: expired pending jobs are never dispatched, retries/snoozes that would land past the deadline fail withDeadlineExceeded, and a leader sweep fails expired queued jobs. A running attempt that finishes after the deadline still succeeds.- The enqueue-time
heartbeat_timeoutparameter reclaims silent holders. A running job whose holder stops heartbeating past itsheartbeat_timeoutis crash-reclaimed by the leader's sweep even while its lease (the globalTASKQ_LOCK_LEASE, default 60 s) is still valid: the shorter of the two deadlines governs. Size it>= 2xthe fleet'sTASKQ_HEARTBEAT_INTERVAL; a value below one heartbeat interval reclaims a healthy job on a single missed beat.2xis enough to absorb one transient beat because the heartbeat loop anchors its wait to each tick's start rather than its end, so a slow tick does not push the next beat out by its own duration, and because a failed tick is not a beat: it stamps nothing, so instead of also sleeping the full remaining interval it retries promptly on a bounded backoff (a quarter interval), keeping the gap between the good beats around one blip well inside the2xfloor. The same rule names the upper-bound trap: aheartbeat_timeoutat or aboveTASKQ_LOCK_LEASEnever governs: the lease deadline (last beat + lease) always precedes the heartbeat deadline (last beat + timeout), so the lease arm reclaims first and the per-job budget silently no-ops. Size it inside the window:>= 2xTASKQ_HEARTBEAT_INTERVALand< TASKQ_LOCK_LEASE. - The stored
error_messageof a genuinestart_to_closetimeout is the literal string"start_to_close": alert onerror_class == "TimeoutError"if you want all timeouts, and remember an actor raising its ownTimeoutErroris indistinguishable by class.
How a worker death is recovered¶
lock_lease (60 s default) is the visibility timeout. The leader's sweep runs every
sweep_interval (30 s default), so worst-case reclaim is ~90 s, then the job retries on its own
RetryPolicy backoff curve (base, cap, backoff kind, jitter: the same curve an
application-level failure would use, not a flat interval), or lands crashed if attempts are
exhausted. Invariants that keep this safe:
lock_lease >= max(heartbeat_interval, heartbeat_command_timeout) +
(max_heartbeat_failures + 1) * (heartbeat_interval +
heartbeat_command_timeout) (the worst coherent failed-beat cascade - the last good beat's tail plus the failed cycles - and the watchdog kills a
stalled loop before its leases expire. Don't lower TASKQ_LOCK_LEASE without re-checking both
(configuration.md: Validation Constraints).
The heartbeat does not rewrite a healthy lease every beat: it renews a held
row only when the row's remaining lease drops to the renewal threshold,
max(lock_lease / 2, max(heartbeat_interval, heartbeat_command_timeout) +
(max_heartbeat_failures + 1) * (heartbeat_interval +
heartbeat_command_timeout)), compared server-side on the clock that
stamped the lease. Every term of that floor is enforced: the tick's whole
command sequence (BEGIN, the writes, the cancel hook, COMMIT) runs under a
single TASKQ_HEARTBEAT_COMMAND_TIMEOUT budget, its teardown (the bounded
rollback-or-close) shares that budget's remainder rather than burning a
second one, and the pool acquire is bounded by the heartbeat interval, so a
failed beat costs at most interval + command_timeout,
and the floor covers max_heartbeat_failures + 1 of them (the cascade the
lock_lease invariant above exists to bound). At the
default settings (60 s lease, 10 s interval, 2 s command timeout) the floor
is 58 s, at or above what the lease has to harvest, so the renewal runs
on every beat exactly as before; the rewrite savings apply from leases
of roughly 70 s upward (2× at 70 s, 4× at 90 s, ~6× at 120 s; see
perf-evidence-lease-renewal.md at the repo root for the
sizing math, the enforced-bound measurement, and the fix-round history).
A live worker's lease never comes closer to expiring than one worst
beat-gap below the renewal threshold (threshold − interval − 2 ×
command_timeout, or lock_lease − that same gap on every-beat configs):
renewal fires at or under the threshold, so the threshold itself is the
renewal point, not the closest approach. Jobs carrying the enqueue-time
heartbeat_timeout are renewed on every beat regardless, because the
sweep's heartbeat arm reads their last_heartbeat_at freshness, not the
lease.
3. Concurrency: process, actor, queue, fleet¶
Five different knobs are called "concurrency". Picking the wrong one is the most common misconfiguration:
| Knob | Scope | Semantics |
|---|---|---|
TASKQ_MAX_CONCURRENCY |
per worker process | Hard bound on simultaneously executing jobs (default 8). One consumer coroutine each. |
@actor(max_concurrent=N) |
per actor, fleet-wide | Best-effort admission damper, not a hard cap (see below). Operator-tunable live. 0 = drain mode: no jobs of this actor dispatch, silently. |
taskq queues set-max-concurrent |
per queue, fleet-wide | Strict leased-slot cap. Read at worker startup; needs a restart to change. |
ConcurrencyReservation |
per bucket, fleet-wide, cross-actor | Strict leased slots in Postgres, declarable inline on the actors: the only strict mechanism that can serialize two different actors (e.g. against one thread-unsafe library). See rate-limiting.md. |
@actor(singleton=True) |
per actor, fleet-wide | At most one active job (pending/scheduled/running). Second enqueue raises SingletonCollisionError. |
max_concurrent is not a hard cap
Dispatch reads the running-count once per round before locking rows. Two replicas dispatching
concurrently each admit up to the cap; the over-dispatch bound is
(num_producers - 1) * max_concurrent per round, and those jobs genuinely run. For
memory- or GPU-bound actors use a strict leased-slot cap (per-queue or
ConcurrencyReservation) instead. Full worked warning:
deployment.md: max_concurrent and max_pending.
A reservation's lease must exceed the actor's worst-case runtime
Reservation slots are leased; a slot whose lease expired is acquirable by the next job. If an
actor can run longer than its bucket's lease, two of them will hold one slot. Size the lease
above your p-max runtime, not your p99.
When to add workers: saturation, not depth¶
Depth alone does not say whether the fleet is short of slots or the slots are idle. The capacity gauges answer it per process and per actor:
| Signal | PromQL | Reads |
|---|---|---|
Utilisation ≥ 0.9 for 10 m and oldest_pending_age rising |
taskq_worker_active_jobs / taskq_worker_max_concurrency > 0.9 beside max by (actor) (taskq_jobs_oldest_pending_age_seconds) climbing |
every slot is busy and pending work is ageing: add workers (or raise TASKQ_MAX_CONCURRENCY where the actors are I/O-bound). |
Utilisation high, oldest_pending_age flat |
as above, age not climbing | the fleet is busy but keeping up; no action. |
Utilisation low, oldest_pending_age rising |
... < 0.5 with the age climbing |
slots are free and work still waits: not a capacity problem: an admission cap (max_concurrent, a reservation or rate limit: taskq_reservation_denials_total), an unserved queue (TaskQQueueUnserved), or a promotion stall (TaskQPromotionStalled). Adding workers changes nothing. |
| One actor holding the slots | sum by (actor) (taskq_jobs_running) dominated by one actor while others' oldest_pending_age climbs |
that actor's max_concurrent (or a queue cap) is the lever, not the replica count. |
Running age past the actor's p99 with timeouts flat |
max by (actor) (taskq_jobs_oldest_running_age_seconds) beyond histogram_quantile(0.99, rate(messaging_process_duration_seconds_bucket[1h])) while taskq_jobs_timeouts_total{kind="start_to_close"} does not move |
an actor with no start_to_close: the attempt has outlived what the actor normally takes and nothing will end it; declare the budget (see §2). |
taskq_worker_active_jobs / taskq_worker_max_concurrency are one series per worker process
(the health socket's taskq_active_jobs is the same number, but only a real scrape sees these
two); taskq_jobs_running{actor} is the fleet-wide running count per actor, sampled by every
worker beside taskq_jobs_by_status.
Capacity ownership: the seed-only trap¶
The @actor(max_concurrent=...) literal only seeds the actor_config row on first
registration. Two production-shaped consequences:
- Stored rows win. Once a row exists, code changes to
max_concurrentare ignored (logged asactor-config-capacity-override). Tune live without restarts: - A stored
NULLmeans uncapped, and pre-existing rows can null your literals. A schema first populated before an actor grew a capacity literal keepsmax_concurrentNULL forever: every decorator literal in the repo is dead config, nothing fails, and the fleet runs uncapped. This has happened across whole fleets (rows seeded by an early deploy, literals added later, zero signal). Audit with:
taskq actor-config list # treat any NULL capacity as a decision you never made
taskq actor-config set my_actor --max-concurrent 10
The exception is max_pending: a NULL stored value falls back to the code literal, so its
declarations work on deploy. (max_pending rejects enqueues past the queued depth with
MaxPendingExceededError, surfaced as taskq.backpressure.errors with kind="max_pending";
filter on the kind label, because the same counter also carries identity-serialization
refusals (unique_for_lock_timeout, idempotency_lock_timeout) that are lock contention, not
capacity pressure.)
Two viable ownership postures; pick one deliberately:
- Operator-owned (TaskQ's default): literals are seeds; operators tune with the CLI; drift is logged. Requires someone to watch the log line.
- Boot-time convergence: your startup code writes the literals into
actor_configon every boot, making code the source of truth. An operator's live tune then reverts on the next deploy; document that.
The queue assignment is operator-owned once a row exists: move an actor between queues
with taskq actor-config move-queue ACTOR NEW_QUEUE: the actor's pending/scheduled
backlog moves to the target queue in bounded batches, then one final transaction flips
the stored assignment and carries the old queue's mode/cap to the target (when the
target has no row of its own), so old-queue strays drain through the target's consumers.
Workers boot on either side of the matching code deploy (a
stale literal logs actor-config-queue-override and adopts the stored queue); ensure
workers consume the new queue, and keep consuming the old one until every producer runs
the new literal. Running jobs the move leaves behind finish on the workers that claimed
them; any that re-pend instead (failure retry, crash reclaim, operator retry) keep their
old queue label as an audit trail but are routed at dispatch by the actor's current
assignment, so the running-job tail drains through the target queue's consumers and
never strands on the retired source queue. A mid-drain abort (timeout, lost connection)
re-raises after the actor-queue-move-aborted event logs how many rows were already
committed onto the target; re-running the move continues where it stopped. metadata
is the remaining structural field: changing it after a row
exists refuses to boot (ActorConfigDriftList); set
TASKQ_FORCE_UPDATE_ACTOR_CONFIG=true for exactly one boot. Never leave it set. See
workers.md: ActorConfig sync.
Starvation: priority and fairness¶
A deep backlog on one queue starves everything queued behind it, including small, urgent jobs,
no matter how much concurrency you add. "No output from family X" with family X jobs sitting
pending is head-of-line blocking, and it is fixed with ordering, not capacity:
@actor(priority=N)/enqueue(priority=N): higher priority dispatches first. Priority is stamped at enqueue time: a standing backlog keeps whatever priority it was enqueued with, so after an incident you may need to bulk-UPDATEthe backlog's priority (or cancel and re-enqueue) to promote the victim family.round_robinqueue mode +fairness_key: interleaves dispatch acrossfairness_keycohorts (typically tenant or entity ids) so one tenant's 10,000-job sync cannot starve the others. Two traps: the mode is not the default and nothing in TaskQ sets it for you: runtaskq queues set-mode <queue> round_robinonce or seed thequeuesrow in your bootstrap (otherwisefairness_keyis a column TaskQ writes and never reads); and jobs enqueued without afairness_keyall collapse into one__null__cohort where round-robin degenerates to plain FIFO; nothing warns you. See workers.md: Queue dispatch modes.
Thread-unsafe native libraries¶
A def actor dispatched through asyncio.to_thread runs on a real OS thread pool: several jobs
in one process can drive one native library from several threads at once. Libraries that are not
thread-safe (PDF rendering toolkits are a classic) will segfault the interpreter, kill every
in-flight job in the process, and corrupt output. The fix is not a number in a config file; it
is a strict ConcurrencyReservation with one slot shared by every actor that touches the
library, across queues and worker processes. "Any safety property expressed as a number in a
config file is one out-of-band edit away from being lost."
Sync actors are bounded by the thread pool, not by max_concurrency alone. A fleet of
blocking def actors is limited by CPython's default executor (min(32, cpus + 4) threads per
process), and each blocked thread still holds its job slot. Prefer async actors; for CPU work use
a dedicated low-max_concurrency worker pool.
4. Sizing: workers and Postgres connections¶
Throughput math¶
Each worker process sustains, for one queue mix:
I/O-bound actors multiplex cheaply (16-64 per process); CPU-bound actors block the event loop
(2-4 per core). The workload table is in
deployment.md: Concurrency tuning. Scale horizontally by
adding processes; the fleet-wide caps from §3 still
apply. Remember your own app-side pools: an actor that opens a DB session per job needs its pool
sized to at least max_concurrency too; that counts against the same max_connections.
The connection budget¶
A worker holds up to four pools plus dedicated connections:
| Resource | Count (default) | DSN | Notes |
|---|---|---|---|
dispatcher_pool |
4 | direct | dispatch claims, sweeps, leader loops |
heartbeat_pool |
4 | direct | heartbeat transaction every 10 s |
worker_pool |
int(max_concurrency * 1.5) |
pooled (may traverse PgBouncer) | terminal writes, sub-enqueues, PG-backed rate limits |
| slot pool (conditional) | max_concurrency + 1 |
direct | per-slot transaction pool: only when a LOOP-scope asyncpg.Connection is registered and max_concurrency > 1; carries every per-job transaction (the actor's own writes via its injected slot connection, the terminal write, transactional sub-enqueues), one connection per consumer slot + one for the readiness probe, fully warmed at boot |
notify_conn |
1 | direct | LISTEN (session-scoped; cannot pool) |
leader_conn |
1 | direct | advisory-lock election (every worker) |
| leader-only monitor + cron | +2 | direct | only on the elected leader |
- Idle floor: pools open with
min_size=1, so an idle worker holds 5 sessions (7 as leader), growing under load to the maxima. - Default worker (
max_concurrency=8): 10 direct + 12 pooled = 22 max; 24 on the leader. - Per-slot path (LOOP-scope connection registered,
max_concurrency > 1): addmax_concurrency + 1direct; at the defaultmax_concurrency=8that is 19 direct + 12 pooled = 31 max (33 on the leader). - Per-
TaskQclient (web/API pod): one pool, ≤ 5 connections. - Workgroup supervisor with child health checks:
health_workers + 1extra.
Fleet formula:
direct = M * (dispatcher_pool_size + heartbeat_pool_size + 2 + slot_pool) + 2 # +2 = the leader's extras
pooled = M * int(max_concurrency * 1.5)
total = direct + pooled + web_pods * 5 (+ supervisor health pool) + your app's own pools
# slot_pool: max_concurrency + 1 on the per-slot path, 0 otherwise
Worked example: 10 workers at max_concurrency=16, defaults elsewhere:
direct = 10 * (4 + 4 + 2) + 2 = 102 # 272 on the per-slot path (+ 10 * 17 slot connections)
pooled = 10 * 24 = 240
total = 342 + your application's own connections # 512 on the per-slot path
Check this against Postgres max_connections including the app's pools, and including the
rolling-deploy doubling: orchestrators (Kubernetes rolling updates, Azure Container Apps
revisions) bring the new pods up before draining the old ones, so your fleet briefly runs at
~2× steady state. Omitting that term is the single most common way this budget gets
underestimated.
On the per-slot path there is a second doubling term: a credential rotation (SIGHUP /
TASKQ_RELOAD_INTERVAL) rebuilds the slot pool, and for the drain window the worker briefly
holds 2 × (max_concurrency + 1) slot-pool connections: the old pool draining plus the
new pool warm, bounded by the reload drain timeout. Plan max_connections against that peak
whenever rotations can coincide across the fleet (a scheduled TASKQ_RELOAD_INTERVAL is
exactly that); against the steady state only when rotations are staggered.
The worker logs its own budget at startup (worker-startup-budget); the same arithmetic is
taskq.worker.budget.compute_connection_budget(), which flags pgbouncer_recommended when the
total exceeds 80 (conservative below the Postgres default max_connections=100). The startup
line's direct count excludes the conditional slot pool; on the per-slot path the
transactional_consume_per_slot startup event states its size (slot_pool_size).
Small SKUs
break that assumption: a 50-connection managed Postgres with ~15 reserved has ~35 usable and
often cannot run PgBouncer at all: two default workers (44-48 connections) already exceed it.
On small SKUs, lower TASKQ_MAX_CONCURRENCY (the derived worker_pool shrinks with it) until
M × budget + app pools + rollout doubling fits.
Changing TASKQ_MAX_CONCURRENCY is a restart-only operation: SIGHUP does not re-read
settings, and on the per-slot path the setting sizes the slot pool (max_concurrency + 1
direct connections, fully warmed at boot), so a change moves the direct-connection budget at
the next startup. On that path it is also boot-blocking; a worker that cannot open the slot
pool fails to boot, naming the pool and the DSN host.
PgBouncer: pooled DSN only
Only worker_pool may route through transaction-mode PgBouncer (TASKQ_PG_DSN_POOLED).
LISTEN, advisory locks, and the sweeps require direct sessions
(TASKQ_PG_DSN_DIRECT). Splitting the two DSNs is the supported pattern (see
configuration.md and
workers.md: PgBouncer compatibility).
Bring-your-own pools change the arithmetic¶
If you pass your own PoolFactory through WorkerConnections
(managed-identities.md), TaskQ invokes it once per role:
dispatcher, heartbeat, and worker pools become three distinct full-sized pools, not one
shared pool, and a worker on the per-slot path adds a fourth, conditional pool: the
per-slot transaction pool. That pool deliberately has no WorkerConnections slot (it is
worker-internal, built by TaskQ on the direct DSN), so a fleet whose credentials live in its
own pool factories must pass pg_credential_provider to worker_main / worker_main_async
(the worker warns slot_pool_own_credentials at boot when caller-supplied pools are in play
with no provider). Size and count accordingly.
Never hand TaskQ a pool your request handlers already use
asyncpg's Pool.acquire() has no default timeout. A request that holds one connection from a
shared pool and then enqueues (which needs a second connection from the same pool) will, at
pool_max concurrent requests, wait forever: a silent, permanent deadlock of the whole
process, with green health probes. Give the TaskQ client its own small pool (≤5), always.
Managed identities and token rotation¶
With token-based auth (Entra ID/AAD, IAM, Vault), asyncpg pools keep the password they were built
with: a worker that never rebuilds its pools looks perfectly healthy for the first hour and then
cannot open a new connection. Size TASKQ_RELOAD_INTERVAL inside your token lifetime with margin
(a common default is 40 minutes against ~60-minute tokens), or SIGHUP to force a reload.
A provider-backed slot pool rotates with the rest, keeping its boot-time size
(TASKQ_MAX_CONCURRENCY is boot-only). In-flight jobs get the reload drain timeout to finish;
past it the old pool terminates; the job's transaction rolls back, the row stays running,
and lock-lease expiry reclaims and retries it: a loud, retryable infrastructure failure, never
a false success. For the drain window's connection peak see the rotation note in
§4.
Cover all five connection roles (dispatcher/heartbeat/worker pools, notify, leader) in your
factories: a partial override yields a worker that starts, dispatches nothing, and never logs
leader-elected. The slot pool is not one of them: it is worker-internal and takes its
credentials from pg_credential_provider or the direct DSN. Full reference:
managed-identities.md.
The notify reconnect lock's worst-case hold. A NOTIFY connection reconnect (health-check
failure, or a SIGHUP credential reload) serializes on notify_reconnect_lock for the sum of
every step inside it: the credential factory call (TASKQ_RELOAD_FACTORY_TIMEOUT, 30s default),
each of the three channels' LISTEN execute and add_listener registration
(TASKQ_NOTIFY_LISTENER_SETUP_TIMEOUT, 10s default, ×2 steps ×3 channels = 60s), and the failed
connection's bounded close (5s): ~95s at shipped defaults. Every individual step is bounded,
so this is never a hang, but for that whole span a concurrent SIGHUP reload reports the notify
connection as failed and dispatch runs on the poll fallback instead of NOTIFY wakeups; to an
operator watching a rotation that looks identical to a wedged worker. It resolves on its own;
widening any of the three timeouts above raises this worst case by the same amount.
Database performance knobs¶
Four Postgres-side settings worth deciding deliberately: each closes a measured incident class.
jit = off for the queue role. During enqueue/dispatch churn the planner's cost model crosses
jit_above_cost (measured at a planner cost of ~4.5M on a busy jobs table) and JIT compilation
made dispatch 45× slower. Disable it once for the role TaskQ connects as:
or per-pool, with no role-level access, by appending
?options=-c%20jit%3Doff (URL-encoded -c jit=off) to the DSN's query string. The repo's
docker-compose.yml carries the equivalent commented block.
Statement-cache sizing: set for you. asyncpg caches prepared statements per connection in an
LRU of statement_cache_size entries (asyncpg default: 100, evicted after
max_cached_statement_lifetime seconds: 300). TaskQ's read paths render far more distinct SQL
texts than 100 (list_jobs emits 384+ filter-combination variants, and the admin pages add more)
so a client or dashboard whose filters vary thrashed the default cache: a measured 90-96 %
steady-state miss rate, slower than disabling the cache entirely (each miss re-pays the
Parse/Describe round trips, +10-40 ms/call at managed-PG RTT). Every pool TaskQ builds now passes
statement_cache_size=512 and max_cached_statement_lifetime=3600, enough for the variant space
with headroom, and no needless re-prepares on long-running workers. The write hot loop (dispatch,
enqueue, terminal writes, sweeps) is textually stable and unaffected.
Both knobs are settings, not hard-coded constants: TASKQ_STATEMENT_CACHE_SIZE and
TASKQ_MAX_CACHED_STATEMENT_LIFETIME (fields statement_cache_size / max_cached_statement_lifetime
on TaskQSettings) flow into every pool TaskQ builds. 0 is meaningful on each: the cache
disabled, or no lifetime cap. These knobs apply only to pools TaskQ builds: bring-your-own
pools (WorkerConnections factories, a caller-supplied pool=) must set the same two
create_pool kwargs themselves: TaskQ cannot resize a pool it did not build.
One deployment must set them to 0 regardless: behind a transaction-mode pooler
(TASKQ_PG_IS_POOLED=true; PgBouncer pool_mode=transaction and friends remap server connections
between statements, so a cached prepared statement's Parse and Bind can land on different server
connections and fail with SQLSTATE 26000). TaskQ then forces statement_cache_size=0 and
max_cached_statement_lifetime=0 on every pool it builds, overriding these two knobs, and treats
the pooler-remap statement errors as transient.
Autovacuum overrides for the hot tables. jobs and job_events turn over hard; the default
autovacuum_vacuum_scale_factor (0.2 of the table) lets bloat accumulate until vacuums are rare
and huge. Per-table overrides, tuned for queue churn:
ALTER TABLE {schema}.jobs SET (autovacuum_vacuum_scale_factor = 0.01,
autovacuum_vacuum_insert_scale_factor = 0.05,
autovacuum_vacuum_cost_limit = 2000);
ALTER TABLE {schema}.job_events SET (autovacuum_vacuum_scale_factor = 0.01,
autovacuum_vacuum_insert_scale_factor = 0.05,
autovacuum_vacuum_cost_limit = 2000);
The insert scale factor is the one that matters for an append-heavy table: dead-tuple thresholds alone may never fire autovacuum (which also refreshes the statistics the planner needs).
fillfactor (70-90): consider, don't copy. Job rows are written once, then updated in place
through their status transitions; a lowered fillfactor keeps those updates HOT (same page, no
index churn). Try 80-90 on jobs only if profiling shows HOT-chain fragmentation; changing it
rewrites the table (VACUUM FULL / pg_repack), so measure before and after.
5. Fan-out at scale: chunks, cursors, idempotency¶
The antipattern: one giant job¶
A "sync everything" job that runs for five hours:
- occupies a concurrency slot for the whole run and bottlenecks every pipeline behind it: nothing downstream proceeds until all the work succeeds;
- gets killed by
start_to_closeif you set one (and if you didn't, a hang holds the slot forever), then retried into the same death untilmax_attemptsorschedule_to_closegives up; - makes a resync repeat the entire five hours; the processing never proceeds.
Batch-shaped work must be chunked. Three supported shapes:
Pattern A: cursor chain (recommended default)¶
Process one page per job; each job enqueues the next page. Progress is durable, per-chunk retries are cheap, downstream consumers start as soon as the first chunk lands, and the queue drain rate is bounded by your concurrency/rate-limit settings instead of one job's runtime.
from datetime import timedelta
from pydantic import BaseModel
from taskq import JobContext, actor
class RowPayload(BaseModel):
run_id: str
row: dict # whatever `fetch_page` returns per row
class SyncPagePayload(BaseModel):
run_id: str
cursor: int # your own pagination cursor; TaskQ has none to pass for you
async def fetch_page(cursor: int, limit: int) -> tuple[list[dict], int | None]: ...
@actor(start_to_close=timedelta(minutes=10))
async def sync_page(payload: SyncPagePayload, ctx: JobContext[SyncPagePayload]) -> None:
rows, next_cursor = await fetch_page(payload.cursor, limit=500)
for row in rows:
await ctx.jobs.enqueue(
process_row,
RowPayload(run_id=payload.run_id, row=row),
# Stable per-row key: without this a parent retry re-enqueues every
# child (autonomous sub-enqueues commit immediately and do not roll
# back with the parent).
idempotency_key=f"row:{payload.run_id}:{row['id']}",
)
if next_cursor is not None:
# Stable key: re-enqueueing this page after any failure dedups instead of duplicating.
await ctx.jobs.enqueue(
sync_page,
SyncPagePayload(run_id=payload.run_id, cursor=next_cursor),
idempotency_key=f"sync:{payload.run_id}:{next_cursor}",
)
Why the pieces matter:
- The cursor lives in the payload. TaskQ's
cursortype is keyset pagination for read APIs (client.list(...)); there is no built-in job-continuation cursor. Your cursor is your data. - The idempotency keys make re-dispatch safe. Both the per-row enqueues and the
successor page carry stable keys. If the parent is retried after enqueuing children,
the duplicates are dropped and the existing jobs returned (
was_existing=True); the chain cannot fork and rows are not processed twice. - Transactional vs autonomous sub-enqueue: know which one you are on. Two modes, decided by
your DI setup, not by the call:
- Autonomous (the default worker): no LOOP-scope
asyncpg.Connectionis registered, soctx.jobs.enqueuecommits each child immediately through the worker pool. A parent failure does not roll back its children; the startup log warnssub_enqueue_autonomous_fallback. The idempotency keys are your correctness backstop (a re-run parent re-enqueues the same children, which dedup). Registering anasyncpg.PoolatScope.LOOPdoes not leave this mode: the transactional path keys onConnection, so a Pool-only registration silently keeps autonomous commit in force. - Transactional: register a LOOP-scope
asyncpg.Connectionand children join the parent's transaction (rollback on failure). Atmax_concurrency > 1the worker opens a per-slot transaction pool: each job transacts on its own connection, and the actor receives that same slot connection by injection, so the actor's own writes join the transaction too, making transactional consume and throughput compatible at any concurrency. Keep the chaining actor on a dedicatedTASKQ_MAX_CONCURRENCY=1worker only when the registered LOOP-scope connection carries session state (SET ROLE,search_path, an RLS-driving GUC) that the transactional writes must inherit (on the per-slot path the actor's writes run on fresh slot connections that never carried that session state), or to keep the connection budget minimal (nomax_concurrency + 1slot pool).
- Autonomous (the default worker): no LOOP-scope
- For a self-continuing poll loop (same job comes back),
raise Snooze(delay)instead of enqueueing a successor; see §7.
Pattern B: fan-out batch + finalizer¶
Enqueue up to 1,000 children in one enqueue_batch(...) call (or stream chunks of 1,000) and
gate completion with a finalizer that snoozes until all children are terminal. From inside an
actor, pass an explicit batch_id and enqueue the finalizer as its own job:
from uuid import NAMESPACE_URL, uuid5
from taskq.batch import EnqueueItem
items = [
EnqueueItem(actor_ref=process_chunk, payload=p, idempotency_key=f"chunk:{run_id}:{i}")
for i, p in enumerate(chunks)
]
# Deterministic id: a parent retry must reuse the same batch id. `new_uuid()`
# (UUIDv7) mints a fresh id per attempt, so deduped children keep attempt one's
# batch id while the finalizer points at an empty batch id, and
# `wait_for_batch` then returns `is_complete=True` on the wrong id. `uuid5`
# keeps the id stable across attempts; the finalizer's own idempotency key
# dedups the second enqueue (`was_existing=True`).
batch_id = uuid5(NAMESPACE_URL, f"taskq-batch:{run_id}")
await ctx.jobs.enqueue_batch(items, batch_id=batch_id)
await ctx.jobs.enqueue(
finalize_run,
FinalizePayload(batch_id=batch_id, expected=len(items)),
idempotency_key=f"finalize:{run_id}",
)
Always UUIDv7, never uuid4(), except retry-safe in-actor batch ids
TaskQ generates every internal id (jobs, workers, batches) as UUIDv7, time-ordered, so
B-tree index inserts stay local and ids sort by creation time. Use new_uuid (exported from
taskq) for any id you choose yourself, such as an explicit batch_id; unless the
batch is enqueued from inside an actor that may retry, in which case derive the id
deterministically from the run (e.g. uuid5) as above so every attempt reuses it.
The finalizer= parameter exists on the client's enqueue_batch (client.enqueue_batch(items,
finalizer=...)); the in-actor SubJobEnqueuer.enqueue_batch takes only batch_id, so enqueue the
finalizer separately as above. The finalizer's wait_for_batch(db, batch_id) raises Snooze on a
10 s interval until the batch completes, then runs once. Worked example:
examples/actors/batch.py and
jobs-clients.md: Batch finalizer.
Size the finalizer's budget generously
A snooze whose now + delay would land past schedule_to_close fails the job with
DeadlineExceeded instead of rescheduling. A finalizer that snoozes for hours needs either
no deadline or (on a kind="indefinite" actor; time_budget is inert on any other kind)
a retry.time_budget that comfortably exceeds the expected batch duration (plus retries).
Note a default-kind (transient) finalizer that snoozes has neither a deadline nor an
attempt ceiling (a snooze refunds the attempt instead of consuming it), so make it
indefinite with a budget if you want it bounded.
Pattern C: app-level run accounting + finalize sweep¶
When completion spans generations of jobs (a chunk enqueues chunks), a single batch_id cannot
cover the chain. Keep a durable counter on your own run/entity rows: increment at enqueue
(gated on was_existing; deduplicated enqueues must not double-count), decrement when a
job lands terminal (in the actor, or via on_retry_exhausted), and run a periodic sweep cron
that force-closes runs whose counter has been nonzero too long. This also catches wedged runs
that a pure finalizer would wait on forever.
Choosing the enqueue API¶
| API | Size | Idempotency | Notes |
|---|---|---|---|
enqueue_batch |
≤ 1,000 | per-item keys honored; same-actor collisions return existing jobs, a collision with another actor's job refuses the whole batch (IdempotencyKeyActorMismatchError) |
single transaction; max_pending enforced per actor; over-cap actors' items refused (typed BatchMaxPendingExceededError naming the actor + item indices), everyone else's admitted |
enqueue_batch_streaming |
unbounded (chunks of ≤ 1,000) | per-item keys honored | generator input; max_pending enforced per chunk with the same per-actor partition; with no caller connection each chunk is its own committed transaction; a failure leaves the committed prefix durable (retry via idempotency keys or the error's refused indices) |
enqueue_batch_fast |
≤ 50,000 | none: any duplicate key aborts the whole COPY with DuplicateIdempotencyKeyError (IdempotencyKeyActorMismatchError when the holder is another actor's) |
bulk-import semantics; returns a count only; max_pending enforced per actor (same partition; COPY stays all-or-nothing on constraint violations) |
See jobs-clients.md for the full tradeoff table.
Choosing a dedup mechanism¶
| Mechanism | Semantics | Use when |
|---|---|---|
idempotency_key (+ idempotency_scope) |
Exact. DB unique index; duplicates return the existing job, no error | re-enqueue after failure/crash; exactly-once scheduling |
unique_for + identity_key |
Windowed, single-flight. A transaction-scoped advisory lock serializes the preflight-then-insert per identity on pool and bare-caller connections alike, so concurrent enqueues dedupe against the winner's row | suppressing duplicate triggers inside a freshness window |
singleton=True |
One active job per actor | "never two of me at once" (mind the cron interaction below) |
Idempotency-key discipline: every rule below was learned from a production incident:
- There is no TTL. A key dedups against any row that still exists; the horizon is retention:
30 d for
succeededrows, 90 d forfailedrows by default. Need a time window? Encode it in the scope:idempotency_scope=f"daily-sync:{date}". - Keys are status-blind. A terminal-
failedjob's key absorbs every re-post onto the dead job: your post-fix rerun of a failed batch will silently no-op. Encode an attempt or window axis (key=f"...:{attempt}", or bump a version in the key when the entity's state changes). - Keys must encode every distinguishing payload dimension. TaskQ cannot see payload contents: a key that carries only one dimension of a composite identity collapses two different work items onto one job, and the second item's work silently never runs: work loss, not duplication.
- No stable key on a self-continuation successor. A continuation job carrying the same key as
its (now-
succeeded) predecessor collides with it and is silently dropped. Successors of the same actor need either a fresh key dimension (the next cursor) or no key. - Namespace client-supplied keys. A raw user-supplied string as the whole key lets one caller squat another caller's dedup slot; mix the caller identity into the key.
- Length cap: 1024 bytes (
TASKQ_IDEMPOTENCY_KEY_MAX_BYTES); oversize keys raise at enqueue. Hash over-cap values (keep legible keys below the cap). unique_forwithoutidentity_keyat enqueue is a silent no-op (logged warning).unique_foron a thin root does not single-flight its fan-out: a root that finishes in seconds frees the window while its child chain still runs; a cron cadence faster than the chain will overlap runs. Single-flight the work, not the root (a running-run guard in your own state, or a strict reservation).metadata.batch_idis library-reserved and stripped from caller-supplied metadata; a caller-suppliedmetadata.singleton=Trueon an ordinary actor is not stripped: it survives onto the row and triggers real single-flight behaviour (enqueue preflight and thejobs_singleton_uniqindex key on it).- For fleet-wide completion reactions (rather than a finalizer), the durable-cursor pattern
TaskQ.watch_reclaims()is documented in architecture.md: which component drives each transition.
tags are the group-operation tool: enqueue(..., tags=[f"run-{run_id}"]) then
JobFilter(tags=(...)) for status polling and bulk cancel_where. Tags must match
\A\w(?:[\w\-]*\w)?\Z (no colons), and invalid tags raise at enqueue, so a bad tag factory fails
every trigger, not just one.
Cron and scheduled workloads¶
scheduled_atis timezone-aware only (naive datetimes raise), and promotion runs on the leader's ~1 s tick; that's your timing precision.- Cron fires on schedule regardless of the previous fire. A cron actor slower than its
cadence piles up overlapping runs. Overlap suppression options:
singleton=Trueon the actor: a fire landing while the previous fire is still active is suppressed, not failed: noconsecutive_failuresstrike, no auto-disable. Safe for actors that routinely outrun their cadence.identity_keyon the schedule +unique_foron the actor: fires inside the window dedup silently (no strike). The window must cover the cadence, and it single-flights the root only (see the dedup rule above).- An app-level running-run guard is the robust option for chains that outrun their cadence.
- Misses past the catch-up window are dropped, and counted. A fire overdue by more than
TASKQ_CRON_CATCH_UP_WINDOWis skipped: the recompute landsnext_fire_atin the future and the dropped occurrences are never replayed. The drop is instrumented, not silent —taskq.cron.skipped_slots(per actor, in fire units), the skip depth ontaskq.cron.slots_behind, andskipped_slotson both thecron missed slots skippedwarning and thecron firedline. A sustained skip rate is the runaway fan-out (jobs clearing slower than the cron period): theTaskQCronSkippedSlotsalert pages on it, and the fires-vs-clearance SQL intaskq.insights(src/taskq/insights.py) names the schedule. - Decorator registration is create-only.
@cron(...)creates the schedule row on first sight and never updates it; a changed expression, timezone, or a removed decorator has no effect on an existing row. Manage live schedules withclient.update_schedule/delete_schedule(an empty expression does not disable a previously-registered schedule). - Auto-disable is permanent until re-enabled: check
taskq.cron.disabled_schedulesand thecron schedule auto-disabledlog line (kind="cron_fire";cron.auto_disabledis only an OTel span event, not a log event); a silently-disabled schedule is an outage with one log line. - Give cron actors a
start_to_close: an unbounded cron actor that hangs holds its queue. Note cron fires currently do not propagate per-actorstart_to_close(only the fleet-wideTASKQ_DEFAULT_START_TO_CLOSEapplies), so the safety net above is what protects cron.
6. Classifying failures: terminal, retryable, transient¶
TaskQ has no exception subclass that marks "permanent". You classify by configuration, in this order (first match wins):
non_retryable_exceptions=(MyError,)on the actor: isinstance match, subclasses included.- Auto-fail classes:
PayloadValidationError,ResultTooLarge, pydanticValidationError(a re-run reproduces the same problem). - The actor's
RetryPolicy.kind:non_retryable→ fail;transient→ retry whileattempt < max_attempts;indefinite→ retry untilschedule_to_close. - Per-instance refinement: a
retry_classifierhook returningRetryOverride(kind=..., delay=...).
Doctrine: put each failure class where it belongs:
| Failure class | Classification | Why |
|---|---|---|
| Malformed payload, schema violations, bad arguments | terminal (PayloadValidationError is auto-terminal) |
deterministic: retries reproduce it |
| Auth failures, permission denied, "unknown resource" for the job's whole subject | terminal via non_retryable_exceptions or classifier kind="non_retryable" |
the condition will not heal |
| Per-item faults inside a fan-out (one deleted file in a 500-item page) | per-item fault, skip and continue, not a job failure | one unreadable item must never fail the whole page/run; record it and move on |
Downstream 429 / Retry-After |
transient with the server's delay: RetryAfter or classifier RetryOverride(delay=...) |
retrying now makes it worse; retrying then succeeds |
| 5xx, timeouts, connection resets | transient with bounded max_attempts |
healable, but bound the budget |
| "Down until Tuesday" (maintenance window) | Snooze (no budget consumed) or kind="indefinite" + time_budget |
known-duration waits should not burn attempts |
Size the retry window to span a routine provider blip. RetryPolicy(max_attempts=4,
base=timedelta(seconds=10)) covers only ~70 s of cumulative delay (10 + 20 + 40 jitter-free); an ordinary object-store 5xx
blip outlasts that, exhaustion is terminal, and (if your on_retry_exhausted marks entities
dead) the entity silently disappears from downstream surfaces. For network-bound actors, prefer
fewer attempts with a longer base over many fast ones.
Multiplicative jitter does not decorrelate a synchronized cohort. The default ±20% around a
5 s base re-clusters a fleet that failed together into a ~2 s window at T+5s; at large fan-out
that is a synchronized retry wave. Disperse cohorts with a longer base and higher jitter, or
shed load with max_pending before the wave forms.
Never catch a failure and return a result
An actor that catches the provider's 429/5xx and returns (ok=False, retryable=True) has
succeeded as far as TaskQ is concerned: the job is succeeded, the retry policy, the
classifier, the advertised Retry-After, and on_retry_exhausted never engage. Raise, or
translate to your typed retryable exception and raise that.
There is also no poison state and no dead-letter queue. A job whose budget is exhausted lands
in terminal failed with error_class MaxAttemptsExceeded / DeadlineExceeded / your
exception's class, and stays queryable in jobs until retention prunes it. Routing to a real DLQ
is your job, at the two designed hook points: on_retry_exhausted (per actor) and the
ErrorReporter (per worker, e.g. Sentry forwarding). Note: on_retry_exhausted receives a bare
JobRow with no DI scope: hooks must build their own connections and cannot inject. See
retries.md: on_retry_exhausted and
observability.md: Error reporting.
Two more states worth naming because they mean infrastructure, not your code:
crashed: the worker died (or lost its heartbeat) mid-attempt with the retry budget exhausted. Crash labels areWorkerCrashed(assumed gone) andHeartbeatLost(alive but partitioned).abandoned: only ever produced by cancellation escalation: a force-cancel whose cleanup did not finish within the grace periods after an operator's cancel request. A timeout or exception never producesabandoned, and shutdown never produces it either: a deploy releases (interrupts) the job back to the fleet instead, the spent attempt standing.
Infra failures during the terminal write leave the job running, on purpose
If Postgres itself errors while recording the outcome, the write is retried for about a
second (waits of 50, 200 and 800 ms, four attempts, within a 5 s wall-time budget); if it
still has not landed, the row is left running and reclaimed by lease expiry (~90 s worst
case with defaults) rather than misclassifying an infrastructure blip as your actor's
failure. Don't be alarmed by a short-lived running row on a dead worker; the sweep owns
it. The reclaim re-runs the attempt: the actor's work finished but its outcome was
never recorded, so this is the at-least-once delivery case that does not involve a worker
crash. Actors whose side effects are not naturally idempotent should guard them with
idempotency_key / unique_for on the jobs they enqueue and idempotent writes of their
own (§5).
A routine SIGTERM drain sets the same cancel event an operator cancel does
ctx.cancel_event / ctx.should_abort() fire during every rolling deploy's drain phase.
What the row records is the actor's own outcome: if your actor treats "cancelled" as "close
the run as cancelled and return", the job records succeeded (it returned normally), no
retry runs, and successor enqueues never happen; a chain whose actor returns early on cancel
dies on every deploy. Read ctx.cancel_origin to tell the deploy apart from the operator:
on SHUTDOWN the attempt is released and re-run by the fleet with the
interrupted attempt spent, so checkpoint via progress state and
re-raise; on OPERATOR the job terminalises, so the
partial result you return is the one kept. See
cancellation.md: Shutdown is not an operator cancel.
7. Waiting politely: rate limits, Snooze, RetryAfter, Retry-After¶
Rate-limit denial is a snooze, not a failure¶
When an actor's rate limit or reservation denies admission, the actor body never runs: the
job is rescheduled at now + retry_after (computed by the limiter store in the DB/Redis clock
domain), the slot is freed, and no retry budget is consumed. A denial is admission control
with HTTP-429 semantics ("come back later", indefinitely retryable), not an execution, so it
writes no job_attempts and no job_events row: per-denial rows grow without bound under
sustained contention, so contention is carried by the aggregated rate_limit_blocked_count on
the job row (snooze_count for an actor-requested deferral), while metadata.awaiting names
the bucket being waited on. A denied job waits in Postgres; this is not busy-spinning in the
worker.
A denial never terminally fails a job either: the job reschedules until capacity frees, and its
only terminal exit is its own schedule_to_close deadline, reached through the ordinary
deadline path. To find jobs starving for admission rather than progressing, sort by
rate_limit_blocked_count.
from taskq.ratelimit import SlidingWindow, TokenBucket, registry
# Downstream allows 50 req/s fleet-wide (`capacity` is the burst allowance,
# `refill_per_second` is the sustained rate):
registry.register(
TokenBucket(name="partner_api", capacity=50, refill_per_second=50.0, backend="redis")
)
@actor(rate_limits=["partner_api"])
async def call_partner(payload: Payload) -> Result: ...
Per-tenant / per-entity quotas use keyed refs; the bucket name is derived per payload key:
from taskq.ratelimit import KeyedRateLimitRef
@actor(
rate_limits=[
KeyedRateLimitRef.typed(
Payload,
base_name="partner_per_tenant",
key_fn=lambda p: p.tenant_id,
capacity=10,
refill_per_second=0.5,
backend="redis",
),
]
)
async def call_partner_per_tenant(payload: Payload) -> Result: ...
Scope matching: rate_limits is per actor. A quota shared across several actors (one
provider, many callers) needs a single shared bucket referenced by all of them; the limit
registry is global, so register the bucket once and list it on every actor that spends it. This
is separate from your HTTP-gateway rate limiting (429s at the edge); TaskQ limits govern
background consumption.
Backends: redis (default for TokenBucket/SlidingWindow; requires TASKQ_REDIS_URL; the
worker fails fast at startup if a Redis-backed limiter is served without Redis), postgres
(needs only the standard TaskQ schema), or memory (per-process, tests only). On Redis
connection errors the limiter falls back to the PG implementation by default
(TASKQ_RATE_LIMIT_PG_FALLBACK_ENABLED). Full reference:
rate-limiting.md.
A saturated reservation does not tax its neighbours¶
Dispatch is reservation-aware at claim time. The claim statement reads live slot occupancy in
reservation_slots (which actors' running jobs currently hold live slots, and how many slots in
those buckets remain acquirable) and folds it into per-actor admission: an actor whose held bucket
is full is admitted nothing that round, and a partially free held bucket admits at most its
free count (times the dispatch oversample). Without this gate, a saturated actor's pending jobs
were claimed into the worker's shared TASKQ_MAX_CONCURRENCY consumer pool, denied by the
post-claim acquire, and snoozed, every cycle, for as long as the backlog and the exhaustion both
lasted, each denied job occupying a consumer slot while it was denied (measured: 25-45% throughput
loss for a healthy actor sharing the worker; after the gate, none (see
perf-evidence-dispatch.md).
What you observe instead:
- The saturated actor's rows stay
pending(never claimed), so they appear in the per-actor backlog series (taskq.jobs.actor_backlog/taskq.jobs.oldest_pending_age_seconds) as what they are: work waiting on capacity. A saturated actor is more visible, not less. - When capacity frees (a holder finishes, or its lease expires: the same acquirability definition the acquire path uses), the gate re-opens on the next dispatch round and the actor drains. The gate is a damper, never a starvation mechanism: the first claim of a never-running actor is never gated, so capacity can always be taken.
- The post-claim acquire remains the admission authority. A claim-to-acquire race (a peer takes the
last slot in between) degrades to one bounded denial cycle per race, with the 429 semantics above
intact: no retry budget spent, no per-denial rows,
rate_limit_blocked_countbumped.
Because the gate derives pressure from holder state (reservation_slots.job_id → the holding
job's actor) rather than from actor declarations, it covers static reservations, keyed
reservations, and the per-queue concurrency cap (queues.max_concurrent) alike. Two boundaries to
know: a bucket shared across different actors gates only the actors currently holding it (a
co-declarant with nothing running is discovered by the ordinary post-claim denial path), and
token-bucket / sliding-window rate limits are not read by the gate at all (their denials are
discovered post-claim and snoozed at the limiter's retry hint, the behaviour described above).
A drained fixed-quota bucket repolls every 5 s, forever
A TokenBucket with refill_per_second=0 never refills and returns retry_after=None, so
denials fall back to the 5 s default backoff. Without a schedule_to_close, such a job
re-queues every 5 s indefinitely. Either give the bucket a refill rate, or give the actor a
deadline (retry.time_budget requires kind="indefinite").
Dev/prod Redis asymmetry
A common compose/dev setup sets TASKQ_REDIS_URL while production deliberately runs
Postgres-only. A Redis-backed limiter added in dev then refuses to start the production
worker: fail-fast at bootstrap, naming the limiters. Decide the backend per environment,
not per habit.
The three "come back later" signals¶
| Signal | Consumes budget | Reschedules at | Use for |
|---|---|---|---|
raise Snooze(delay) |
No: the claim's attempt increment is released, so attempt returns to what it was and max_attempts never moves |
now + delay |
waiting for a condition (batch completion, external state) |
raise RetryAfter(delay) |
Yes (default); bounded by max_attempts, terminal MaxAttemptsExceeded when out |
now + delay |
a retry that should wait a known time (429s) |
raise RetryAfter(delay, consume_budget=False) |
No (snooze semantics) | now + delay |
known-delay wait that must never exhaust the budget |
A deferral on a row carrying an operator's cancel request is refused (noop) and the cancel ladder terminalises the row; a deferral whose schedule_to_close lapses on such a row terminalises cancelled, never DeadlineExceeded: operator intent outranks the deadline.
A classifier RetryOverride(delay=...) is the type-level variant: it applies to a whole
exception class and is clamped by max_retry_backoff; RetryAfter's delay is not clamped
(only schedule_to_close gates it).
Honoring Retry-After (and x-retry-after) headers¶
TaskQ does not automatically honor any HTTP header; there is no setting or integration that does this. The supported pattern is to read the header yourself and translate it into a signal:
from taskq import RetryAfter, RetryPolicy, actor
@actor(retry=RetryPolicy(kind="transient", max_attempts=8))
async def call_api(payload: Payload) -> Result:
resp = await http_post(payload.url)
if resp.status == 429:
# Standard header (seconds) or your gateway's x-retry-after; same translation.
wait = float(resp.headers.get("Retry-After") or resp.headers.get("x-retry-after") or 60)
raise RetryAfter(delay=timedelta(seconds=wait))
if resp.status in (404, 410):
raise UnknownResource(payload.url) # listed in non_retryable_exceptions
return parse(resp)
For per-status-code policy (429 → wait the header's delay, 404 → terminal, 5xx → bounded
transient), use the retry_classifier example in
retries.md §5; it carries the
header value through RetryOverride(delay=...) and inherits the max_retry_backoff clamp, so a
malformed Retry-After: 999999999 cannot strand the job.
Scheduled re-enqueue as the "snooze until it can run" fallback¶
When the wait is longer than any job should hold a slot, don't snooze: enqueue a successor with
a scheduled_at (timezone-aware; promotion tick ~1 s) plus an idempotency_key, and return
success. The job queue is your timer, and dedup makes the pattern crash-safe.
8. Observability and alerting¶
Wiring an exporter¶
TaskQ emits via the OpenTelemetry API and never overrides standard OTel vars. Two supported shapes:
- OTLP (collector, Jaeger, Tempo, Datadog, Sentry, PostHog): install
taskq-py[otel]and setOTEL_EXPORTER_OTLP_ENDPOINT(:4317gRPC /:4318HTTP),OTEL_SERVICE_NAME,OTEL_RESOURCE_ATTRIBUTES.taskq workerinstalls the SDK providers from those variables at startup and logsotel-exporter-configured traces=otlp metrics=otlp source=env(the console renderer's shape; under the defaultTASKQ_LOG_FORMAT=jsonthe same fields ride theotel-exporter-configuredJSON event) (opentelemetry-instrument taskq workeris the equivalent launcher; an embedded worker callstaskq.obs.configure_exporters(settings)or initializes the SDK in-process (examples/otel_setup.py). - Vendor SDK in-process (e.g. Azure Monitor / App Insights): call the vendor's
configure_azure_monitor(...)inside the worker process before it starts; the worker logsotel-exporter-preconfiguredand leaves the vendor's providers alone.
A vendor connection string on the container does nothing by itself
APPLICATIONINSIGHTS_CONNECTION_STRING on a worker container exports nothing unless the
worker process itself calls the vendor's configure function; it is not one of the
standard OTEL_* variables the worker wires from. The standard variables set without
the [otel] extra export nothing either; the worker says so at startup
(otel-exporter-unavailable, naming the extra). Verify the startup line and that
telemetry actually arrives (one trace, one metric series) before trusting the pipeline.
Every job emits an enqueue PRODUCER span, a process CONSUMER span (linked, with
lifecycle.* events), and an attempt.N child. Prometheus scraping and vendor receivers:
observability.md and
deployment.md: Observability Setup.
The metrics that catch the failure modes in this page¶
| Metric / signal | Catches |
|---|---|
taskq.queue.depth (by queue; counts pending and scheduled) |
backlog growth, starved queues, fan-out storms |
taskq.jobs.by_status (by status; pending and scheduled reported separately) |
which side of promotion the backlog sits on |
taskq.jobs.actor_backlog (by actor and queue; pending only; exact below the sampler's 1000-row per-pair cap, reading 1000 at/above it) |
the unconsumed actor: one actor's series rises while its queue-mates stay flat, invisible in any queue-summed view |
taskq.jobs.queue_wait_seconds (histogram by actor and queue) |
what dispatched jobs actually waited; a rising p99 with flat depth is dispatch starvation (priority/fairness, admission caps), not backlog |
taskq.jobs.oldest_pending_age_seconds (by actor and queue) |
the same condition as a head-of-line age growing with wall clock; the series TaskQQueueDepthHigh fires on |
taskq.jobs.oldest_due_age_seconds |
how long the oldest due scheduled job has waited for promotion |
taskq_maintenance_leader_sweep_last_success_seconds (by sweep) |
per-sweep stalls: a sweep that stops completing |
taskq_maintenance_leader_sweep_timeouts_total (by sweep) |
batches aborted by deadlines or server-side cancels, and gauge-sampler reads that did not complete; an absent or frozen gauge beside a rising rate is a dead sampler, not a resolved alert |
taskq_worker_loop_unexpected_errors_total (by loop) |
a long-lived loop's backstop (UnexpectedLoopErrorGuard) caught a non-transient error — the leader maintenance loops, the worker producer loop, the progress flush loop; anything above zero warrants investigation |
taskq.queue.live_workers (by queue, same tick as depth) |
a queue with work and no live worker; TaskQQueueUnserved joins it against taskq.queue.depth |
taskq.jobs.stranded (by actor and reason) |
jobs that can never dispatch: no_actor_config (no actor_config row) or unserved_queue (no live worker on the routing queue) |
taskq.dispatch.duration |
dispatch contention (PgBouncer/pool trouble) |
taskq.jobs.oldest_running_age_seconds (by actor) |
an attempt that outlived what the actor normally takes with taskq.jobs.timeouts flat; an actor with no start_to_close |
taskq.worker.active_jobs / taskq.worker.max_concurrency (one series per process) and taskq.jobs.running (by actor) |
slot saturation: utilisation ≥ 0.9 with oldest_pending_age rising is "add workers"; which actor holds the slots (see §3: When to add workers) |
taskq.worker.slot_pool.acquire_failures / taskq.worker.slot_pool.connections_in_use |
per-slot pool exhaustion and saturation; an acquire failure is infrastructure (the job is left for lock-lease reclaim, not failed); the gauge pinned at the pool maximum with zero acquire failures is saturation, visible below the acquire-failure cliff |
messaging.process.duration (by outcome) |
actor latency, slow chunks; filter outcome="succeeded" for the healthy percentiles |
taskq.jobs.attempt_failures (by actor, error_type, retryable) |
a dependency failing under retry cover (retryable="true" rising while the terminal-failed share stays flat); the series TaskQRetryRateHigh fires on |
taskq.jobs.abandoned (by actor) |
an actor that ignores cancellation: an operator cancel outlasted both graces; never produced by a deploy |
taskq.lock.expires_in_seconds |
heartbeat trouble before it becomes crashed jobs |
taskq.jobs.timeouts (by actor and kind) |
start_to_close hits per attempt; schedule_to_close hits however the deadline was enforced (sweep or handler arm); taskq.deadline_exceeded_sweep.jobs_failed is the sweep's own count |
taskq.backpressure.errors (filter kind to the capacity kinds) |
max_pending rejections: producer pressure |
taskq.cron.disabled_schedules |
a cron outage with one log line |
taskq.cron.skipped_slots (by actor) + taskq.cron.slots_behind (depth, fire units) |
the runaway fan-out: a cron-triggered sync whose jobs clear slower than the cron period — the leader is dropping fire slots past the catch-up window, counted, never replayed; run the fires-vs-clearance SQL in taskq.insights (src/taskq/insights.py) to name the schedule |
taskq.maintenance_leader.is_leader summed != 1 |
leader split-brain / no leader |
When metrics are unavailable or suspect, query the tables, not the logs; during one production outage the console logs looked clean while the worker was down:
-- Replace {schema} with your TASKQ_SCHEMA_NAME.
SELECT id, worker_label, last_seen_at,
last_seen_at < clock_timestamp() - interval '60 seconds' AS stale
FROM {schema}.workers ORDER BY last_seen_at DESC;
SELECT queue, status, count(*) FROM {schema}.jobs
WHERE status IN ('pending','scheduled','running')
GROUP BY queue, status ORDER BY count(*) DESC;
The per-actor backlog sampler: every worker, every interval, depth-bounded¶
The two per-(actor, queue) series above are fed by the backlog-detection sampler, which runs on
every worker every TASKQ_QUEUE_DEPTH_INTERVAL (default 15 s), deliberately not
leader-gated, unlike the maintenance sweeps: a detector hosted behind the leadership gate would
emit nothing under exactly the failure it exists to expose (an election loss or a stuck lock mutes
every leader-gated loop at once). Your scrape target deduplicates the identical per-worker series.
Because N workers pay the read each interval, the read itself is bounded so its cost never grows
with backlog depth: a loose index scan over jobs_actor_dispatch_idx enumerates the distinct
pending (actor, queue) pairs (one index seek per pair, never one per row), and a per-pair probe
reads at most 1000 rows in dispatch-head order (priority DESC, scheduled_at). One tick costs
at most (#pairs holding pending work) × 1000 row visits (shaped by the deployment's actors and
queues, never by how deep a backlog gets) and touches no terminal rows. Measured on a single
100k-deep pair (local Postgres 18): ~14 ms and ~6000 shared buffers per tick before the bound,
~0.4 ms and ~100 buffers after, flat against the 10k case
(see perf-evidence-dispatch.md).
What the cap does to the numbers you read:
taskq.jobs.actor_backlogis exact below 1000 pending for a pair and reads 1000 at or above it: a lower bound, never an under-count. A pair pinned at the cap is "at least this deep"; dashboard over it with that meaning.taskq.jobs.oldest_pending_age_secondsis the age of the oldest row within the pair's dispatch-head sample: exactly the oldest pending row whenever the pair is below the cap (or priorities are uniform), and in every case the head-of-line age an unconsumed actor grows without bound, so theTaskQQueueDepthHighalert's semantics are unchanged by the cap.- When you need the exact depth of a pair at/above the cap, query the table directly:
SELECT count(*) FROM {schema}.jobs WHERE status='pending' AND actor='...' AND queue='...'; the right tool for a one-off investigation, which is exactly what a fleet-wide every-15-s sampler cannot afford to be.
Watch: large scheduled backlogs¶
A very large set of due scheduled jobs drains one bounded batch per one-second
tick (TASKQ_EVENT_WRITER_BATCH_SIZE rows, default 100, each batch its own committed
transaction with a server-side statement_timeout). Promotion always makes progress,
but a backlog larger than the drain rate still grows, and the fleet still looks
healthy from the outside while it does (workers heartbeat normally, dispatch reports
count: 0). An earlier design promoted the whole due set in one transaction under a
single dispatcher_command_timeout deadline, which wedged into timeout-rollback-timeout
past a backlog cliff; the bounded batches replaced it (see
maintenance-sweeps.md). The detection guidance below catches
any failure mode that grows the scheduled backlog. Symptoms: scheduled depth grows,
pending stays empty, throughput is zero, and health probes stay green. Detect it
before it bites:
SELECT count(*) FROM {schema}.jobs
WHERE status = 'scheduled' AND scheduled_at <= clock_timestamp();
Alert on that count, on taskq.jobs.oldest_due_age_seconds, and on the
TaskQScheduledBacklogGrowing / TaskQPromotionStalled rules in
src/taskq/contrib/prometheus/rules.yaml
(runbooks: runbooks.md). Prevent it with max_pending on high-fan-out actors, retry policies that disperse
cohorts (longer base, higher jitter; see §6),
and bounded fan-out per job (chunk sizes in the hundreds, not the tens of thousands).
Alert rules¶
Ship-ready alert rules for the metrics above exist in the repo and are ready to import:
src/taskq/contrib/prometheus/rules.yaml
(24 rules: queue depth, heartbeat misses, terminal-failed share, retried-failure share, abandoned
jobs, lock TTL, leader split-brain, dispatch latency, claim-latency degradation, progress failures, disabled cron,
scheduled-backlog growth, promotion stall, sweep timeouts, sweep unexpected errors, sweep degraded
tier, maintenance-lock contention, rate-limit dependency outage, cron lock contention, cron budget
deferrals, cron skipped slots, unserved queue, stranded jobs, expired-lease zombies) and the equivalent PrometheusRule
CRD at src/taskq/contrib/kubernetes/prometheus_rule.yaml. Importing them is not enough; make
sure something scrapes the workers (TASKQ_METRICS_PORT, every pod; see
deployment.md: Prometheus scrape): the rules read
worker-side series the admin process's /jobs/health/metrics never carries. The scrape listener
binds nothing until the port is set, refuses startup (exit 1) if it cannot bind or configure, and
serves an unauthenticated endpoint: pin it to the pod network with a
TASKQ_METRICS_HOST=127.0.0.1 sidecar scraper, or scope it by network policy. Per-platform env
blocks are in deployment.md: Listener deployment recipes.
9. Porting workers from other queue systems¶
TaskQ's core concepts and their operational equivalents for teams moving from background job systems:
| Concept | TaskQ feature | Notes |
|---|---|---|
| Concurrency model | TASKQ_MAX_CONCURRENCY |
asyncio coroutines, not processes; CPU-bound work must move to threads/executors |
| Soft timeout per attempt | start_to_close |
cooperative cancellation of the attempt |
| Hard process kill per job | n/a | no per-job process kill; see the sync-actor caveat in §2 |
| Visibility timeout for dead workers | lock_lease (60 s) + leader sweep |
worker death → reclaim → retry |
| At-least-once delivery | default behavior | lease reclaim on worker loss |
| Backoff algorithm | RetryPolicy(max_attempts, backoff, base, cap, jitter) |
jitter is ±20% multiplicative, not full jitter, and it does not decorrelate synchronized cohorts (see §6) |
| Retry configuration | default (all exceptions retry under the policy) | list the terminal ones instead: non_retryable_exceptions |
| Rate limiting per actor | TokenBucket / SlidingWindow on the actor |
fleet-wide, Redis- or PG-backed; keyed refs for per-tenant quotas |
| Delayed scheduling | scheduled_at |
timezone-aware; ~1 s promotion precision |
| Time budget for entire job lifetime | retry.time_budget → schedule_to_close (time_budget requires kind="indefinite") |
interval from enqueue, server clock |
| Deduplication of reruns | idempotency_key (+ scope) |
DB-enforced; duplicates return the existing job; mind the key-discipline rules in §5 |
| Job dependency chains (many-to-one) | batch finalizer + wait_for_batch, or app-level run accounting |
§5 Patterns B/C |
| Job workflows (chaining) | ctx.jobs.enqueue(...) from the actor body |
transactional on a LOOP-scope conn (per-slot connections make it safe at any max_concurrency; session-state inheritance still needs a single-slot worker), autonomous otherwise; see §5 |
| Task routing | @actor(queue=...) + worker TASKQ_QUEUES |
routing is by queue, not by broker exchange |
| Job priority | @actor(priority=...) / enqueue(priority=...) |
higher dispatches first; stamped at enqueue; see §3 |
| Per-tenant fairness | round_robin mode + fairness_key |
the mode must be set explicitly; see §3 |
| Worker labeling | worker_label (+ worker_group OTel label) |
labels only; no routing semantics |
| Scheduled job execution (cron) | @cron(...) |
fires on schedule regardless of the previous fire; overlap suppression has traps; see §5: Cron |
| Dead-letter queue routing | on_retry_exhausted + ErrorReporter |
no built-in queue; route where you want it |
10. The footgun index¶
The condensed "know this before your first incident" list. Each row links to the deep dive.
Configuration & routing
| Footgun | Symptom | Fix / deep dive |
|---|---|---|
worker_group expected to route or partition |
jobs "not reaching" a group | it's an OTel label only; route with queues (§1) |
| Worker consumes no queue an actor enqueues to | jobs sit pending forever |
check actors-on-unconsumed-queues warnings; see workers.md |
API and worker on different TASKQ_SCHEMA_NAME |
every job stuck pending, no error anywhere |
one source of truth for the schema, refuse-don't-default |
round_robin expected but never set (or no fairness_key) |
fairness silently off; __null__ cohort = plain FIFO |
set the queue mode; key every enqueue; see §3 |
TASKQ_MIGRATE_ON_START=true expected to run in the worker |
warning migrate-on-start-ignored-by-worker |
run migrations in a pre-deploy job / admin UI (deployment.md) |
taskq queues set-max-concurrent changed, nothing happened |
cap unchanged | queue caps are read at worker startup; restart workers |
@actor(max_concurrent=...) added in code, no effect |
actor-config-capacity-override log |
stored actor_config wins; NULL = uncapped; audit with taskq actor-config list (§3) |
Actor's metadata changed after first registration |
worker refuses to boot (ActorConfigDriftList) |
one-boot TASKQ_FORCE_UPDATE_ACTOR_CONFIG (§3); a queue change is a move, not drift; taskq actor-config move-queue |
.env files expected to lose to (or beat) process env |
config resolves "backwards" | dotenvmodel 1.x: process env beats .env unless DOTENV_OVERRIDE=true (configuration.md; and mind the loading CWD |
| Actor registry passed as a generator (or generated actors missing from it) | zero/n actors registered | pass a mapping; test that every actor name is in the registry; see workers.md: Actor registry |
Timeouts & retries
| Footgun | Symptom | Fix / deep dive |
|---|---|---|
No start_to_close anywhere; an actor hangs |
slot held forever | set TASKQ_DEFAULT_START_TO_CLOSE + per-actor overrides (§2) |
Expecting schedule_to_close to kill a running attempt |
long attempt survives past deadline | it only gates future dispatches (§2) |
start_to_close expected to kill a sync actor's thread |
job marked timed out, side effects continue anyway | sync actors keep running; poll ctx.should_abort() (actors.md) |
heartbeat_timeout set below one heartbeat interval |
healthy jobs reclaimed on a single missed beat | size it >= 2x TASKQ_HEARTBEAT_INTERVAL: the per-job analogue of the lock_lease >= 4 x heartbeat_interval invariant (§2) |
heartbeat_timeout at or above the fleet's TASKQ_LOCK_LEASE |
the per-job budget silently never governs: the lease deadline (last beat + lease) always precedes the heartbeat deadline (last beat + timeout), so the lease arm reclaims first and the knob no-ops; the reclaim event names cause='lock_expired' directly |
size it inside the window: >= 2x TASKQ_HEARTBEAT_INTERVAL and below TASKQ_LOCK_LEASE (§2) |
| Retry window shorter than routine provider blips | terminal exhaustion on an ordinary 5xx | fewer attempts, longer base (§6) |
Snoozing finalizer with a tight time_budget |
DeadlineExceeded mid-batch |
size the budget to batch duration + retries; time_budget requires kind="indefinite" (§5) |
max_retry_backoff raised, long backoffs still capped |
retries at 24 h ceiling | the ceiling is min(policy.cap, TASKQ_MAX_RETRY_BACKOFF) (retries.md) |
| Catching failures and returning a result | job succeeded, retry machinery never engaged |
raise; see §6 |
| Actor returns normally during a drain-cancel | job records succeeded, chain dies on every deploy |
drain sets the same cancel event; never return normally on cancel (§6) |
Cron actor overlap "suppressed" with singleton=True |
slot skipped, no strike, next due tick re-evaluates | suppression semantics; see §5: Cron |
Fan-out & dedup
| Footgun | Symptom | Fix / deep dive |
|---|---|---|
| One giant "sync everything" job | pipeline blocked for hours; killed by start_to_close; resync repeats |
chunk it; see §5 Pattern A |
No idempotency_key on chunk jobs |
retries fork the chain, duplicate work | stable business key per chunk (§5) |
| Stable key on a self-continuation successor | continuation silently dropped (collides with succeeded predecessor) | fresh key dimension or no key (§5) |
| Key missing a payload dimension / re-posting after terminal failure | work silently lost / rerun no-ops onto the dead job | keys are payload-blind and status-blind; encode dimensions and attempts (§5) |
unique_for without identity_key |
dedup silently off | pass identity_key at enqueue; see actors.md |
unique_for on a thin root expected to single-flight the chain |
overlapping runs despite the window | single-flight the work, not the root (§5) |
enqueue_batch_fast with duplicate keys |
whole COPY aborts | pre-dedup or use enqueue_batch (§5) |
Blind retry after BatchMaxPendingExceededError |
duplicate jobs: the within-cap actors' items were already committed | every enqueue tier enforces max_pending per actor; retry only the error's refused item indices, or use idempotency_keys (§5) |
Huge synchronized scheduled cohort (mass retry wave) |
promotion stalls; health green; throughput zero | disperse cohorts, max_pending, scheduled-depth alert; see §8 |
| Tag factory emitting colons | every enqueue 500s | tags must match \A\w(?:[\w\-]*\w)?\Z; see §5 |
Runtime & infrastructure
| Footgun | Symptom | Fix / deep dive |
|---|---|---|
| Everything through transaction-mode PgBouncer | LISTEN/advisory-lock failures | only worker_pool may pool; split the DSNs (§4) |
| Fleet sized without connection math | too many connections under load |
per-worker 22 (default), rollout ×2: the budget formula (§4) |
BYO PoolFactory counted as one pool |
three full-sized pools (plus the conditional per-slot pool on that path), budget blown | factory is invoked once per role (§4) |
| TaskQ client handed the request handlers' pool | silent whole-process deadlock at pool_max |
dedicated small pool; see §4 |
| Token auth without reload | healthy for an hour, then cannot connect | TASKQ_RELOAD_INTERVAL inside token lifetime, all five roles (§4) |
| NOTIFY reconnect mid-rotation looks like a hang | dispatch on poll fallback, SIGHUP reload reports notify as failed, for up to ~95s |
notify_reconnect_lock's worst-case hold is the sum of its bounded steps, not a hang; see §4 |
terminationGracePeriodSeconds < shutdown worst case |
SIGKILL mid-drain, crashed jobs |
grace ≥ cancellation + cleanup + ~42 s tail (deployment.md) |
| DDL run against live tables mid-deploy | brief InvalidCachedStatementError (0A000) in worker logs |
transient by design: the pool re-prepares next tick, no restart (deployment.md) |
| Migration hangs "waiting for lock" and the whole fleet's queries freeze with it | a queued ACCESS EXCLUSIVE parks every later statement on the table behind itself |
the runner's 30 s ddl_lock_timeout gives up cleanly (MigrationLockTimeoutError); find the holder, re-run migrate up (deployment.md) |
| One job longer than the shutdown budget | watchdog force-exits; sibling in-flight jobs die too | size the grace to your slowest actor, or cap it with start_to_close (§2) |
Actors longer than cancellation_grace_period + cleanup_grace_period |
interrupted on every deploy and re-run from scratch (interrupt_count climbs, work restarts); only schedule_to_close ends the loop |
bound them with schedule_to_close, or checkpoint via progress state and resume on re-claim (§6, cancellation.md) |
Drained refill_per_second=0 bucket, no deadline |
job re-queues every 5 s forever | add refill or schedule_to_close (§7) |
| Redis in dev, none in prod | worker refuses to start in prod only | decide the limiter backend per environment (§7) |
| Worker without a Redis client expected to publish progress | SSE/progress silently empty, no error | progress fanout needs Redis; PG snapshotting still works; see progress.md |
max_concurrent=0 set as a "disable" |
actor silently stops dispatching; jobs pile up pending |
0 is drain mode: use it deliberately, drain with it, then restore |
Redis-backed limiter without TASKQ_REDIS_URL |
worker refuses to start | fail-fast by design: configure Redis or use backend="postgres" (§7) |
Suppressing asyncio.CancelledError in an actor |
cancel phase escalates to abandoned |
never catch it; see cancellation.md |
Two workers pinned to the same TASKQ_HEALTH_SOCKET_PATH |
second worker's health server fails | the default is per-process unique (/tmp/taskq_health_<pid>.sock); give explicitly pinned paths unique values |
leader_lease below 4 * heartbeat_interval |
no error, no boot refusal | silently raised to the floor: the same lock_lease >= 4 x heartbeat_interval invariant, honoured rather than enforced, so raising heartbeat_interval alone raises the effective lease instead of shortening the renewal margin (architecture.md) |
| Cancel expected to survive a crash-retry | retried attempt runs uncancelled | retry arms reset cancel state by design; re-cancel if still needed |
| Exporter env var set, telemetry expected | nothing collected, silently | exporter must be configured in-process; see §8 |
| PyPI release assumed current | fixed bugs recur; documented features missing | check the CHANGELOG, pin a rev, assert the pin (see the intro warning) |
11. Adoption checklist¶
Everything to verify before pointing production traffic at a new TaskQ fleet. (The deployment mechanics of each item: deployment.md: Production Checklist.)
- Version: git pin asserted by test, or release docs matched to the release (see the intro warning)
- Migrations run as a pre-deploy step (the worker ignores
TASKQ_MIGRATE_ON_START); schema name is one source of truth shared by API and worker - Timeouts:
TASKQ_DEFAULT_START_TO_CLOSEset as a safety net; every long-runner declares its ownstart_to_close; everykind="indefinite"actor has atime_budget - Failure classification: terminal exception types listed in
non_retryable_exceptions(or classified viaretry_classifier); per-item faults skip-and-continue; defaultRetryPolicy()(3 attempts) reviewed per actor; no actor catches failures and returns - Dedup: batch-shaped work chunked (§5);
chunk/continuation jobs carry stable, dimension-complete
idempotency_keys; self-continuations carry a fresh key dimension - Concurrency:
TASKQ_MAX_CONCURRENCYmatched to workload class; strict caps (queue orConcurrencyReservation) where a soft damper is not enough; thread-unsafe libraries serialized by a shared reservation - Capacity audit:
taskq actor-config list; every actor'smax_concurrent/max_pendingis the value you decided (NULL = uncapped); an ownership posture chosen (operator-owned vs boot-time convergence) - Queue modes:
round_robin+fairness_keyset where tenants share queues; priorities assigned to latency-sensitive actors - Cron: every schedule registered by this deploy still intended; actors have
start_to_close(note cron fires currently ignore per-actor values; the fleet-wideTASKQ_DEFAULT_START_TO_CLOSEis what protects cron); overlap strategy chosen with its trap understood (§5: Cron) - Connection budget: computed for the target fleet size against
max_connections(including app pools, BYO factory fan-out, rollout doubling, and the per-slot rotation peak where that path is in use);pgbouncer_recommendedchecked - DSN split:
TASKQ_PG_DSN_DIRECT(worker core) vsTASKQ_PG_DSN_POOLED(worker_pool) - Credentials: reload interval inside token lifetime; all five connection roles covered
- Shutdown budget: supervisor/
terminationGracePeriodSeconds≥cancellation_grace + cleanup_grace + ~42 s(default model: 82 s), and ≥ your slowest actor - Health probes:
taskq health live/readywired (exec probes; TCPTASKQ_HEALTH_PORTonly wherehttpGetis forced); unique socket path AND unique probe port per process (a bind collision fails the listener loudly but the boot continues; the orchestrator's failed probes are the fail-closed backstop) - Metrics scrape:
TASKQ_METRICS_PORTset on every worker (off unless set; scrape endpoint unauthenticated, so pod network, loopback sidecar viaTASKQ_METRICS_HOST, or SG-scoped); a bind or config failure exits the worker, so a bad port shows up at deploy - Redis provisioned iff using Redis-backed limiters or real-time progress; PG fallback decision made; dev/prod symmetry checked
- Observability: exporter wired in-process and verified to arrive; the alert rules from
§8 imported and scraped; someone watches
taskq.queue.depth, scheduled depth, andtaskq.backpressure.errors - DLQ routing decided:
on_retry_exhausted/ErrorReportertarget chosen (there is no built-in dead-letter queue) - Idempotency of actor bodies audited; delivery is at-least-once, including crash-reclaim re-dispatch
12. Scaling playbook: from signal to knob¶
Symptom-first: what the operator actually sees (an alert, an admin UI view, a metric series), the knob(s) that address it, and how to confirm the knob worked. This table only routes: the arithmetic behind each row is in the section it links; do not re-derive it here. Incident response (what to do while it burns) lives in runbooks.md; this section is about which knob to reach for once the fire is out. Admin routes referenced below are in admin-ui.md.
| You see | First knob(s) | Confirm it worked | Failure mode of over-tuning |
|---|---|---|---|
Actor saturated: taskq_worker_active_jobs / taskq_worker_max_concurrency > 0.9 sustained while taskq.jobs.oldest_pending_age_seconds climbs; /admin/actors shows the actor pinned at its cap. No shipped alert: this one is a dashboard check. |
taskq actor-config set ACTOR --max-concurrent N (live; the stored row wins over the code literal; see §3 seed-only trap); TASKQ_MAX_CONCURRENCY or more replicas when no per-actor cap binds (the math: §3 when to add workers, §4 throughput) |
Utilisation drops below 0.9 and oldest_pending_age flattens on the other actors first |
Pushing max_concurrent high "to use the fleet" removes the damper that was protecting a downstream dependency; adding replicas when a strict queue cap or ConcurrencyReservation is the real bound buys idle pods |
Worker CPU / event-loop lag: watchdog tier-1 loop-lag warnings with faulthandler dumps; /admin/workers shows beats arriving late. No shipped alert. The stall ATTRIBUTION names the actor: taskq_worker_loop_stall_attributions_total (and the worker's Stall hotspots column) tells you which actor's synchronous code is blocking the loop, and as blocking_call (released the GIL: move it to a thread) or gil_held (held the GIL: chunk it or move it off the loop) |
More worker processes (asyncio is one thread per process; CPU-bound actors block the loop, §3); lower TASKQ_MAX_CONCURRENCY per process. TASKQ_WATCHDOG_LOOP_LAG_WARN_BUDGET / TASKQ_WATCHDOG_LOOP_LAG_BUDGET only after the host is provably not loaded |
Lag warnings stop without having touched the budgets | Raising the lag budgets converts a designed trip-and-restart into a wedged worker that keeps serving slowly; the terminal detector exists so the supervisor restarts you |
Queue depth growing, live workers idle: TaskQQueueDepthHigh fires while utilisation stays low; taskq.jobs.queue_wait_seconds p99 rises with flat depth; /admin/queues/{queue} shows depth sitting on one queue |
Queue mode + fairness: taskq queues set-mode <queue> round_robin and fairness_key on the hot producers (§3 starvation); the actor's max_concurrent damper or a reservation holding slots; TASKQ_DISPATCH_OVERSAMPLE when identity collisions or many dispatchers poll the same actor+queue |
queue_wait_seconds flattens while depth stays flat, then drains |
oversample above the number of dispatchers that routinely poll the same actor+queue enlarges every candidate window for nothing; round_robin set without fairness_key collapses to FIFO with zero signal |
Pending backlog with stranded > 0: TaskQStrandedJobs; taskq.jobs.stranded carries the reason (no_actor_config / unserved_queue); /admin/actors misses the actor |
No worker knob fixes the rows: register the actor on a worker consuming that queue (TASKQ_QUEUES / --queues), or taskq actor-config move-queue (§3 capacity ownership). TASKQ_STRANDED_JOBS_INTERVAL is detection cadence only |
The stranded gauge returns to 0 per actor | Shortening the interval polls Postgres more often and recovers nothing; it is a detector period, not a repair loop |
Rate-limit denials: taskq_reservation_denials_total climbing; /admin/rate-limits and /admin/reservations show saturated buckets; TaskQRateLimitDependencyOutage when the PG fallback path is the one struggling |
Limiter rates and reservation capacity (application config, not env; see §7); TASKQ_MAX_KEYED_RESERVATIONS / TASKQ_MAX_KEYED_RATE_LIMITS when denials are cardinality-driven; TASKQ_TOKEN_BUCKET_LOCK_TIMEOUT_MS / TASKQ_SLIDING_WINDOW_LOCK_TIMEOUT_MS when fallback row locks are the bottleneck |
The denials counter flattens and queue_wait_seconds resumes |
Widening the lock budgets makes a saturated bucket wait longer instead of denying: latency replaces refusals; adding workers changes nothing because admission sits upstream of the worker |
Cron piling up at tick: TaskQScheduledBacklogGrowing / TaskQPromotionStalled with scheduled depth growing; /admin/schedules shows the fire history; TaskQCronLockContention on the lock |
Spread cron expressions across minutes rather than aligning every schedule at * * * * *; TASKQ_CRON_TICK_LIMIT for a catch-up burst larger than one tick plans (§5 cron, §8 watch); TASKQ_CRON_CATCH_UP_WINDOW bounds what a restart replays |
Scheduled depth drains one bounded batch per tick and taskq.jobs.oldest_due_age_seconds falls |
The tick limit is consumed by the single leader; raising it moves the pile downstream into dispatch instead of clearing it faster; only one process pays the knob no matter how many replicas run |
terminal-write-failed logs / disowned jobs: the worker fails every attempt of a terminal write, disowns the row (lease stops renewing), and TaskQRunningLeaseExpired catches the reclaim; usually beside TaskQHeartbeatMisses or TaskQLockExpiringSoon |
Not actor config: database health. PgBouncer saturation, max_connections exhaustion, a failover (§4 connection budget). Fix the budget (TASKQ_MAX_CONCURRENCY down, a pooled DSN for worker_pool), never the actor |
terminal-write-failed stops and no rows sit running past their lease |
Raising TASKQ_MAX_CONCURRENCY grows the derived worker_pool and makes the exact connection pressure that caused the writes to fail worse |
p95 duration far above p50: messaging.process.duration{outcome="succeeded"} tail; /admin/history sorted by started_at shows the long-running jobs |
Review the outlier actors' start_to_close (§2) and chunk sizes (§5); TASKQ_DEFAULT_START_TO_CLOSE as the fleet-wide safety net; fairness_key when one cohort causes the tail |
p95 tracks p50 and taskq.jobs.timeouts stays flat |
A fleet-wide start_to_close sized above everyone's p95 silently kills legitimately slow attempts (and sync actors keep running past it anyway; only the slot is freed) |
Two rows in this table are deliberately "no knob": stranded rows and terminal-write failures are routing and infrastructure conditions, and both failure modes above where an operator reached for a per-process number to fix a fleet-wide condition are the ones §3 and §4 exist to prevent.
13. TimescaleDB: plain Postgres or hypertables?¶
TaskQ's retention tables (jobs_archive, job_attempts_archive, job_events) are plain
Postgres by default, and that is the right default for most fleets. On a server with the
TimescaleDB extension they can convert to hypertables with Timescale-managed retention policies
and a columnstore on the two archive tables (TASKQ_TIMESCALEDB_HYPERTABLES=true, opt-in at the
taskq migrate up deploy step). The conversion mechanics, the DDL, and the retention-interplay
rules are timescaledb.md's subject. This section is the decision and the
day-2 operations, built from the repo's own measured A/B runs
(benchmarks/timescale_tradeoffs.py and benchmarks/timescale_compression.py, results in
benchmarks/results/timescale-*.json; every number below is read off those artifacts).
The mode dimension: three first-class storage modes¶
Every trade in this section sits on a dimension the section used to leave implicit: the
detected storage mode. TaskQ detects it from the connected server (never from settings) —
vanilla (no extension installed), timescale-apache (extension installed, license apache),
or timescale-tsl (extension installed, full Timescale-license feature set) — and
taskq doctor leads every report with the detected mode and its capability consequences. The
full mode × capability matrix, cited from the detection code, is
timescaledb.md's support matrix
and mirrored in deployment.md.
The cells that matter to the decision framework below:
- Compression exists in exactly one mode. The columnstore — and therefore every compression
number in this section (the 6.20x storage reduction, the 4.6x/9.3x cold-read wins, the
decompression GUC prerequisite, the first-tick DML-decompression catch-up) — is
timescale-tslonly. Under thetimescale-apachelicense the server refuses every compression API (measured on 2.30.1), andvanillahas no extension at all: in both, the archive tables stay rowstore. - Policy-driven chunk-drop retention also exists in exactly one mode. The retention
policies — and therefore the policy floor, the faster drains, the first-run aged-tail drop,
and the watermark gap — are
timescale-tslonly too: the apache license refusesadd_retention_policyalongside the compression APIs (measured on 2.30.1). - In
timescale-apacheandvanilla, retention is the row-level sweeps, full-range — bounded batch deletes,expire_athonored exactly, the event-prune watermark kept on every deletion. The two modes differ in one cell only: the apache mode's hypertables (faster recent-history reads, chunk partitioning) with no policy machinery on top.
So when reading the decision table below: its "hypertable" column is the timescale-tsl
mode's behavior; if your server detects timescale-apache, take the table's read column
(where it cites hypertable reads — those hold, the partitioning is Apache-licensed) but the
plain column for every retention-drain and compression row.
The decision table¶
| Your situation | Decision | The measured evidence |
|---|---|---|
| Archive below ~10,000 rows | Stay plain. | 10k is the smallest swept scale, and no measured advantage for the hypertable justifies the machinery there: the archive page is already 4.9× faster, but at that size it is single-digit milliseconds either way. Plain is the simplest machinery and keeps every row-exact deletion guarantee. |
| Big archive + read-heavy admin surfaces | Hypertables win clearly. | The admin archive tab's newest-first page at the 1M-job corpus: 8.6 ms vs 45.0 ms plain (~5.2×) in the committed run (timescale-tradeoffs.json; plain p95 49.1 ms — the plain side carries the run-to-run spread, the hypertable side held 8.6 ms across runs). Since the retention-policy floor, the retention drains run FASTER than plain: event TTL 0.04 s vs 0.30 s (~6.8×), archive expiry 0.32 s vs 1.14 s (~3.6×). The columnstore shrinks archive storage −84 % (6.18×) on the 400k-row corpus. |
| Very high throughput fleets | Mind the prune→archive move — it is a flat ~8× slower on the hypertable. | Per 100k jobs moved: 4.5 s plain vs 35.9 s hypertable at the 1M corpus. Scale-invariant above ~100k rows: the drain-total penalty is 7.8× at 100k, 8.0× at 400k, 8.1× at 2M, and the per-batch p50 carries the same ~8-9× at both 400k and 2M. The DELETE half of the move runs on the never-converted jobs table in both modes, so the entire penalty is the INSERT half — chunk routing and per-chunk index maintenance — which is inherent to the feature: no setting removes it. |
| Forensic-grade audit needs | Read the guarantee gradient first. | On hypertables, expire_at is honored exactly inside chunk lifetime (the expiry sweep stays row-exact there), but the aged end is chunk-dropped silently, watermark-blind: the policy advances no pruned_through_id, so a stale watch_reclaims cursor loses events with no gap signal — keep the cursor strictly inside event_retention_period (or one chunk interval, whichever is larger). The re-archive fold guard can lose its witness: "archived at most once" holds only while the archive chunk lives. And job_attempts_archive's dropped cascade means parentless attempt rows stay queryable up to one chunk interval after the parent's chunk drops. Vanilla keeps all four guarantees; hypertables trade them for chunk-drop retention. |
Two write-path facts that do not decide the trade, but bound it: enqueue parity holds
(enqueue_batch_fast within ~3 % — jobs is plain in both modes), and the hypertable's
job_events insert pays ~6 %. There is no write cliff; the penalty that matters is the
prune→archive move in the third row.
The shape of the scale curve: read/write mix decides, not scale¶
The scale sweep (10k / 100k / 400k / 2M archive rows, fresh identical container pair per scale) measured both curves:
- The read win exists from ~10k rows and decays slowly. The archive tab's newest-first page is faster on the hypertable at every swept scale: 4.9× at 10k, 8.2× at 100k, 7.2× at 400k, 3.2× at 2M. It never crosses over — it narrows.
- The write penalty saturates at a flat ~8× above 100k rows. The prune→archive drain-total penalty goes 4.9× at 10k → 7.8× → 8.0× → 8.1×, and the per-batch p50 sits at the same ~8-9× at both 400k and 2M. Above ~100k rows the penalty is a constant ratio, not a growing one.
So the decision above ~10k archive rows is your read/write mix: deployments whose admin
surfaces read recent history heavily win on the hypertable at any size; deployments that only
write the archive and rarely read it pay the flat insert tax for nothing. Live-table reads are
structurally untouched either way (jobs is never converted; the live pages measured at parity).
One recorded exception to re-check against your own fleet: the all-statuses count read 51.3 ms vs
14.1 ms on the hypertable engine at 1M — a planner artifact on a table that is plain in both
modes, so treat a count anomaly there with suspicion, not as an engine cost.
Planning the enabling deploy¶
- Size the window for the constraint scans and the
migrate_datacopy together. Both takeACCESS EXCLUSIVE: the copy rewrites existing rows into chunks, and before it runs the constraint surgery (the FK drop, the pkey drops, everyADD CONSTRAINT ... UNIQUE) each takesACCESS EXCLUSIVEand full-scans its table to validate the uniqueness it asserts. On a big archive those scans are part of the deploy window, not a footnote to it. - Deploy in a maintenance window when no worker archives. The pkey drop and the unique add run one per transaction on the deploy connection's autocommit, and the migration advisory lock serializes migrators only — workers keep running through the whole conversion. Between a table's pkey drop and its unique add, that table holds no unique constraint on its id columns; a duplicate insert landing in that window makes every subsequent deploy fail on the unique add until the duplicates are removed by hand. The window is two adjacent statements — tiny and bounded, but not zero.
- Size the post-deploy window for the first policy run. Every chunk older than the retention interval is drop-eligible the moment the enabling deploy finishes, so the first background policy run drops the whole aged tail in one sweep — potentially gigabytes of IO where vanilla mode expires the same rows gradually.
- Raise the decompression budget before relying on the columnstore.
timescaledb.max_tuples_decompressed_per_dml_transactiondefaults to 100000, and at that default the archive-expiry sweep hard-errors on compressed chunks withConfigurationLimitExceededError(measured: one 10k-row expiry batch decompressed 356633 tuples). The deploy probes the setting and logshypertable-decompression-budget-at-defaultwhen it sees the default — raise it (or set0= unlimited) server-wide, don't ignore the warning. - Bounded runs convert anyway.
migrate up --phase pre,--target, and--max-stepsall run the full hypertable work and re-register the policies when the flag is on: a run intended to apply one unrelated migration does the conversion too. Don't aim a bounded run at a fleet's schema casually.
What to watch after enabling¶
- The policy background jobs.
timescaledb_information.jobscarries one row per registered policy (proc_name = 'policy_retention', plus the archive tables' compression-policy jobs); each row'sconfigholds thedrop_after/compress_afterinterval the last deploy registered. A policy that stops firing shows up as chunk counts growing without bound on the aged end — alert on chunk-count growth per hypertable (timescaledb_catalog.chunkjoined onhypertable_id, or the friendliertimescaledb_information.hypertablesview). Note the two failure shapes differ: a policy that is paused (scheduled = false) owns nothing — the row-level sweeps resume the aged end (row-exact, bounded batch deletes) instead of deferring to it — while a policy that is registered, scheduled, and failing on every run (the license-downgrade shape,taskq doctor's storage-mode drift arm names it) still holds the floor and strands the aged end. - The sweeps' baseline changes shape after the policy-floor fix — read it as health, not
breakage. Above the floor the aged end belongs to the policy (silent, chunk-granular), so the
event-TTL and archive-expiry sweeps' aged-end deleted counts fall toward zero and their
durations collapse to the floor probe plus an empty-window index scan. "The expiry sweep
deletes nothing" is the floor working on this mode — but it is not the ONLY zero, and the
two must be told apart. On a compressed chunk, an expiry DELETE's first execution can
silently skip the qualifying rows that chunk holds (measured on 2.30.1, deterministic,
independent of
max_tuples_decompressed_per_dml_transactionand of everyenable_dml_decompression-family GUC: a probe with 40 qualifying rows — 12 in young chunks, 28 in compressed ones — deleted 12 on the first execution, then 28 on the second, then 0; with the qualifying set entirely inside freshly compressed chunks the first tick deletes 0 outright; the probe is a recorded measurement inbenchmarks/results/timescale-compression.json'sfirst_tick_probekey, frombenchmarks/timescale_compression.py, which also records the bareDELETE WHERE expire_at < nowcontrast — that shape deletes all 40 on the first execution). The mechanism is TimescaleDB's DML-decompression path trailing the chunk's compression state by one execution — the next sweep tick (the same statement, executed again) catches up, and there is no GUC workaround:enable_dml_decompression = offdoes not sidestep the lag, it hard-errors the delete. So a first-tick 0 on a compressed chunk can be this one-tick catch-up, while the floor's zero is the ZERO-DELETES-BECAUSE-NOTHING-QUALIFIED case. Distinguish them by counting what still qualifies: rows below the floor (the policy's) never appear in this count, so what it returns is the sweep's own work that did not happen yet (0 = the floor working; > 0 on a compressed chunk after a first 0-tick = the catch-up; > 0 persisting across ticks = a stuck sweep, page):
-- read drop_after from timescaledb_information.jobs.config (the policy's own horizon)
SELECT count(*) FROM "{schema}".jobs_archive
WHERE expire_at < statement_timestamp()
AND finished_at >= statement_timestamp() - '{drop_after}'::interval;
The signals that still mean what they
meant are the stall families: taskq_maintenance_leader_sweep_last_success_seconds (a sweep
that stops completing) and taskq_maintenance_leader_sweep_timeouts_total (batches aborted by
deadlines). Watch those; do not alert on zero aged-end deletes.
- The deploy-time decompression warning. hypertable-decompression-budget-at-default in the
deploy step's logs is the one configuration trap that fires later, at the first expiry batch on
a compressed chunk — fix the GUC when you see it, not after the sweep pages you.
Turning it back off¶
The symmetric disable path is wired into the CLI: taskq migrate
disable-hypertables (the CLI form of disable_hypertables in
src/taskq/timescale.py, the mirror of the deploy step's enable side). The
procedure and its semantics:
- Flip
TASKQ_TIMESCALEDB_HYPERTABLES=falsefirst — the mirror of enable's gate. With the flag still true the command exits 1 with the remedy (the library-level gate is a zero-statement no-op; the CLI refuses to let a mistyped invocation look like a completed disable). - Run the disable under the same migration advisory lock context the enabling deploy used.
It removes every registered retention and compression policy (refusing to swap tables under a
live policy), then per hypertable, RENAME-FIRST: copies the rows into a restore heap and
count-verifies the copy (this exercises the full read path — compressed chunks decompress —
before any name moves), renames the hypertable to
{table}__hypertable_trash(metadata-only: from that moment the rows exist in two places and the vanilla name is free), moves the migration-built vanilla table into place from the{schema}__vanillascratch schema the bundled migrations themselves build — the restored pkeys, indexes, foreign keys, and defaults are the migrations' own output, never re-typed by hand — returns the rows FROM THE TRASH twin-verified against both copies, and only then drops the trash and the heap. The vanilla behaviors are restored idempotently: the bare primary keys reject duplicates again, thejob_attempts_archive → jobs_archivecascade works again, and the event id sequence re-anchors past the restored maximum (forward-only, never backward; the re-anchor's one honest gap — an EMPTY restored table can re-issue an event id the prune watermark already claims — is stated in timescaledb.md). - A crashed run converges on re-run, structurally. A crash at ANY swap stage leaves every
row in at least two places, and the next disable's first act — before any
DROP SCHEMA CASCADE— finishes every crashed table's move under the same count/twin verification: a stranded trash renames back, stranded copies are absorbed into the live table, and nothing holding a row whose live twin is missing is ever dropped. There is no hand-finishing step. - Run it in a maintenance window when no worker archives anyway — the same caveat the enabling deploy carries. Workers keep running through the whole disable (the advisory lock serializes migrators only); the rename-first swap returns the rows from the trash, so a row committed mid-swap lands in the trash and comes back — the copy window loses nothing — but the swap's locks and the widened-uniqueness edge (below) are sized for a quiet archive all the same. One loud refusal: rows the hypertable's widened uniqueness admitted (same id, different partition-column value) cannot come back into the restored table's bare primary key; the swap names the conflicts and stops rather than choosing which row survives.
Roll-forward semantics hold on both paths: the flag-off deploy issues zero statements and a previously converted schema keeps its chunk retention at the last-registered intervals until a disable run actually executes — nothing between the flip and the disable degrades a live fleet.
See timescaledb.md for the conversion DDL, the retention-interplay rules, and the test coverage pinning all of it; configuration.md for the retention settings the chunk intervals and policies derive from.
Related documentation¶
- retries.md: retry policies,
start_to_closevsschedule_to_close, classifier hooks - workers.md: worker internals, concurrency model,
WorkerSettingsreference - jobs-clients.md: enqueue/batch APIs, idempotency, dedup mechanics
- actors.md:
@actoroptions,unique_for, sync actors, sub-job enqueuing - rate-limiting.md: primitives, backends, reservations, queue-level strict caps
- cron.md: schedule registration, DST strategies, per-property dedup
- managed-identities.md: credential providers, BYO connections, token rotation
- deployment.md: production checklist, Kubernetes/systemd/Compose, scaling
- observability.md: OTel setup, metrics reference, error reporting
- timescaledb.md: the hypertable opt-in, conversion mechanics, measured trade-offs
- troubleshooting.md: symptom-indexed diagnosis for everything above going wrong