Alert Runbooks¶
One section per TaskQ Prometheus alert, in the shape an on-call reader needs first: what fired, how to confirm it against the database (not just the metric), how to remediate, and (for the backlog alerts) how to tell under-provisioned (add workers) from stalled (fix the sweep/database; scaling out makes it worse).
The shipped alert rules live in
src/taskq/contrib/prometheus/rules.yaml
(plain) and
src/taskq/contrib/kubernetes/prometheus_rule.yaml
(PrometheusRule CRD); the two carry identical alerts.
SQL examples assume the default schema (TASKQ_SCHEMA_NAME=taskq); substitute
your schema. Timestamps in the jobs table are written by the database clock,
which is why every age check below uses clock_timestamp() server-side
instead of subtracting in your own clock domain.
TaskQQueueDepthHigh¶
What fired. max by (actor, queue) (taskq_jobs_oldest_pending_age_seconds) > 900 for 5 minutes: the oldest PENDING job of some (actor, queue) pair has been eligible for more than 15 minutes, sustained. The gauge is attributed per actor and per queue on purpose, not per queue: in a multi-worker fleet no worker refuses to start because an actor's declared queue has no consumer (no supervisor can know what consumes a queue), so a misrouted actor produces no refusal, no error and no failed job, and its rows pile up pending while every probe stays green. Age rather than depth, because a deep queue that drains is healthy throughput.
How to confirm.
- Metric:
taskq_jobs_oldest_pending_age_seconds{actor="...", queue="..."}climbing while the same queue's other actors stay flat is the misroute-or-starvation shape; the whole queue rising together is shared capacity. Readtaskq_queue_live_workers{queue="..."}beside it: a zero there is the never-consumed case, which TaskQQueueUnserved owns. - SQL: the pending head of every (actor, queue) pair right now:
SELECT actor, queue, count(*) AS depth,
extract(epoch FROM (clock_timestamp() - min(scheduled_at)))::int AS oldest_age_s
FROM taskq.jobs
WHERE status = 'pending' AND scheduled_at <= clock_timestamp()
GROUP BY actor, queue
ORDER BY oldest_age_s DESC;
- The gauge is fed by the leader's
actor_backlogsampler. If the series went absent or frozen exactly when the incident got worst, the sampler read is what died; check TaskQSweepTimeouts before trusting the (now silent) gauge.
How to remediate.
- No live worker subscribes to the queue: add the queue to some worker's
TASKQ_QUEUESand restart it, or run a worker that serves it. The pile dispatches on its own once a consumer exists; nothing was lost. - Live workers present and draining slowly: the queue is under-provisioned. Add workers or raise
TASKQ_MAX_CONCURRENCYon the fleet that serves it. - One actor aging while its queue-mates drain: check for a damper holding its rows back, the actor's own
max_concurrentor a queue cap fromtaskq queues set-max-concurrent; the alert shows the head's age, the cap is the cause. - Confirm recovery: the age falls under the threshold and the depth drains.
Do not clear the gauge by deleting or re-enqueueing the pending pile: the rows are eligible work that dispatches in order once capacity exists, and re-enqueueing duplicates them.
TaskQScheduledBacklogGrowing¶
What fired. taskq_jobs_oldest_due_age_seconds > 300 and (taskq_jobs_scheduled_count > (taskq_jobs_scheduled_count offset 5m) or changes(taskq_jobs_scheduled_count[5m]) == 0) for 5 minutes: the oldest due job has been waiting more than 5 minutes AND the scheduled job count is demonstrably not draining: either it is HIGHER than it was 5 minutes ago (promotion from scheduled to pending is not keeping up with arrivals) or it has not moved at all across those 5 minutes (promotion has stopped and no arrivals are landing net, the stalled plateau). Either way, due work is waiting while the scheduled backlog fails to shrink, not merely one slow-to-clear job.
An earlier form of this alert joined the oldest-due-age gauge against its own value 5 minutes back. That self-join is satisfied by a perfectly healthy, steadily draining backlog for the entire time its current straggler waits its turn: the age of "whichever job is currently oldest" climbs monotonically right up until that one job is promoted, regardless of how healthily everything behind it drains, so the join degenerated to a bare age > 300 threshold and paged on healthy operation. Count, not the single oldest item's age, is what distinguishes "stalled" from "one slow straggler": a scheduled count that is moving (falling as jobs promote, rising as arrivals land) is healthy flow no matter how long the current straggler has waited. What is never healthy is a due job aging past the threshold while the count never moves at all, and a strict growth comparison cannot see it: a flat count is never > itself 5 minutes back, so a promoter that has stopped completely while arrivals are absent (or throttled, or backpressured) would stay silent forever on the growth arm alone. That is the stalled plateau the changes(...) == 0 arm catches.
taskq_jobs_scheduled_count is a label-less twin of taskq_jobs_by_status{status="scheduled"}, sampled by the same leader tick; it exists so every operand of the alert carries an identical (empty) label set. Prometheus pairs the two sides of a vector and/or (or comparison) only when their label sets are identical, with no on/ignoring modifier here to reconcile a mismatch, and it reports a non-matching join as an empty result rather than an error, so a version of this alert that joined the per-status depth gauge directly against the label-less age gauge could never fire, however bad the stall.
How to confirm.
- Metrics:
taskq_jobs_oldest_due_age_secondsclimbing;taskq_jobs_scheduled_count(equivalentlytaskq_jobs_by_status{status="scheduled"}) either rising while{status="pending"}is flat (outpaced by arrivals) or not moving at all (the stalled plateau). - SQL: jobs that are due for promotion right now:
A persistently non-zero count on the leader is the confirmation; a healthy promotion sweep drives it to zero (or near zero) every second.
How to remediate. This alert is the derivative and the age: depth alone can be a legitimate burst, but due jobs aging past 5 minutes is not. Follow the triage below before touching replica counts.
Under-provisioned or stalled?
- Under-provisioned → add workers: queue depth high,
taskq_jobs_oldest_due_age_secondslow or falling, andtaskq_maintenance_leader_sweep_last_success_seconds{sweep_name="scheduled_to_pending"}fresh (moving every second). Dispatch capacity is the bottleneck; scale out. - Stalled → fix the sweep/database, do NOT scale out:
taskq_jobs_oldest_due_age_secondsclimbing while the scheduled count rises or never moves. The usual signature is the sweep'slast_successstamp gone stale: promotion is not running at all. A FRESH stamp with a frozen count is the subtler shape: the sweep completes but promotes nothing, so checkTaskQSweepTimeoutsandTaskQSweepDegradedand confirm with the due-jobs SQL above. No amount of extra workers promotes a scheduled job; only the leader's sweep does. Scaling a stalled engine adds database load without adding progress. See TaskQPromotionStalled and continue there.
TaskQPromotionStalled¶
What fired. time() - taskq_maintenance_leader_sweep_last_success_seconds{sweep_name="scheduled_to_pending"} > 120 for 2 minutes: the scheduled_to_pending sweep has not completed in 2 minutes. Three cadences are in play and they are not the same number: the promotion tick runs every second (worker/leader.py's scheduled_wake loop), sweep_interval — the unit the staleness bound is expressed in — defaults to 30 s, and three missed sweep_intervals — the /ready degraded criterion — is a stall, not slowness, while this alert's own threshold is the fixed 120 s. This is the same signature as the 12,732-job incident, where a second schema in one database silently held the maintenance lock and promotion stopped while everything else looked green.
How to confirm.
- Metric: the
last_successstamp forsweep_name="scheduled_to_pending"frozen while the process is up;taskq_jobs_by_status{status="scheduled"}climbing. - SQL: due jobs accumulating:
SELECT count(*), min(scheduled_at) FROM taskq.jobs
WHERE status = 'scheduled' AND scheduled_at <= clock_timestamp();
- SQL: is anything holding this schema's maintenance advisory lock? The
lock key is schema-qualified
(
hashtextextended('taskq:maintenance_leader:<schema>', 0), built bytaskq.constants.schema_lock_name), so it appears withclassid = 0:
SELECT pid, locktype, classid, objid, granted
FROM pg_locks
WHERE locktype = 'advisory' AND classid = 0;
A granted = false waiter with an old pid, or a holder whose pid
maps to a session of this schema that should not be leader (a stuck
partitioned holder), is the finding. The classic second-schema cause:
another schema's deployment silently holding the one shared lock,
fixed by the schema-qualified names (see
TaskQLeaderLockContention); a holder from
another schema now takes a different key and cannot stall this one.
Clock-domain caveat (both sides of the difference are wall clock). The
alert's expression computes time() - <gauge> in the wall-clock domain on
both operands: the gauge is stamped from time.time() inside the leader
process (see the metric's description) and Prometheus's time() is the
scrape-time wall clock. A backward clock step (an NTP correction, a manual
change, a VM restored from a suspended state) moves time() behind the
stamp, so the expression reads negative and stays under the 120 s
threshold until wall time has caught up with the stale stamp: a sweep
that stopped completing reads fresh for the whole catch-up window and
this alert is muted for exactly that long. A forward step has the
opposite polarity: it can fire the alert one jump early. This caveat is
scoped to the Prometheus alert expression and to the
sweep_last_success_seconds gauge it reads — both operands are wall
clock. The readiness body's in-process maintenance view is deliberately
NOT in that scope: worker/health.py computes its staleness from the
monotonic ledger (_sweep_success_monotonic_cache, time.monotonic()),
which cannot step backwards, so /ready's maintenance section is
trustworthy through NTP corrections and every other wall-clock event
(the two-ledger design — a wall stamp recorded beside every monotonic
one at the same call site — is documented at obs/_otel.py's
record_sweep_success). If a promotion stall is suspected during or
right after a clock correction, do not trust the alert expression: run
the due-jobs SQL below and watch the last_success stamp's own
movement, which is a timestamp, not a difference — and trust /ready,
whose maintenance view has no such exposure.
How to remediate.
- Check
TaskQSweepTimeoutsandTaskQSweepDegraded; if either is firing, the database cannot finish the sweep's batches and that is the root cause. - Check
{schema}.maintenance_leader.expires_at. A lease that has lapsed and that no pod takes over means the survivors cannot reach or write that table; check theirelection-attempt-failedlogs and the application role's grants on it. Recovering the role needs nothing else: no privilege over other sessions, and no manual intervention in the database. - If no lock contention and no timeouts: check leader health
(
sum(taskq_maintenance_leader_is_leader) == 1; theTaskQLeaderSplitBrainOrNoLeaderalert covers the zero-leader case) and Postgres connectivity/latency from the leader pod. - After the cause is fixed, confirm recovery: the
last_successstamp moves again and the due-jobs count from the SQL above drains to zero.
Do not scale out. Promotion is a leader-side sweep, not worker capacity.
Extra workers consume pending jobs faster but promote nothing.
TaskQSweepTimeouts¶
What fired. rate(taskq_maintenance_leader_sweep_timeouts_total[5m]) > 0 for 5 minutes: sweep batches are being aborted by deadlines (TimeoutError on the client) or server-side cancels (QueryCanceledError from statement_timeout): the database cannot finish bounded batches, or a gauge sampler's read did not complete. The sweep_name label says which: sweep names are aborted batches; the sampler names (queue_depth, backlog_detection, actor_backlog, reservation_slots) are reads that did not happen, counted for every failure class; a dead sampler's gauges go stale or absent while nothing else names the loss, and the per-actor backlog read dying resolves TaskQQueueDepthHigh at the exact moment the incident it alerts on is killing the read, which is why that failure must land here.
How to confirm.
- Metric:
taskq_maintenance_leader_sweep_timeouts_totalrising, labeled bysweep_name;TaskQSweepDegradedoften follows once the batch-size breaker latches. A sampler name rather than a batch name means a gauge read is failing; read that gauge beside the counter: an absent or frozen series while this rate rises is the dead-sampler signature, never a resolved alert. - SQL: what the sweeping session is doing when it dies (run while the rate is non-zero):
SELECT pid, state, wait_event_type, wait_event, now() - query_start AS age,
left(query, 120) AS query_head
FROM pg_stat_activity
WHERE query ILIKE '%job_events%' OR query ILIKE '%taskq%';
Long age with wait_event_type = 'Lock' points at a lock pile-up;
age near TASKQ_EVENT_WRITER_STATEMENT_TIMEOUT_MS with no wait event
points at plain slowness (I/O, bloat, plan regression).
How to remediate.
- Identify the wait from the SQL above. Lock pile-up: find and clear the
blocking session (
pg_blocking_pids()). Slowness: check table bloat and indexes on the tables the sweep writes (job_events,jobs). - If the database is genuinely slower than the batch budget, lower
TASKQ_EVENT_WRITER_BATCH_SIZE(each batch gets smaller; the sweep drains the remainder across more batches) rather than raisingTASKQ_EVENT_WRITER_STATEMENT_TIMEOUT_MSpast thereclaim_event_visibility_delaymargin; the margin is what keeps out-of-commit-order reclaim events from being silently missed. - Remember the reduced tier is a degradation ceiling, not a fix: if
TaskQSweepDegradedis firing alongside this alert, the worker has already latched to reduced batches and the database problem is still there.
TaskQSweepUnexpectedErrors¶
What fired. rate(taskq_maintenance_leader_sweep_unexpected_errors_total[5m]) > 0 for 5 minutes: prune-family sweep batches (the prune and archive_expiry sweeps) are being aborted by an error outside the deadline family, a constraint violation, a connection reset, or any other fault that is not a statement cancel. TaskQSweepTimeouts covers the deadline family only; this counter is the rest, and without it a drain stopped by this class reads healthy on every success-path metric: the row counters and success stamps only move on success, so a prune that keeps failing looks like a quiet day.
How to confirm.
- Metric:
taskq_maintenance_leader_sweep_unexpected_errors_totalrising, labeled bysweep_name(pruneorarchive_expiry). Theprune-failed/archive-expiry-failederror log lines carry theerrorrepr next to each increment; the exception class is the diagnosis. - Cadence: one increment per aborted batch, and the first aborted batch ends the sweep attempt, so the counter advances on the retry ladder (
TASKQ_SWEEP_BREAKER_*backoff, 60s doubling to a 1800s cap) rather than per batch per second. A rate that stays positive over the 5m window is a persistent failure, not a busy drain. - Latch: the batch-size breaker counts these failures the same as deadline failures, so
TaskQSweepDegradedfiring alongside means the worker has latched to the reduced tier and is still failing there.
How to remediate.
- Read the exception class on the
prune-failedlog line. AUniqueViolationErroragainst the archive tables usually means duplicate archive targets: check for a second writer inserting intojobs_archive(a sibling taskq deployment pointed at the same schema), or a partially-restored backup. - A connection-class error (
PostgresConnectionError,OSError): treat as a database or network incident; the retry ladder resumes the drain when the connection is back. - Anything else: the error class is new, the reduced tier is the safety net that keeps the batches small while you look. If the sweep stays stopped, the retention backlog grows silently behind
TaskQSweepDegraded; escalate before the backlog hits dispatch latency.
TaskQSweepDegraded¶
What fired. taskq_maintenance_leader_sweep_batch_size < taskq_maintenance_leader_sweep_batch_size_configured (for 0m, page immediately): a sweep is running at the reduced batch tier. The worker itself is reporting an unhealthy database: the batch-size breaker only latches after repeated batch cancellations, and it does not unlatch for the rest of the process lifetime. Both series are emitted by the same worker under the same sweep_name label, so the comparison always tracks that worker's own TASKQ_EVENT_WRITER_BATCH_SIZE configuration; no threshold to maintain.
How to confirm.
- Metric:
taskq_maintenance_leader_sweep_batch_sizepersweep_namebelow the same worker'staskq_maintenance_leader_sweep_batch_size_configured(the configuredTASKQ_EVENT_WRITER_BATCH_SIZE). - SQL: the sweep is still making progress, just slower:
Combined with TaskQSweepTimeouts history: the breaker latched because
batches kept being cancelled within TASKQ_SWEEP_BREAKER_WINDOW_SECS.
How to remediate.
- Treat this as the worker's own verdict on the database: find and fix the underlying slowness (see TaskQSweepTimeouts).
- Restart the worker (or let the orchestrator recycle it) after the database is healthy again; the breaker does not unlatch, so the reduced tier persists until a fresh process.
TaskQDispatchLatencyHigh¶
What fired. histogram_quantile(0.99, rate(taskq_dispatch_duration_seconds_bucket[5m])) > 0.05 for 5 minutes, by queue: the dispatch query's p99 exceeds 50 ms. The histogram times the claim round trip's SQL execution only, not actor run time, so this is the database getting slow at handing out work. The threshold is set near where the claim's FOR UPDATE SKIP LOCKED plan stops being index-shaped: a healthy dispatch round is sub-millisecond, so a 50 ms p99 is a plan or contention problem, not a tuning nit.
How to confirm.
- Metric:
taskq_dispatch_duration_secondsp99 byqueue. One queue high while the rest are flat points at that queue's claim path; every queue high at once points at the database. - SQL: what the dispatching sessions are waiting on while the alert fires:
SELECT pid, state, wait_event_type, wait_event,
now() - query_start AS age, left(query, 120) AS query_head
FROM pg_stat_activity
WHERE query ILIKE '%SKIP LOCKED%' OR query ILIKE '%taskq%';
wait_event_type = 'Lock' with short ages is dispatcher-on-dispatcher contention; ages near the threshold with an I/O wait is heap or index pressure on jobs.
- Plan check: the claim reads the pending partial index (jobs_dispatch_idx). A bloated table or a dropped index shows as a sequential-scan plan; EXPLAIN the claim query and compare against the flat sub-millisecond expectation in docs/design/sql-hotpath-followups.md.
How to remediate.
- Every queue slow: treat it as a database incident first. TaskQSweepTimeouts and TaskQRateLimitDependencyOutage read the same Postgres; a latency problem there degrades dispatch with it, and fixing dispatch alone fixes nothing.
- One queue slow: check the population the claim has to consider. A very deep
pendingbacklog on that queue, a largeTASKQ_DISPATCH_OVERSAMPLE, and table bloat all grow the candidate set the claim probes before it locks. - Confirm recovery: the p99 falls back under 50 ms.
Do not scale dispatchers out to fix this: more concurrent claim rounds multiply contention on the same rows and push the p99 up, not down. Capacity adds consumption rate; it does not make the claim faster.
TaskQClaimLatencyDegraded¶
What fired. taskq_claim_degradation_ratio > 10 for 5 minutes, by queue: this worker's claim-query latency p99 is more than ten times its own rolling 24-hour baseline (minute-bucketed p99s, kept for a day). The ratio is the PREDICTOR of the MVCC death spiral: a long or overlapping transaction pins the MVCC horizon, VACUUM cannot reclaim, and the claim query's B-tree scans degenerate through the dead tuples the queue's own churn leaves behind - brandur.org/postgres-queues measured 15x lock-time degradation this way, and PlanetScale's 2026 re-run confirmed SKIP LOCKED only "lifts the floor, not the ceiling" (identical dead-tuple scans under both). The ratio exists because an absolute threshold cannot see this failure: a fleet that degrades slowly makes each new latency its own normal (and TaskQDispatchLatencyHigh, the absolute twin, stays silent).
How to confirm.
- Metric:
taskq_claim_degradation_ratio{queue="..."}climbing past 10 whiletaskq_claim_claims_per_second{queue="..."}falls is the spiral's last visible stage (the claim path slowing until throughput collapses). The percentiles (taskq_claim_latency_p50_secondsand its p95/p99 siblings) show the distribution moving as a whole: p50 moving with p99 is the whole scan degrading, not one straggler. - SQL: find the transaction pinning the horizon - the one thing every dead tuple needs gone before VACUUM can touch it:
SELECT pid, state,
now() - xact_start AS xact_age,
now() - state_change AS idle_age,
left(query, 120) AS query_head
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
AND now() - xact_start > interval '5 minutes'
ORDER BY xact_start;
A long-running transaction of any shape is the pin: an idle-in-transaction app session holding a connection mid-transaction, a long migration, a pg_dump with a snapshot, a stuck LISTEN/event-loop transaction.
- Confirm the dead-tuple pressure it causes:
SELECT n_dead_tup, n_live_tup, last_autovacuum
FROM pg_stat_user_tables
WHERE relname = 'jobs' AND schemaname = 'taskq';
n_dead_tup climbing while last_autovacuum stalls in the past is the horizon pinned; the claim scans pay for every one of those rows.
Why the timeouts do not save you. Postgres ships three relevant knobs, and none of them catches this failure:
idle_in_transaction_session_timeout(default 0 = off) fires only when a session sits IDLE inside a transaction - an actively working long transaction (a batch migration, a COPY) never trips it;transaction_timeout(Postgres 17+, default 0 = off) caps a transaction's total lifetime, but the pinning transactions in production are usually legitimate - a 20-minute migration is not a leak - so operators leave the knob off, and an app pool's idle-in-transaction session may not be the one pinning anything anyway;statement_timeoutbounds ONE statement, not the transaction: a transaction that runs a million short statements never trips it while pinning the horizon for its whole life.
And even when a timeout fires, it fixes the future, not the present: the dead tuples accumulated while the horizon was pinned stay until VACUUM reaches them.
How to remediate.
- Kill or complete the pinning transaction:
SELECT pg_terminate_backend(<pid>);for an app-held one (the app retries; that is what retries are for), or let the migration finish. The horizon unpins the moment the last snapshot that can see the dead tuples ends. - Confirm VACUUM drains:
n_dead_tupfalls andtaskq_claim_degradation_ratioreturns toward 1. The ratio falls within a claim-window of the vacuum, which is the confirmation the incident is over - do not re-page on the absolute gauge's slow decay. - Check what held the horizon: an application session left
idle in transactionneedsidle_in_transaction_session_timeoutset on THAT role (ALTER ROLE app SET idle_in_transaction_session_timeout = '5min'), which is a targeted fix that cannot break legitimate long migrations.
Mitigation, bounded and measured: the claim cursor (TASKQ_CLAIM_CURSOR_RESET_SECONDS, opt-in - default off) bounds the claim query's candidates at the worker's per-queue high-water mark of claimed ids, so the candidate probe's seek lands on live tuples instead of walking the dead zone. It is a selection predicate (a below-cursor row strands until the jitter reset forgets the cursor - the knob's interval, ±50%), round-robin queues are exempt, and its end-to-end win on this codebase's claim CTE is plan-dependent (the spiral's planner-blindness keeps some probes on index choices the bound cannot position) - measured in perf-evidence-mvcc-horizon.md and gated in tests/perf/test_mvcc_claim_degradation.py. The gauges are the part that works everywhere; the cursor is the bounded experiment for fleets whose shape matches the isolated measurement.
TaskQLeaderLockContention¶
What fired. sum(rate(taskq_leader_lock_contention_total[10m])) > 0 and sum(taskq_maintenance_leader_is_leader) < 1 sustained for 10 minutes: maintenance-lock acquisitions are being lost AND no worker holds leadership. The counter is recorded by the losing side at every maintenance acquisition point, labeled by lock.
The leader-count operand is what makes this alertable at all. A healthy multi-worker fleet has exactly one winner per election round and every other worker records a loss, so a lost-acquire rate above zero is the normal steady state of any fleet larger than one worker; an alert on that rate alone pages on health and trains operators to silence it. Losses while the leader count has fallen below one is the genuine signature: nobody ever wins. Both operands are summed to fleet-wide scalars so the vector join pairs; an and between series carrying different label sets never matches, and Prometheus reports that as an empty result rather than an error. The lock key is schema-qualified (taskq:maintenance_leader:<schema>), built by taskq.constants.schema_lock_name, so contention is always between sessions of the same schema: a second schema in the same database takes a different key and cannot starve this one (the cross-schema silent starvation the unqualified lock name allowed is fixed). What sustained contention on a schema-qualified lock means: same-schema double-election attempts that keep losing (two pods of this deployment racing each other every heartbeat), or a stuck/long-held session sitting on this schema's key.
How to confirm.
- Metric:
taskq_leader_lock_contention_totalrising, labeled bylock; confirm the label is your schema'staskq:maintenance_leader:<schema>. Read it besidesum(taskq_maintenance_leader_is_leader): a rising rate with that sum at 1 is an ordinary fleet electing a leader, not an incident. A rate that equals the election attempt rate (taskq_leader_election_attempts_total) fleet-wide, with the sum at 0, means no worker ever wins. - SQL: advisory lock holders and waiters:
SELECT l.pid, l.classid, l.objid, l.granted, a.application_name,
now() - a.backend_start AS session_age
FROM pg_locks l JOIN pg_stat_activity a ON a.pid = l.pid
WHERE l.locktype = 'advisory';
The lock is a single bigint key (hashtextextended('taskq:maintenance_leader:<schema>', 0)), so it appears with classid = 0; match objid against SELECT hashtextextended('taskq:maintenance_leader:<your-schema>', 0) to pick this deployment's row out of the set. Contention is recorded once per distinct holder a pod finds in its way, so a stable fleet (however many followers it has) records nothing after it settles, and a handover records one event per pod. A sustained rate therefore means the observed holder keeps changing (handover churn), or that this pod cannot even read the lease row to observe a stable holder; its probe fails every cycle; check its leader-retry lines and connectivity.
How to remediate.
- Identify the winning session from the SQL above. Because the key is schema-qualified, a holder belonging to another schema or deployment sharing this database is not the cause: it holds a different key. The holder is a session of this same schema.
- Cross-check it against
{schema}.maintenance_leader. A pod that follows a live lease records no contention at all, and a pod that finds the row absent wins it outright on its next cycle, so a sustained rate means the counter's remaining shapes: the row's holder keeps changing (handover churn), or the counting pod cannot read the row at all. A courtesy lock held by a session no lease row accounts for never produces it: the lease is what confers the role, so nothing waits on that session, but a lock nobody owns still points at a pod that elected under an older release, or at a session one left behind. - Upgrade discipline: the schema-qualified names replaced the
unqualified (
taskq:maintenance_leader) ones outright, and the two are NOT overlap-compatible: a mixed old/new fleet holds different keys, so old and new releases can both act as leader of the same schema at once (sweeps stay row-safe underFOR UPDATE SKIP LOCKED; cron gains a double-fire window because its lock is what serialises ticks). Adopt a release that changes lock names by restarting the fleet onto it, not by rolling it; the window is the deploy, not the steady state (seetaskq.constants.schema_lock_name). - Confirm recovery:
taskq_leader_lock_contention_totalstops rising andsum(taskq_maintenance_leader_is_leader) == 1again (seeTaskQLeaderSplitBrainOrNoLeader).
TaskQLeaderSplitBrainOrNoLeader¶
What fired. sum(taskq_maintenance_leader_is_leader) != 1 for 2 minutes, severity critical. The gauge reads 1 on the elected leader pod and 0 on every follower, labeled by worker_id, so the fleet-wide sum is the leader count. 0 means no worker holds the maintenance role: promotion, cron, reclaim and prune are all stopped. Above 1 means two pods both believe they are leader. Both are split-brain conditions in the operational sense: the single-serialiser contract everything else assumes is not holding.
How to confirm.
- Metric:
taskq_maintenance_leader_is_leaderperworker_id. All zeros, or two 1s. The zero case withtaskq_leader_lock_contention_totalrising is no worker ever winning the election; see TaskQLeaderLockContention. - SQL: who the database says holds the role, and who holds the advisory lock:
SELECT worker_id, elected_at, expires_at FROM taskq.maintenance_leader;
SELECT pid, granted, now() - backend_start AS session_age
FROM pg_locks l JOIN pg_stat_activity a ON a.pid = l.pid
WHERE locktype = 'advisory' AND classid = 0;
A healthy read is exactly one lease row with expires_at in the future, held (via the schema-qualified bigint key) by that pod. Two lease rows with overlapping terms, or a pod still reporting is_leader = 1 after its own row lapsed, is the finding.
- The above-one shape has a known mundane cause: a fleet mid-upgrade across releases whose lock names differ holds different keys and can both act as leader of the same schema at once. Rule the mixed-fleet case out first (see the upgrade discipline in TaskQLeaderLockContention) before hunting anything exotic.
How to remediate.
- Zero leaders: work the contention path, not the gauge. Follow TaskQLeaderLockContention and TaskQPromotionStalled. Everything leader-side is stopped while this reads 0, so pending and scheduled work piles up, but nothing is lost; workers keep consuming what is already
pending. - Two leaders: identify the stale believer from the metric's
worker_idand restart that pod. The usual shape is a pod partitioned from Postgres right after winning: its in-processis_leaderevent stays set until a renewal fails, while the lease row it left behind lapses and a successor takes over. The sweeps are row-safe underFOR UPDATE SKIP LOCKEDeither way; the one real double-fire window is cron, whose advisory lock is what serialises ticks, so checkcron_schedules-driven jobs for duplicates on the overlap interval before declaring no damage. - Confirm recovery: the sum returns to exactly 1 and the per-sweep
last_successstamps move again.
TaskQRateLimitDependencyOutage¶
What fired. rate(taskq_ratelimit_acquire_dependency_failures_total[5m]) > 0 for 5 minutes: rate-limit acquires are failing because the limiter's store (Redis, or the PG fallback behind it) could not answer, and the worker failed the acquire closed as a denial. Every rate-limited dispatch is snoozing while the outage lasts: no work is lost, but nothing rate-limited moves either, and the queue looks calm while it piles up behind the limiter.
How to confirm.
- Metric:
taskq_ratelimit_acquire_dependency_failures_totalrising, labeled byerror_type(the exception class name). Read it besidetaskq_reservation_denials_total{source="rate_limit"}: denials with this counter flat are ordinary contention; denials with this counter rising are an outage masquerading as contention; the two must be told apart before anyone scales a bucket. - The
error_typelabel names the failure class (a Redis connection error, a timeout): it distinguishes "the store is unreachable" from "the store is slow". - Check the store from a worker pod, not from your laptop: the outage is between the worker's network position and the store (DNS, NetworkPolicy, the store itself).
How to remediate.
- Restore the store dependency: Redis connectivity from the worker pods first (the common cause), then the store itself. The PG fallback fails the same closed way when Postgres is the sick dependency; check
TaskQSweepTimeouts/TaskQDispatchLatencyHighbefore touching Redis. - Do NOT raise bucket limits or disable rate limiting during the outage: the denials are the limiter failing closed (the configured safe behavior), and widening limits cannot create store capacity.
- Snoozed dispatches retry on their own once acquires succeed again; confirm recovery by watching
taskq_ratelimit_acquire_dependency_failures_totalflatten and the snoozed backlog drain (taskq_jobs_by_status{status="scheduled"}falling).
TaskQProgressPublishFailures¶
What fired. rate(taskq_progress_publish_failures_total[5m]) > 0 for 5 minutes: Redis publish round trips for progress events are failing. The counter is bumped on every failed publish attempt, labeled by channel (per_job or global) and error_type (the exception class name). The progress-publish-failure WARNING beside it is window-gated to one line per channel per minute, because a sustained Redis death fails every publish of every job and a log line per attempt is a flood, not a signal; the counter is the per-attempt aggregate the alert reads.
What it means. Only the real-time fanout degrades: the admin UI's live progress views fall back to polling at TASKQ_ADMIN_UI_POLLING_INTERVAL_SECONDS, so pages stay correct and merely stale between polls. The progress rows themselves persist on a separate Postgres path, counted by taskq_progress_flush_failures_total (a stage="per_job" versus stage="pool" distinction, a different incident); a job's settled progress is not lost by a publish failure.
How to confirm.
- Metric:
taskq_progress_publish_failures_totalbychannel, error_type.per_jobfailures drop live updates for individual jobs' viewers;globalfailures drop the fleet-wide progress channel.error_typedistinguishes the shapes: connection classes are network between the worker and Redis,ResponseErroris Redis answering with an error (read-only replica, maxmemory), timeout classes are saturation. - The admin UI shows the degradation directly: a page whose SSE stream cannot deliver events still polls, so "the page updates, only late" is the published symptom of this alert, never of the flush counter.
How to remediate.
- Restore Redis from the worker pods' position (the outage is between them and the store, so test from there, not from your laptop): connectivity first, then capacity (
maxmemoryevictions, a read-only replica after a failover). - Do not disable progress emission to quiet the alert: the publish is fire-and-forget and its failure mode (a stale admin page that still polls) is strictly better than flying without progress telemetry.
- Confirm recovery: the rate returns to 0 and the live views resume updating between polls.
TaskQCronLockContention¶
What fired. rate(taskq_cron_lock_contention_total[10m]) > 0 for 10 minutes: cron ticks are returning without firing because another session holds the cron advisory lock, sustained. A brief low rate is the benign leader-handover overlap; a rate sustained at the tick cadence means cron is not running anywhere: the lock is transaction-scoped and releases on COMMIT/ROLLBACK, which never happens if the holding session was partitioned without a FIN. That is the fleet-wide cron stall: every schedule silently stops firing, and the signals an operator would check first (taskq_cron_disabled_schedules, taskq_cron_consecutive_failures) deliberately stay still in that mode.
How to confirm.
- Metric:
taskq_cron_lock_contention_totalrising at roughly the tick rate (one contention per tick attempt), not brief bursts.taskq_cron_disabled_schedulesstaying 0 while nocron firedlines appear is the same stall seen from the other side. - SQL: the cron lock holder (the lock name is
taskq:cron:<schema>, a single bigint key viahashtextextended, so it appears withclassid = 0):
SELECT l.pid, l.granted, a.state, a.wait_event_type,
now() - a.backend_start AS session_age,
left(a.query, 120) AS query_head
FROM pg_locks l JOIN pg_stat_activity a ON a.pid = l.pid
WHERE l.locktype = 'advisory' AND l.classid = 0
AND l.objid = (SELECT hashtextextended('taskq:cron:<your-schema>', 0));
A long-lived granted session whose session_age far exceeds worker liveness (partitioned without a FIN) is the finding; a healthy leader's hold is transaction-scoped and momentary.
How to remediate.
- Terminate the stale holder if it is dead weight
(
SELECT pg_terminate_backend(<pid>);); the server reaps partitioned sessions only on itstcp_keepalives_*schedule, which can outlast a maintenance window. - If the holder is a healthy worker of THIS schema: two pods both running cron loops against the same schema points at a deployment/election misconfiguration: one cron loop per schema is the contract; fix the deployment, do not kill the session.
- Confirm recovery:
taskq_cron_lock_contention_totalstops rising,cron firedlines resume, and missed schedules catch up (due schedules fire immediately once the lock is free; cron does not skip missed ticks by default; see the cron guide forTASKQ_CRON_TICK_LIMITif the backlog is large).
TaskQCronBudgetDeferrals¶
What fired. A sustained rate of taskq_cron_budget_deferrals_total (one increment per cron fire the tick's funded factory budget could not fund a grant for). A brief burst is benign: catch-up backlogs drain in tick-sized batches and each draining tick defers whatever its factories' waits could not fit. A rate that persists for minutes means one schedule's payload factory is monopolizing the tick budget every tick: it is planned ahead of its peers (an older next_fire_at, or a catch-up crawl), consumes most of the funded factory budget, and still SUCCEEDS, so it never strikes, never auto-disables, and never frees the budget. Its peers then defer on every tick: delayed (the deferral advances next_fire_at one leader tick, the owed slot stays inside the catch-up window), never struck, never disabled: quiet starvation with no auto-disable rescue, which is exactly why the counter exists. The monopolizer itself looks perfectly healthy (cron fired lines, consecutive_failures = 0).
How to confirm.
- Metric:
rate(taskq_cron_budget_deferrals_total[5m])non-zero and flat, not decaying: a decaying rate is a catch-up drain ending. Theactorlabel names the STARVING schedule's actor (per-schedule attribution is on the log line, not the label). - Logs:
cron-fire-budget-deferredevents with the sameschedule_idevery ~1s, while a NEIGHBOUR schedule'scron firedlines keep appearing; the neighbour whose fires sit immediately beside the deferrals in the timeline is the monopolizer. Its factory's duration is visible in the gap between consecutivecron firedlines. - SQL: the order the tick plans in (the monopolizer is the enabled, due row at the front):
SELECT id, actor, name, payload_factory, next_fire_at,
now() - next_fire_at AS overdue_by, consecutive_failures
FROM taskq.cron_schedules
WHERE enabled AND next_fire_at <= now()
ORDER BY next_fire_at
LIMIT 10;
The monopolizer is the due row with a payload_factory and the oldest next_fire_at (or one advancing by one catch-up slot per tick); the deferring schedules are the factory-backed rows behind it.
How to remediate.
- Tighten
TASKQ_CRON_PAYLOAD_FACTORY_TIMEOUTto below the monopolizing factory's real duration (read it off thecron firedtimeline gaps). The factory then outruns its granted deadline, takes the strike it has earned, and auto-disables afterTASKQ_CRON_AUTO_DISABLE_THRESHOLDticks, freeing its peers. This is the intended consequence, not collateral damage: a payload factory slower than the operator-declared budget is a defect of that schedule. - If the monopolizer's duration is legitimate, raise
TASKQ_DISPATCHER_COMMAND_TIMEOUTso the funded factory budget (90% of it) fits the monopolizer plus a fundable grant (a quarter of the funded budget) for its peers, which then fire in the same tick. Check the watchdog interplay documented on that setting before widening it. - Or move the slow work out of the factory: payload factories run inside the leader's tick, holding the cron advisory lock for the whole planning batch. Work slower than a fraction of a tick belongs in the job the schedule enqueues, not in the payload build.
- Confirm recovery:
taskq_cron_budget_deferrals_totalstops rising, the deferring schedules'cron firedlines resume, and their owed slots fire (deferred slots are retried, not skipped; nothing was lost).
TaskQCronSkippedSlots¶
What fired. A sustained rate of taskq_cron_skipped_slots_total (one increment per cron fire attempt, carrying that attempt's full drop count, labeled by actor): the schedule's next_fire_at fell behind now() - TASKQ_CRON_CATCH_UP_WINDOW, and every schedule occurrence between the owed slot and the recomputed one is a fire the system could not even attempt. The count is in FIRE UNITS — a */5 schedule reported as 3 means three dropped fires (about 15 minutes of schedule time). Skipped slots are never replayed: the recompute lands next_fire_at in the future, so the count IS the record of what was dropped. A brief burst is a leader failover or a paused fleet crossing the window once; a rate sustained for 5 minutes means the schedule is losing the race every period — the runaway fan-out: a cron-triggered sync whose actor's jobs clear slower than the cron period fires them.
How to confirm.
- Metric:
rate(taskq_cron_skipped_slots_total[5m])— sustained non-zero is the runaway; a spike that decays to zero is a one-off (failover, paused fleet). The depth (how far behind the actor's worst schedule was at the skip) istaskq_cron_slots_behind{actor}— a depth that keeps CLIMBING period over period is the fan-out compounding; a depth that returns to 0 on the next fire is a one-time drop. - Logs: the
cron missed slots skippedWARNING carriesskipped_slotsandschedule_idper drop; thecron firedline for the same fire repeats the depth, so one schedule's timeline shows exactly which periods were dropped. - SQL: the clearance comparison — is the actor's job completion rate slower than the cron period? — is the runaway predicate in
src/taskq/insights.py(the per-schedule fires-vs-clearance SQL; fires are attributable to their schedule through the durablemetadata->>'cron_schedule_id'stamp on every cron-enqueued job). Run it to name the schedule and quantify the gap:
SELECT id, actor, name, cron_expr, next_fire_at,
now() - next_fire_at AS overdue_by, last_fired_at
FROM taskq.cron_schedules
WHERE enabled AND next_fire_at <= now()
ORDER BY next_fire_at
LIMIT 10;
- Distinguish the sibling signals:
taskq.cron.budget_deferralsis a budget-shaped lag (the fire is DEFERRED one tick and retried — nothing dropped);taskq.cron.skipped_slotsis a real drop (the slot is gone). A schedule can carry both.
How to remediate.
- Widen the fan-out's capacity: the actor's jobs take longer than the cron period, so the backlog compounds period over period — raise the actor's concurrency (a second worker on its queue) or make each job cheaper, until jobs clear faster than the period. The
taskq.jobs.actor_backlog/taskq.jobs.oldest_pending_age_secondsgauges show the pile-up on the job plane while the counter shows the drops on the cron plane. - Lengthen the cron period on the schedule (or fan out from one fire into a batched job) so the period fits the actor's real clearance time.
- If the drops are acceptable (the schedule only needs the LATEST occurrence, and stale ones may be dropped — a common and legitimate shape for sync triggers), widen
TASKQ_CRON_CATCH_UP_WINDOWto stop the drops, or leave it: the counter then documents the drops instead of hiding them. What the system never does is drop a slot without counting it. - Confirm recovery: the counter's rate returns to 0 and
taskq_cron_slots_behindre-observes the actor at 0 on its next clean fire.
TaskQCronScheduleDisabled¶
What fired. taskq_cron_disabled_schedules > 0 (for 0m, immediately): one or more cron schedules have been auto-disabled after TASKQ_CRON_AUTO_DISABLE_THRESHOLD (default 3) consecutive payload_factory failures. The gauge is refreshed by the leader's cron tick from the database's own count of disabled rows, so it settles on what every process has left behind after each tick. A disabled schedule enqueues nothing: its jobs stop firing, silently, on the schedule's own cadence.
How to confirm.
- Metric: the gauge value itself. It does not decay; it moves when the database's set of disabled schedules changes, and it returns to 0 when the last disabled schedule is re-enabled.
- SQL: which schedules are disabled and why:
SELECT actor, name, consecutive_failures, last_fire_error, next_fire_at
FROM taskq.cron_schedules
WHERE NOT enabled
ORDER BY actor, name;
last_fire_error carries the exception text of the strike that disabled the schedule; the cron schedule auto-disabled ERROR log lines and the per-tick cron fire failed lines name the same schedules.
- The strikes are consecutive: a single success resets the count, so a schedule that reached auto-disable failed three ticks in a row. A factory raising every tick is a defect of that factory's code or configuration, not a transient dependency; a rate-limit or database outage during the ticks instead shows up in the alerts that own those dependencies.
How to remediate.
- Fix the
payload_factorythelast_fire_errornames and deploy the correction before re-enabling. Re-enabling a still-broken schedule spends three more strikes on the same defect and lands back here. - Check whether a restart already resolved it. Since the ownership model, the schedule row carries
disabled_by:'auto'(the cron loop's failure-count auto-disable) is reverted automatically the next time a worker boot re-declares a code-owned schedule (enabled=true,consecutive_failures=0, the marker cleared, and acron-schedule-auto-disable-revertedlog line at the boot). So if the failure was a transient blip (failover, saturation) and the fixed or still-declared code re-registers the schedule, the alert resolves on the next boot and this section's step 2 is unnecessary. A row withdisabled_by='operator'is deliberate operator intent: no restart re-enables it, only the steps below do. A disabled row withdisabled_by=NULLis a mixed-version deploy's shape (an old pod cannot write the marker): a boot re-enables it when it carries the old failure arm's fingerprint (consecutive_failuresat or past the threshold,last_fire_errorset) and leaves it alone otherwise; disabled rows that predate marker tracking were stamped'operator'by migration01.00.19_05. During a rolling deploy the same recovery also runs at every new-release leadership assumption (log linecron-schedule-auto-disable-reverted-at-takeover), because the old leader can keep firing -- and keep striking -- after every new pod has booted: a fingerprinted NULL row resolves when a new pod next leads, not only at the next restart.
SELECT actor, name, enabled, disabled_by, consecutive_failures, last_fire_error
FROM taskq.cron_schedules
WHERE disabled_by IS NOT NULL OR NOT enabled
ORDER BY actor, name;
enable() resets consecutive_failures to 0, clears last_fire_error and disabled_by, and the admin UI's schedules page exposes the same action. Missed fires inside TASKQ_CRON_CATCH_UP_WINDOW catch up on the next tick after enabling; older misses are skipped by design.
4. Confirm recovery: the gauge returns to 0 and cron fired lines resume for the re-enabled schedule.
Do not raise TASKQ_CRON_AUTO_DISABLE_THRESHOLD to keep a broken schedule alive: the auto-disable exists so a permanently failing factory stops consuming tick budget every second, and a larger budget only delays the same silence.
TaskQRunningLeaseExpired¶
What fired. taskq_jobs_running_lease_expired > 0 for 5 minutes: running jobs whose lock lease is past expiry, sustained, with no cancel in flight (rows in a cancel phase (cancel_phase != 0) are carved out of the gauge, because the reclaim sweep deliberately waits cancel grace + cleanup grace + 60 s past expiry for a cancelling row before it pre-empts it), so an expired lease mid-cancel is the cancellation protocol working, not an incident (a cancel that never completes pages elsewhere: TaskQAbandonedJobs when its worker is alive to escalate through the phases, TaskQHeartbeatMisses when it died mid-cancel; the reclaim sweep honors the row to cancelled either way). A healthy fleet reads 0: the leader's reclaim sweep (sweep_name="expired_locks") drains expired leases within a tick or two of expiry, so a sustained non-zero count means reclaim is not draining. Work is claimed and stuck in running while health probes stay green: the zombie-running shape.
How to confirm.
- Metric:
taskq_jobs_running_lease_expired(sampled by every worker, so one flapping series is a sampling artifact; the alert fires on the sustained value). Cross-check the reclaim sweep's health:taskq_maintenance_leader_sweep_last_success_seconds{sweep_name="expired_locks"}fresh means the sweep runs but rows regrow faster than it drains (workers dying or wedging mid-run); a stale stamp means the sweep itself is stopped (see TaskQPromotionStalled: the same signature, different sweep). - SQL: the zombies and their holders (the gauge's own predicate, cancel phases excluded):
SELECT id, actor, locked_by_worker, lock_expires_at,
now() - lock_expires_at AS overdue_by, attempt, max_attempts
FROM taskq.jobs
WHERE status = 'running' AND lock_expires_at < clock_timestamp()
AND cancel_phase = 0
ORDER BY lock_expires_at;
Cancelling rows the sweep is still waiting out (expected, not zombies: the same grace ladder the reclaim sweep applies):
SELECT id, actor, cancel_phase, cancel_requested_at, lock_expires_at
FROM taskq.jobs
WHERE status = 'running' AND lock_expires_at < clock_timestamp()
AND cancel_phase != 0
ORDER BY lock_expires_at;
- The admin
/jobspage renders the same state per row (Lease column): a redexpiredbadge with the holding worker.
A note on the two clocks this runbook lives between: a holder can read as
dead on the liveness surfaces (the admin banner, taskq doctor's
"no live worker serves", taskq.queue.live_workers) while its RUNNING
rows are not yet zombies. Liveness is the worker row's heartbeat
window (TASKQ_ADMIN_WORKER_LIVENESS_SECONDS, default 30 s); the
reclaim is the ROW's own lease (lock_expires_at, default lease 60 s)
or its per-job heartbeat deadline - whichever fires first governs, and
neither consults the banner. So "doctor says no live worker" plus a
stuck running row inside its lease is the designed shape, not a
contradiction: the row's job follows the lease (this runbook), the
queue's pending work follows liveness (TaskQStrandedJobs).
The worker row itself is removed later still, by the stale-worker sweep's
own window (heartbeat_interval * (max_heartbeat_failures + 3), 60 s at
the defaults) - the band between the liveness edge and that removal is
where the surfaces legitimately disagree.
How to remediate.
- If
TaskQHeartbeatMissesis firing or the holders' workers are gone: the jobs self-heal: the reclaim sweep transitions them (retryable →pendingafter a backoff; exhausted →crashed). The alert's value is that it stays non-zero when that does NOT happen. - If the reclaim sweep is stalled or timing out: follow TaskQPromotionStalled and TaskQSweepTimeouts; fix the sweep/database; the zombies are a symptom.
- If the sweep is healthy and the count still regrows: jobs are repeatedly outliving their lease: the lease (
lock_lease-shaped settings) is shorter than the actor's real run time and heartbeats are not renewing fast enough. CheckTaskQLockExpiringSoon(it reads the measured lease left at each renewal:lock_leaseminus the gap since the previous one, so firing means renewals are landing late, from a slow heartbeat pool, failed ticks or a blocked event loop;taskq_worker_event_loop_lag_secondsandtaskq_heartbeat_misses_totalsay which) and widen the lease/heartbeat budget for those actors; do not restart workers to "clear" the gauge: the same jobs will zombie again. - Confirm recovery:
taskq_jobs_running_lease_expiredreturns to 0 and stays there across several sweep intervals.
TaskQFailedJobRateHigh¶
What fired. sum(rate(messaging_client_consumed_messages_total{outcome="failed"}[5m])) / sum(rate(messaging_client_consumed_messages_total[5m])) > 0.01 for 5 minutes: more than 1% of consumed attempts ended in a terminal failure: a non-retryable exception class, or the retry budget (max_attempts / schedule_to_close) exhausted. Retried failures are not in this share: they end as outcome="scheduled" and are counted by TaskQRetryRateHigh.
How to confirm.
- Metric:
messaging_client_consumed_messages_total{outcome="failed"}byactor, andtaskq_jobs_attempt_failures_total{retryable="false"}byactor, error_type; the second names the exception class per actor. - Database, the terminal rows and their reason:
SELECT actor, error_class, count(*)
FROM taskq.jobs
WHERE status = 'failed' AND finished_at > clock_timestamp() - interval '15 minutes'
GROUP BY actor, error_class ORDER BY count(*) DESC;
error_class = 'DeadlineExceeded' is a schedule_to_close budget too tight for the actor's retry curve (taskq_jobs_timeouts_total{kind="schedule_to_close"} rises with it); a PayloadValidationError is a producer shipping a payload the actor's schema rejects; anything else is the actor's own exception.
How to remediate.
- One actor, one
error_class: fix that actor or its dependency; failed rows can be retried withtaskq job retry, the embedded admin UI's Retry button, orbackend.retry_job()once the cause is fixed (the standalonetaskq ui serve's Retry button answers 503 — no Backend there; see admin-ui.md). DeadlineExceededdominating: widenschedule_to_close(or the retrybase/cap) for the actor; see ops.md: Timeouts.- Many actors at once: a shared dependency (database, downstream API); check
TaskQRetryRateHighandTaskQDispatchLatencyHighfirst; the terminal share is the tail end of the same incident once budgets run out.
TaskQRetryRateHigh¶
What fired. sum(rate(taskq_jobs_attempt_failures_total{retryable="true"}[5m])) / sum(rate(messaging_client_consumed_messages_total[5m])) > 0.1 for 10 minutes: more than 10% of consumed attempts raised and were rescheduled for another try. Each retry burns an attempt, a backoff delay and a worker slot, so a sustained rate is a dependency failing under retry cover: the jobs still complete, the terminal-failed share stays flat, and nothing else fires until budgets run out.
How to confirm.
- Metric:
taskq_jobs_attempt_failures_total{retryable="true"}byactor, error_type; the exception class per actor is the diagnosis (ConnectionError/TimeoutErroron one actor is its downstream;asyncpgclasses across actors is the database). - Database, jobs currently waiting on a retry and what they last raised:
SELECT actor, error_class, count(*), min(scheduled_at) AS next_try
FROM taskq.jobs
WHERE status = 'scheduled' AND attempt > 1
GROUP BY actor, error_class ORDER BY count(*) DESC;
How to remediate.
- Fix or wait out the dependency the
error_typenames; retries recover on their own once it answers again. Do not raisemax_attemptsto "make it go away": that spends more slots on the same failure. - If the rate is one actor with a transient class that is really permanent (a bad payload that will never succeed), classify it:
non_retryable_exceptionson the actor, or aretry_classifier; see ops.md: Classifying failures. - Confirm recovery: the retried share falls back under the threshold and
taskq_jobs_by_status{status="scheduled"}drains.
TaskQAbandonedJobs¶
What fired. rate(taskq_jobs_abandoned_total[5m]) > 0 for 5 minutes: a job was abandoned: an operator-requested cancel outlasted both grace periods (the actor was asked to stop, then forced with task.cancel(), and still never exited), so the running attempt was taken from it. Shutdowns never produce this: a deploy releases (interrupts) in-flight jobs back to the fleet. A retry or snooze never reaches this series either; those are outcome="scheduled" on the consumed-messages counter, so any rate here is a real actor ignoring cancellation.
How to confirm.
- Metric:
taskq_jobs_abandoned_totalbyactor; recorded by the abandon write itself, on both backends. - Database, the abandoned rows and the attempts that were taken away:
SELECT j.id, j.actor, j.finished_at, a.worker_id, a.duration_ms
FROM taskq.jobs j
JOIN taskq.job_attempts a ON a.job_id = j.id AND a.attempt = j.attempt
WHERE j.status = 'abandoned' AND j.error_class = 'CancelAbandoned'
ORDER BY j.finished_at DESC LIMIT 50;
How to remediate.
- The named actor does not yield to cancellation: it is blocking the event loop (a sync call without
asyncio.to_thread), swallowingCancelledError, or running a native call that cannot be interrupted. Make it cooperative: pollctx.cancellation_requested(or awaitctx.cancel_event) in long loops, keep blocking work off the loop; see ops.md: Thread-unsafe native libraries. - The abandoned row is terminal; the actor's coroutine may still be running in the worker until the process restarts. If it holds resources, restart that worker (
taskq_active_jobson its health socket shows the stuck slot). - If the graces are too short for a well-behaved actor's cleanup, widen
TASKQ_CANCELLATION_GRACE_PERIOD/TASKQ_CLEANUP_GRACE_PERIOD, but only after (1) is ruled out.
TaskQHeartbeatMisses¶
What fired. rate(taskq_heartbeat_misses_total[5m]) > 0 for 5 minutes, severity critical: a worker's heartbeat renewal ticks are failing. The counter is bumped once per failed beat in the heartbeat loop, on every transient Postgres error class (connection resets, statement timeouts, server-side cancels). At TASKQ_MAX_HEARTBEAT_FAILURES (default 3) consecutive failures the worker does not limp on: it self-isolates, handing its running jobs back to the fleet (retryable rows re-pended, exhausted rows crashed) and terminating, so a sustained miss escalates from lost renewals to lost capacity within a few beats.
How to confirm.
- Metric:
rate(taskq_heartbeat_misses_total[5m]). A blip that decays within a beat or two is one transient error; a flat sustained rate is a worker that cannot reach Postgres. The counter carries no worker label, so read it beside the logs of the replica that is reporting it. - Logs:
heartbeat-tick-failureWARNING lines witherror_classnaming the Postgres failure, thenheartbeat-failures-approaching-limitat half the budget, then the self-isolation. At the defaults the whole escalation fits in about 30 seconds (3 beats x 10 s). - SQL: which workers the database still considers alive:
SELECT id, hostname, pid, worker_label, last_seen_at,
now() - last_seen_at AS silent_for
FROM taskq.workers
ORDER BY last_seen_at DESC;
A row going silent for more than a beat while its process is up is the confirmation.
How to remediate.
- Read
error_classon theheartbeat-tick-failurelines. Connection-refused classes are network or DNS between the pod and Postgres; server-shutdown classes are the database restarting under the fleet; timeout classes are latency, and TaskQDispatchLatencyHigh or TaskQSweepTimeouts firing alongside points at a shared database incident. - A worker that crossed the budget has already isolated: it exits, the orchestrator restarts it, and its re-pended jobs dispatch elsewhere. The capacity dip is the design working; do not pin a whole fleet's queue set on one replica.
- If misses cluster at deploys or failovers, check the lease arithmetic the settings loader enforces:
lock_leasemust covermax(heartbeat_interval, heartbeat_command_timeout) + (max_heartbeat_failures + 1) * (heartbeat_interval + heartbeat_command_timeout)(58 s at the defaults). A lease tighter than that turns a beat hiccup into TaskQRunningLeaseExpired. - Confirm recovery: the rate returns to 0 and the affected
taskq.workersrow'slast_seen_atis fresh again.
Do not raise TASKQ_MAX_HEARTBEAT_FAILURES to quiet this alert: the budget is the worker's deadline for handing its jobs back while their leases are still sane, and widening it widens the window in which a dead worker's jobs look running.
TaskQLockExpiringSoon¶
What fired. histogram_quantile(0.99, rate(taskq_lock_expires_in_seconds_bucket[5m])) < 30 for 5 minutes: the lease left on job locks at the moment the heartbeat renewed them has a p99 below 30 s. The histogram records, on every successful beat, lock_lease minus the measured gap since the previous beat's UPDATE, both stamps taken at the same point so network latency cancels; a sample near 0 means the beat landed just before the leases it renewed expired. The reclaim sweep takes a job the moment the gap reaches the lease, so this alert is the leading edge of TaskQRunningLeaseExpired: renewals are landing late, and the budget is being spent.
How to confirm.
- Metric: the p99 of
taskq_lock_expires_in_secondsagainst your configuredTASKQ_LOCK_LEASE(default 60 s): a p99 of 30 s means half the lease is gone to lateness. Readtaskq_heartbeat_misses_total(failed ticks) andtaskq_worker_event_loop_lag_seconds(a blocked loop) beside it; those two say WHICH kind of lateness this is. - First check your cadence before debugging lateness. The threshold (30 s) sits exactly on the histogram's
le="30"bucket edge, and a sample is quantized into that bucket: a HEALTHY configuration whose steady-state remaining lease —lock_lease − heartbeat_interval— lands in (20, 30] seconds serves a p99 of ~29.9 s and this alert pages forever on a fleet with nothing wrong. Example:TASKQ_HEARTBEAT_INTERVAL=5withTASKQ_LOCK_LEASE=33(the cascade floor for that cadence) reads ~28 s at every beat. If your cadence's lease-minus-interval falls in that window, override the threshold in your own rule configuration to below your cadence's steady-state value (ap99 < lock_lease − heartbeat_interval − marginshape); the shipped default is tuned for the default cadence (60 s lease − 10 s interval = ~50 s, which quantiles to ~59.9 s — safely above the line). - The sample is stamped on every successful beat whether or not the renewal threshold renewed any rows, so the histogram measures the beat cadence itself: a late beat lowers it exactly as a failed one does.
- SQL: what the fleet's running locks have left:
SELECT id, actor, locked_by_worker, lock_expires_at,
extract(epoch FROM (lock_expires_at - clock_timestamp()))::int AS remaining_s
FROM taskq.jobs
WHERE status = 'running'
ORDER BY lock_expires_at
LIMIT 20;
How to remediate.
taskq_heartbeat_misses_totalrising with it: the heartbeat pool cannot answer in time. Follow TaskQHeartbeatMisses (connectivity first, then the heartbeat pool'sTASKQ_HEARTBEAT_POOL_SIZEandTASKQ_HEARTBEAT_COMMAND_TIMEOUT).- Heartbeats clean but
taskq_worker_event_loop_lag_secondshigh: the beats are late because the loop is blocked. Find the blocking actor (the worker'sevent-loop-stall-attributedwarnings name it and the admin UI's Stall hotspots column aggregates it); this is an actor defect, not a lease misconfiguration. - Neither, and Postgres latency is the story: widen the budget through the enforced arithmetic (
lock_lease >= max(heartbeat_interval, heartbeat_command_timeout) + (max_heartbeat_failures + 1) * (heartbeat_interval + heartbeat_command_timeout)); a settings load that fails the invariant is the loader keeping the lease ahead of the isolation cascade, not a bug. - Confirm recovery: the p99 climbs back above the threshold, toward the full lease.
Do not fix this by raising TASKQ_LOCK_LEASE alone when the cause is a blocked event loop: a longer lease moves the reclaim deadline out over the same wedged beat, and the actor stays wedged twice as long before anything notices.
TaskQQueueUnserved¶
What fired. taskq_queue_depth{queue!="_other_"} > 0 unless on(queue) taskq_queue_live_workers > 0 for 2 minutes: a queue holds pending or scheduled jobs and no live worker subscribes to it: no worker row whose last_seen_at is inside TASKQ_ADMIN_WORKER_LIVENESS_SECONDS (default 30 s, three heartbeats) lists the queue in its queues. Nothing will consume the work. Both gauges come from the same leader sampler tick, so the join never compares two moments; a worker row that stopped heartbeating does not count even before the stale-worker sweep removes it.
How to confirm.
- Metric:
taskq_queue_depth{queue="<q>"}besidetaskq_queue_live_workers{queue="<q>"}(absent, or 0). - Database: who last served the queue and when:
SELECT id, hostname, pid, worker_label, last_seen_at,
last_seen_at > clock_timestamp() - interval '30 seconds' AS live
FROM taskq.workers
WHERE '<q>' = ANY(queues)
ORDER BY last_seen_at DESC;
No rows: nothing ever subscribed (a producer enqueues onto a queue name nobody runs, or the queue was dropped from every TASKQ_QUEUES at the last deploy). Rows, none live: the replicas serving it are down or partitioned from Postgres; check their /ready and TaskQHeartbeatMisses.
- The admin UI's queues page shows the same condition as the "pending jobs but no alive worker" banner.
How to remediate.
- Start (or scale up) a worker whose
TASKQ_QUEUESincludes the queue, or add the queue to an existing worker's subscription. The backlog drains on its own once a live worker subscribes; nothing was lost. - If the queue name is a producer mistake (a typo, a stale config), re-route the jobs:
taskq actor-config move-queuefor a whole actor, or re-enqueue. The stranded-jobs detector names the actors involved (taskq_jobs_stranded{reason="unserved_queue"}). - Confirm recovery:
taskq_queue_live_workers{queue="<q>"} > 0and the depth falling.
TaskQStrandedJobs¶
What fired. taskq_jobs_stranded > 0 for 5 minutes: pending/scheduled jobs that can never be dispatched, with the reason on the label. reason="no_actor_config": the actor has no actor_config row (deregistered with taskq actor-config deregister, or never registered by any worker), so the dispatch CTE, which derives its candidates from actor_config, never sees the rows. reason="unserved_queue": the queue dispatch routes the actor on (the actor's current assignment for a re-pended row, the row's own queue otherwise) has no live worker subscribed. Neither dispatch nor the deadline sweep will ever touch these rows.
How to confirm.
- Metric:
taskq_jobs_strandedbyactor, reason; sampled by the leader everyTASKQ_STRANDED_JOBS_INTERVAL; thestranded-jobs-no-actor-config/stranded-jobs-unserved-queuelog events carry the same counts, and the second names the queues. - Database:
-- no_actor_config
SELECT j.actor, count(*) FROM taskq.jobs j
WHERE j.status IN ('pending', 'scheduled')
AND NOT EXISTS (SELECT 1 FROM taskq.actor_config ac WHERE ac.actor = j.actor)
GROUP BY j.actor;
How to remediate.
no_actor_config: run a worker that registers the actor (registration writes the row at boot), or, if the actor is gone for good, cancel the rows (JobsClient.cancel_where(JobFilter(actor=...)),taskq job cancel-where, or the embedded admin UI's cancel action) so they stop counting.unserved_queue: follow TaskQQueueUnserved: subscribe a live worker to the named queue, or move the actor's assignment withtaskq actor-config move-queue.- Confirm recovery: the series clears on the next detector tick (an empty reading is published, not a frozen last value) and the
stranded-jobs-clearedevent logs.
Event-loop stall attribution (worker warnings)¶
What fires. No alert: the worker's lag watchdog warns on its own. A warning named event-loop-stall-attributed logs once per stall at the warn tier and again at the terminal trip tier, alongside the existing worker-watchdog-lag-warn and worker-watchdog-trip events. Every attribution also bumps taskq_worker_loop_stall_attributions_total{actor, kind}, and the heartbeat merges the worker's rolling tally (top 20 actors by count, with per-kind counts) into its workers row metadata, so the hotspots are visible fleet-wide in /admin/workers and taskq doctor without scraping each worker.
What it means. The event loop could not schedule (a beat was late past the warn budget), and the watchdog attributes the stall to the actor whose frame sat under the work holding the interpreter. The kind label separates the two shapes:
blocking_call: the actor's synchronous call RELEASED the GIL (time.sleep, a socket or HTTP wait, a subprocess). The sampling is exact: the watchdog thread kept running and caught the blocking frame.gil_held: the synchronous work HELD the GIL (a C extension that does not release it, such as a large document parse, or a hot pure-Python loop). The watchdog's own wakeups starved; the sample is approximate and points at (or just after) the C call.
The warning's frame field is file:line:function of the deepest non-taskq frame, and actor/job_id name the registered actor and (when exactly one running job matched) the job.
How to remediate.
blocking_call: move the blocking call off the event loop (asyncio.to_thread/run_in_executor) or make the actor async.gil_held: the actor holds the GIL in a long synchronous computation: chunk it or move it off the loop.- Confirm recovery: the per-actor rate of
taskq_worker_loop_stall_attributions_totalflattens, and the worker's Stall hotspots column in/admin/workersstops growing.
Related documentation¶
- Observability: the metrics these alerts evaluate, including the sweep sample-population rule.
- Configuration:
TASKQ_EVENT_WRITER_*,TASKQ_SWEEP_*andTASKQ_CRON_TICK_LIMITknobs referenced above. - Troubleshooting: symptom-first diagnosis paths.