Campaigns architecture
The decision record is ADR-016. This page describes how the pieces fit together. For the product behaviour, see the campaigns guide.
Components
| Piece | Where | Responsibility |
|---|---|---|
| Shared logic | packages/campaigns | Segment rule validation and compilation to SQL, templates, keyword matching, consent rules, contact upserts, CSV parsing, campaign state machine, settings and plan checks |
| Keyword handling | apps/whatsapp-worker/src/message-events.ts | STOP/START in the inbound message transaction; last_inbound_at |
| API | apps/api/src/modules/contacts, apps/api/src/modules/campaigns | Contacts, tags, imports, segments (preview), campaigns (validation, launch, pause, resume, cancel), settings |
| Worker | apps/campaign-worker | Start due campaigns, audience snapshots, release, stats and completion, cancel cleanup, CSV imports |
| Status mirror | Trigger messages_campaign_recipient_sync (migration 0005) | Copies message status onto the recipient row, forward only |
| Dashboard | apps/web/app/app/[orgId]/p/[projectId]/contacts, …/campaigns | Contacts, imports, segment builder, campaign builder and live view, settings |
Data
contacts ──< contact_tags >── tags
│ └──< contact_consent_events contact_suppressions (project, sha256(project:phone))
│ └──< contact_import_contacts >── contact_imports ──< contact_import_errors
segments
campaigns ──< campaign_lanes (campaign × number) ──> campaign_instance_pacing (number: next_release_at)
└──< campaign_recipients (snapshot) ── message_id ··· messages (priority, campaign_id, campaign_recipient_id)
campaign_settings (project)
campaign_recipients.message_id and messages.campaign_recipient_id have no foreign keys: retention deletes messages
while campaign history is kept.
Lifecycle in the worker
launch (API) campaigns.status = scheduled, audience rule frozen, campaign-nudge
starter scheduled & due → running: snapshot_cutoff, consent_mode_snapshot, lanes, pacing rows
(number unusable or media gone → starts paused; plan or audience invalid → failed)
materializer lease (60 s) per campaign; chunks of 2,000 contacts by id > cursor and created_at <= cutoff,
compiled rule; INSERT … ON CONFLICT DO NOTHING (unique per contact and per JID); skip reasons
releaser (N loops) one transaction per message:
claim pacing row (next_release_at <= now, oldest first, FOR UPDATE SKIP LOCKED)
pick the least recently served campaign on the number (campaign row FOR NO KEY UPDATE SKIP LOCKED)
number state → auto-pause (logged out, restricted, deleted; disconnected > 15 min)
window: campaign messages QUEUED/PROCESSING on the number < CAMPAIGN_INSTANCE_WINDOW
plan, monthly quota (auto-pause), project backlog cap (defer), media (auto-pause)
next pending recipient FOR UPDATE SKIP LOCKED; contact FOR SHARE; consent re-check
render, insert message (priority 10) + event + message.queued + usage; thread if a
conversation exists; recipient → queued; pacing += delay
after commit: send-nudge
send lane (whatsapp-worker) claims QUEUED by (priority, created_at); status changes fire the trigger
rollup stats_due_at: GROUP BY status per campaign (5 s while active, then 60 s / 1 h for a week);
completion when snapshot done and nothing pending or queued; stranded recipients → pause
cancel cleanup pending → cancelled; QUEUED messages → FAILED/CANCELLED (+ events, message.failed)
imports lease (120 s); stream CSV from object storage; batches of 500 in one transaction each
(upserts, tags, consent, errors, counters, row cursor)
Every step claims work with SKIP LOCKED or a lease, so any number of campaign-worker replicas can run.
Locking order
The API (pause, resume, cancel) locks the campaign row, then its lanes. The releaser locks the pacing row, then the campaign row (skipping it if busy), then the lane, the recipient and the contact (shared). Inbound keyword handling locks the contact only. These orders never invert, so there is no deadlock between them.
Consent at release time
The release transaction reads the contact FOR SHARE. An opt-out holds the contact's row lock until it commits, so a
release either sees the opt-out or waits for it. The effective consent mode is the stricter of the one frozen at start
and the current project setting.
Failure behaviour
| Failure | Effect |
|---|---|
| Worker crash during a snapshot | Lease expires after 60 s; another replica continues from the cursor (inserts are idempotent) |
| Worker crash during a release | The transaction rolls back; nothing was queued |
| Worker crash during an import | Lease expires after 120 s; the import resumes after the last committed batch |
| Redis down | Nudges are lost; loops poll every second and the release loop sleeps at most 5 s |
| Number disconnected | Releases on that number are deferred 15 s at a time; after 15 minutes the campaign pauses |
| Message expires or fails in the send lane | The trigger marks the recipient failed (code in failureCode) |