The async job queue count is large and growing. This plan reorganizes queues for better SLA isolation, adds durability, and enables horizontal scaling.
9 active queues, 11 worker processes. Core problems:
| Queue | Job types | Problem |
|---|---|---|
integration |
16 types | Mega-monolith — AI calls starve incoming messages |
chat |
7 types | Mixes realtime typing pings with bulk template blasts |
schedule |
7 types | Cron polling mixed with broadcast pipeline |
default |
3 types | CSV exports block audit log writes |
Global defaults causing pain:
concurrency: 5for everything excepttrigger(100) — IO-bound outbound queues are severely throttledattempts: 2universally — too few for transient HTTP failuresremoveOnFail: { count: 5000 }with no DLQ — jobs are silently lost
| Risk | Severity |
|---|---|
integration head-of-line blocking — one AI call delays real user messages |
HIGH |
| No WhatsApp/Messenger rate limiting — broadcast bursts can trigger provider suspension | HIGH |
| Silent job loss — no DLQ, audit log loss is a compliance risk | HIGH |
| No observability — first sign of backlog is user complaints | HIGH |
Engineers keep adding to integration — split cost grows monthly |
MEDIUM |
Every queue must be assigned a tier. Each tier defines concurrency, retry, and SLA defaults.
| Tier | SLA | Concurrency template | Retry policy |
|---|---|---|---|
realtime |
< 1 s | 50–200 | 3 attempts, backoff: 500 ms / 2 s / 8 s |
interactive |
< 5 s | 20–50 | 3 attempts, backoff: 2 s / 10 s / 30 s |
bulk |
minutes | 10 + limiter |
5 attempts, backoff: 10 s / 60 s / 300 s |
background |
hours | 2–5 | 3 attempts, slow backoff |
cron |
minutes | 1 (singleton) | 1 attempt |
ai |
seconds–minutes | per-provider limiter | 2 attempts, no retry on quota errors |
Goal: Stop flying blind, stop losing jobs. No queue splits.
Files to create/change:
apps/builder/src/app/(dashboard)/admin/queues/route.ts— Bull Board admin-only routeapps/worker/src/lib/metrics.ts— Prometheus exporter (queue depth, latency p50/p95, fail rate)packages/worker-config/src/queues/dlq/index.ts— new dead-letter queuepackages/worker-config/src/lib/worker-runner.ts— sharedwithWorker(name, handlers, tier)helperapps/worker/src/*/worker.ts— migrate each to usewithWorker; DLQ push on final failure
Alerts to wire:
- DLQ depth > 0
- Per-tier queue depth thresholds exceeded for > T minutes
- Processing latency p95 > tier SLA
Risk: LOW — pure additive instrumentation, no behavior change.
Goal: Stop the most obvious head-of-line blocking with config changes only. No queue splits.
Changes:
- Remove global
defaultWorkerOptions.concurrency = 5inpackages/worker-config/src/lib/connection.ts. Force each worker to declare its own. - Set per-queue concurrency:
| Queue | Concurrency | Notes |
|---|---|---|
inbound-events (future) |
100 | IO-bound, light DB writes |
flow-execution (future) |
30 | Mixed DB + provider calls |
bot-response (future) |
10 + limiter | LLM latency, per-provider RPM |
chat (current) |
50 | Raise from 5 until split in Phase 3 |
integration (current) |
30 | Raise from 5 until split in Phase 3 |
aiAgent |
3 | CPU/IO heavy, embedding API rate limits |
webhook |
20 + limiter | Per-target-host fairness |
trigger |
50 | Down from 100; verify with metrics |
analytics |
5 | Batch jobs, mostly serial |
exports (future) |
2 | Long-running, memory-heavy |
audit-log (future) |
10 | Must not lose, cheap writes |
cron (future) |
1 | Singleton-style |
- Add per-job-type retry policy map in each queue file under
packages/worker-config/src/queues/*/index.ts. - Add BullMQ
limitertochat(WA template jobs) andwebhookworkers. - Add
priorityto producers:priority: 1forincomingMessage,runFlowPostback,runFlowQuickReply;priority: 10for broadcast-originated jobs.
Risk: MEDIUM — concurrency changes affect DB connection pool sizing. Roll behind staged deploys and monitor Phase 1 dashboards first.
Goal: Structural fix for head-of-line blocking and noisy-neighbor.
New queues under packages/worker-config/src/queues/:
| New queue | Job types from integration |
Tier |
|---|---|---|
inbound-events |
incomingMessage, messageStatus, contactMarkAsRead, agentMarkAsRead |
realtime |
flow-execution |
sendFlow, runFlowPostback, runFlowQuickReply, runRef, runChallenge, sendSequenceFlow, createMessage |
interactive |
bot-response |
processAutomatedResponse |
ai |
contact-admin |
blockContact, unblockContact, assignConversation |
background |
| New queue | Job types from chat |
Tier |
|---|---|---|
chat-outbound-realtime |
sendChannelMessage, sendFlowMessage, sendChatMessage, sendTyping |
realtime |
chat-outbound-bulk |
sendWhatsappTemplateMessage |
bulk (rate-limited) |
realtime-broadcast |
broadcastEvent |
realtime |
chat-notifications |
notifyExportResult |
background |
Steps:
- Add new queue files and update
queueNamesenum inpackages/worker-config/src/lib/types.ts(mark old as deprecated, do not remove yet). - Update producers at all 28 call sites. Use a thin routing shim during cutover so old action enums resolve to the new queue.
- Add new worker processes under
apps/worker/src/for each new queue. - Update
apps/worker/package.jsonscripts. - Run old and new queues in parallel until old queues drain, then decommission.
Risk: MEDIUM–HIGH — many producer call sites. Mitigate with compatibility shim and keeping old workers alive for one release window.
Goal: Prevent cron jobs and audit writes from being held behind unrelated work.
New queues:
cron←evaluateTriggers,cleanupTriggers,scanSmartDelay,finalizeBroadcasts,enqueueBroadcastbroadcast-pipeline←prepareBroadcast,sendBroadcastexports←exportContactsaudit-log←sendAuditLogerror-log←sendErrorLog
Introduce Redis-lock leader election for scheduler bootstrap (apps/worker/src/cron/leader.ts) so upsertJobScheduler only fires from one replica.
Risk: LOW–MEDIUM — fewer producers than Phase 3.
Goal: Prevent one workspace from monopolizing capacity.
- Per-workspace rate-limit keys in Redis for
chat-outbound-bulkandbot-response. - Per-AI-provider token buckets (
OpenAI,Anthropic,Google,DeepSeek) keyed by API key with RPM/TPM caps. - Per-target-host rate limiter for
webhook. - Hash-partitioned consumers for busiest queues, modeled on existing
SCHEDULER_BUCKET_RANGEpattern inapps/worker/src/sequence-scheduler/worker-producer.ts.
Risk: MEDIUM — rate limits easy to mistune. Roll out per-provider one at a time.
- Remove dead
broadcastenum value or wire it up (packages/worker-config/src/lib/types.ts:13). - Decide on the
createProducer/createConsumerfactory inpackages/worker-config/src/message-queue/factory.ts: promote as canonical or delete it (see note below on Kafka migration path). - Eliminate the two diverging concurrency defaults (5 vs 100).
- Write
packages/worker-config/README.mddocumenting every queue's tier, SLA, and retry policy.
Risk: LOW — structural/cosmetic, no behavior change.
The codebase already has a dormant abstraction in packages/worker-config/src/message-queue/:
factory.ts—createProducer(config)/createConsumer(config)with aproviderswitchbullmq-provider.ts— the only implemented provider todaytypes.ts—MessageQueueConfigwithpartitions,replicationFactorfields (Kafka-style)
This abstraction is currently used only by sequenceScheduler. To make the migration path viable for all queues:
- Phase 6 decision point: If Kafka is on the 12-month roadmap, promote
createProducer/createConsumeras the canonical interface. Every queue goes through it. BullMQ stays the default provider; a Kafka provider can be added behind theproviderswitch. - What the factory already supports:
partitions,replicationFactor, consumer groups, provider switching via env flag. The missing piece is a Kafka implementation ofMessageQueueProvider. - Practical migration: With the factory as canonical, swapping BullMQ → Kafka at queue level is a config change (
provider: 'kafka'), not a code rewrite. You can migrate queue by queue. - If Kafka is not planned: Delete the factory. Less indirection, simpler debugging. Direct BullMQ usage everywhere is fine indefinitely for Redis-backed workloads at this scale.
- Alternatives: If you want Kafka-like semantics (log compaction, replay, consumer groups) without the operational overhead, consider Redpanda (Kafka-compatible, single binary) or NATS JetStream (simpler, Redis-style ops, built-in replay). Both can slot into the factory abstraction with a new provider implementation.
Recommendation: Keep Redis/BullMQ for now (Phases 1–5 deliver major scaling wins without changing the broker). In Phase 6, decide whether to invest in the factory abstraction based on whether multi-broker support is actually needed by then.
- No queue handles more than 5 unrelated job types
- Every outbound-to-third-party queue has a rate limiter
- Every queue has a DLQ;
removeOnFailis not the only retention mechanism - Bull Board live with Prometheus metrics exported
- Alerts fire on DLQ non-empty and per-tier queue-depth thresholds
- Every queue's tier, SLA, and retry policy documented in
packages/worker-config/README.md - Dead
broadcastenum resolved - One canonical producer/consumer pattern (factory promoted or deleted)