GoodJob backlog and PostgreSQL saturation during worker autoscaling
Incident date: July 20-21, 2026
Environments: US production, with related GoodJob pressure in EU production
Status: Recovering; US backlog continues to drain under a temporary worker cap
Severity: High
Summary
US production experienced customer-facing timeouts followed by a long-lived GoodJob backlog after a burst of Intercom webhook work caused the shared Rails worker fleet to autoscale. Each added worker task also added database-heavy conversation-sync concurrency. PostgreSQL reached approximately 99% CPU, connection counts spiked, and many sync jobs contended on hot rows.
We reduced the worker fleet and disabled AutoscaleWorkersJob to contain the timeout incident. The pause did not expire and had no alert, so US remained at 2 workers for approximately 23 hours while queued work accumulated. When capacity returned, the backlog exposed additional database bottlenecks: per-event updates to organization_data_sources.last_synced_at, GoodJob candidate-selection queries, advisory-lock churn, and downstream fan-out into imports, completion jobs, translations, evaluations, and redaction handoffs.
The incident shows that GoodJob was a reasonable original choice for low-volume, transactionally enqueued Rails work, but it is no longer the right shared queue for Rulebase’s high-volume ingestion and fan-out paths. Queue coordination, application traffic, analytics, worker writes, cleanup, and autovacuum now compete for the same 4-vCPU PostgreSQL instances.
This incident is separate from the EU redaction discovery incident, although both incidents demonstrate the need for explicit database concurrency budgets.
Customer impact
- US API requests, including
/organization/statistics, returned 504 responses or exceeded PostgreSQL’s statement timeout. - Background work waited for as long as approximately 23 hours.
- Conversation imports, completion work, translations, evaluations, and redaction scheduling were delayed.
- Recovery traffic repeatedly pushed PostgreSQL to 97-99% CPU.
- EU also accumulated a smaller GoodJob backlog and reached approximately 99% database CPU while draining it.
No evidence indicates permanent customer-data loss. Some background jobs remain delayed while the queues drain.
Detection
The incident was first detected through customer-visible slowness and a 504 from /organization/statistics. Sentry showed a broader burst of PostgreSQL statement timeouts across Rails endpoints and jobs.
AWS and PostgreSQL inspection then showed:
- US PostgreSQL at approximately 99.4% CPU.
- Average database load near 382 active sessions on a 4-vCPU instance.
- Connections averaging approximately 589 and peaking at 1,156.
- 113 sessions waiting in GoodJob polling.
- 38 sessions contending while updating
employees.conversation_agents_count. - Only 3 sessions running the statistics sentiment aggregate, confirming that the endpoint was a victim rather than the initiating load.
Timeline
All times are Eastern Time.
- July 20, 12:53 PM: Customer-facing slowness and 504 responses were reported from
/organization/statistics. - Approximately 1:00 PM: AWS and PostgreSQL inspection showed that the US database was saturated by worker-driven load. The shared Rails worker fleet had scaled from 12 tasks toward its maximum of 30.
- Approximately 1:07 PM: The worker fleet was manually reduced to 2 tasks. The autoscaler immediately began restoring capacity because queue latency remained above its threshold.
- Approximately 1:12 PM:
AutoscaleWorkersJobwas disabled as an emergency containment action. - Approximately 5:34 PM: US had 107,579 runnable GoodJobs and only 2 worker tasks. EU had 111,812 runnable jobs, largely at priorities excluded from autoscaling.
- Overnight: The US autoscaler remained disabled without an expiry, heartbeat alert, or minimum-capacity fallback. The oldest queued work eventually reached approximately 23 hours.
- July 21, approximately 8:35 AM: US had 178,946 ready jobs. The fleet began scaling back up.
- During recovery: PostgreSQL returned to 98-99% CPU. A live snapshot showed 107 connections contending on
organization_data_sources.last_synced_atand another 56 contending in GoodJob candidate selection. - After deploying PR #8442 : The
last_synced_atrow-lock convoy disappeared. Net drain improved from approximately 225 to 650 jobs per minute before downstream fan-out and renewed worker growth reduced the net rate. - Later July 21: Autoscaling returned the fleet to 24 workers. PostgreSQL again reached approximately 98% CPU, with 94
BufferMappingwaiters and approximately 445 connections. Reducing the fleet to 10 removed the persistentBufferMappingwaiters and reduced connections to approximately 300 while the primary Intercom webhook backlog continued to decline.
Root cause
The incident had 2 root-cause layers: an unsafe scaling model caused the initial database saturation, and an unguarded containment pause caused the long-lived backlog.
Shared autoscaling multiplied database-heavy concurrency
An Intercom webhook burst placed large numbers of ForwardIntercomWebhookJob records on the default queue. Normal volume was approximately 200-270 jobs per minute, then increased to 824, 1,167, 893, and 970 jobs across 4 consecutive minutes.
AutoscaleWorkersJob watches queue latency for default and latency_30s. It responded correctly to the latency signal by adding 2 worker tasks every 2 minutes. However, each worker task consumes all configured GoodJob pools:
sync_conversation: 4 threads
latency_30s: 4 threads
other queues: 4 threadsScaling toward 30 tasks therefore allowed up to 120 concurrent sync_conversation jobs even though conversation-sync latency was not the scaling signal. The additional workers increased PostgreSQL polling, connections, row updates, and lock contention faster than they increased useful throughput.
Emergency containment persisted without safeguards
Reducing worker capacity and disabling autoscaling was the correct immediate response to customer-facing timeouts. The containment became a separate failure because it had:
- no automatic expiry;
- no alert that the autoscaler heartbeat was missing;
- no queue-age or backlog-growth alert;
- no minimum-capacity fallback;
- no recovery checklist requiring the pause to be revisited.
The worker fleet therefore remained at 2 tasks for approximately 23 hours while new work continued arriving.
Recovery amplifiers
Several conditions made recovery slower after worker capacity returned:
- Hot parent-row updates: Every provider webhook event updated
organization_data_sources.last_synced_at, causing many transactions to serialize on a small number of rows. PR #8442 now coalesces those updates to at most once per minute while preserving every connection log. - GoodJob acquisition pressure: Every worker thread selects, locks, and updates jobs through PostgreSQL. Large backlogs caused many concurrent candidate-selection and advisory-lock queries.
- Downstream fan-out: Processing one webhook can create imports, completion jobs, translations, evaluations, redaction handoffs, and other follow-up work. Gross completions were much higher than the visible net decline in ready jobs.
- Shared database: Interactive API queries, analytics, queue coordination, job writes, cleanup, and autovacuum all used the same PostgreSQL instance.
- Priority mismatch: Important large backlogs at priorities 25 and 50 did not contribute to the autoscaler’s priority-10 latency signal.
- Preserved queue history: High GoodJob record churn increased table and index maintenance and required sustained autovacuum work.
Architectural assessment of GoodJob
GoodJob was not inherently a bad decision. It provided a low-operations, transactionally consistent job system when Rulebase had lower volume, fewer workers, shorter Rails-local jobs, and more PostgreSQL headroom.
The current use is a bad architectural fit for high-volume ingestion and fan-out. GoodJob places the queue control plane on the same database as the application data plane:
GoodJob
queue polling + locking + retries + scheduling + history -> PostgreSQL
business reads and writes -> PostgreSQL
BullMQ
queue polling + locking + retries + scheduling -> Redis
business reads and writes -> PostgreSQLThis difference explains why BullMQ has not shown the same queue-acquisition failure mode at comparable job counts. Redis absorbs rapid queue-state mutation without competing with customer queries or PostgreSQL autovacuum.
BullMQ does not protect PostgreSQL from an inefficient processor. The separate EU redaction incident ran through BullMQ and still saturated PostgreSQL with an unbounded discovery query. A queue migration must therefore include hard global concurrency limits, efficient bounded queries, and database-aware backpressure.
What went well
- AWS Performance Insights and PostgreSQL activity identified the active wait classes and statements.
- Manually reducing workers protected customer-facing traffic during the first incident.
- Removing the unused employee counter-cache writes eliminated the initial hot-row update path.
- Coalescing
last_synced_atupdates removed the main recovery lock convoy. - Reducing the recovery fleet from 24 to 10 immediately reduced database connections and persistent
BufferMappingcontention. - The primary Intercom webhook backlog continued to decline even when total queue depth was obscured by downstream fan-out.
What did not go well
- Autoscaling treated every Rails worker task as interchangeable even though each task multiplied database-heavy sync concurrency.
- The autoscaler used queue latency without considering database CPU, connections, active sessions, or lock waits.
- The manual pause had no expiry, alert, or minimum-capacity fallback.
- We lacked queue-age and per-job-class growth alerts.
- GoodJob queue history and polling competed directly with customer-facing PostgreSQL work.
- Total queue depth obscured progress because upstream processing generated large downstream queues.
- Recovery initially focused on adding workers rather than maximizing useful throughput per database connection.
Resolution and recovery
The current recovery controls are:
- US Rails workers are capped at 10 while the backlog drains.
- The unused
employees.conversation_agents_countmaintenance path has been removed. - Webhook event logging updates
organization_data_sources.last_synced_atat most once per minute per data source. - Queue depth, dominant job classes, database load, connections, and wait events are being checked with read-only production queries.
The incident remains in recovery until the oldest incident backlog has cleared and both regions sustain normal database and request latency for at least 30 minutes.
Corrective actions
Highest priority
- Split
sync_conversationinto a dedicated worker service with a hard regional concurrency budget that shared worker autoscaling cannot change. - Stop general workers from consuming
sync_conversation. - Add database-aware scaling guards. Do not increase worker concurrency when PostgreSQL CPU, connections, active sessions, or lock waits exceed defined thresholds.
- Add an expiring autoscaler pause with a visible owner, reason, expiry time, and automatic alert before expiry.
- Add a minimum-capacity fallback when the autoscaler heartbeat is missing.
Queue architecture
- Move high-volume webhooks, provider sync, imports, translations, evaluations, redaction, and similar fan-out pipelines to dedicated BullMQ queues.
- Keep GoodJob for low-volume transactional Rails work, cron, and lightweight relay or outbox delivery.
- Use deterministic BullMQ job IDs and idempotent processors.
- Use a Rails outbox or retryable relay when a database commit and BullMQ enqueue must not diverge.
- Configure global concurrency per database-heavy BullMQ queue rather than relying only on per-process concurrency.
- Reduce GoodJob record retention and verify cleanup and autovacuum behavior under production-shaped churn.
Operational safeguards
- Alert on oldest runnable job age, total ready jobs, and growth by job class in each region.
- Alert when
AutoscaleWorkersJobis disabled or has not completed successfully within its expected interval. - Add dashboards for useful throughput: arrivals, gross completions, net ready-job change, and downstream fan-out.
- Define a recovery runbook that specifies worker caps, database thresholds, nonessential queues to pause, and safe verification queries.
- Separate maintenance jobs such as QA eligibility sweeps, counter reconciliation, risk calculations, and backfills into a concurrency-1 service.
Lessons
Queue latency is not a sufficient autoscaling signal for database-heavy workers. The system must scale each workload independently and enforce a database concurrency budget across all worker processes.
GoodJob remains useful where transactional enqueueing and Rails locality matter. It should not remain the shared ingestion backbone now that queue coordination itself materially competes with customer traffic. BullMQ removes that queue-control-plane pressure from PostgreSQL, but every processor still needs bounded database work.
Emergency controls are part of the production system. A pause without an expiry, alert, owner, and recovery path can turn a successful mitigation into the next incident.