Skip to content

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

PieceWhereResponsibility
Shared logicpackages/campaignsSegment rule validation and compilation to SQL, templates, keyword matching, consent rules, contact upserts, CSV parsing, campaign state machine, settings and plan checks
Keyword handlingapps/whatsapp-worker/src/message-events.tsSTOP/START in the inbound message transaction; last_inbound_at
APIapps/api/src/modules/contacts, apps/api/src/modules/campaignsContacts, tags, imports, segments (preview), campaigns (validation, launch, pause, resume, cancel), settings
Workerapps/campaign-workerStart due campaigns, audience snapshots, release, stats and completion, cancel cleanup, CSV imports
Status mirrorTrigger messages_campaign_recipient_sync (migration 0005)Copies message status onto the recipient row, forward only
Dashboardapps/web/app/app/[orgId]/p/[projectId]/contacts, …/campaignsContacts, 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.

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

FailureEffect
Worker crash during a snapshotLease expires after 60 s; another replica continues from the cursor (inserts are idempotent)
Worker crash during a releaseThe transaction rolls back; nothing was queued
Worker crash during an importLease expires after 120 s; the import resumes after the last committed batch
Redis downNudges are lost; loops poll every second and the release loop sleeps at most 5 s
Number disconnectedReleases on that number are deferred 15 s at a time; after 15 minutes the campaign pauses
Message expires or fails in the send laneThe trigger marks the recipient failed (code in failureCode)