Queues & Background Processing¶
Most Prism features are initiated by an HTTP request but completed asynchronously. This section documents the queueing model that does the "completed asynchronously" half. If you have not yet read the Request Lifecycle, start there — it frames the sync/async boundary this section drills into.
Validation legend¶
| Symbol | Meaning |
|---|---|
| ✅ | Verified against source in this review |
| 📄 | Documented in the architecture map (map-04-crons-queues) |
| 💻 | Behaviour confirmed by reading the implementation file |
| ♻️ | Legacy / superseded but still running |
| ⚰️ | Dead / commented-out code |
| ⚠️ | Tech-debt or operational risk — see Technical Debt |
1. The core idea: queues are collections, crons are the broker¶
Prism has no external message broker in the monolith. There is no RabbitMQ, no SQS, no Redis Streams, and no Kafka inside uengage-crm. ⚠️ (Kafka exists only in the separate prism-services estate; the monolith documented here does not use it.)
Instead, the pattern is uniform:
- A producer (an HTTP controller, another cron, or a helper in
commonFunctions/) inserts a row/document into a "queue" — which is just an ordinary MongoDB collection or MySQL table — with a status field marking it not yet processed. - A consumer is an in-process
node-cronjob (node-cron ^3.0.0) that wakes on a fixed cadence, polls the queue for unprocessed rows, drains a batch, does the work, and flips the status. 💻
flowchart LR
P["Producer<br/>(controller / helper / cron)"] -->|insert status=pending| Q[("Queue collection/table<br/>Mongo or MySQL")]
C["node-cron consumer<br/>(polls on a schedule)"] -->|SELECT/find pending| Q
C -->|do work| EXT["SMS / WhatsApp / Firebase /<br/>Wallet / Milestone"]
C -->|UPDATE status=done/failed| Q
Every cron require()s itself at startup from index.js and self-registers via cron.schedule(...). There is one process; the crons share it. Crons use the MySQL jobsPool (not the web pool) so background work does not starve web requests — see Caching / Pooling.
Why this matters operationally: when "nothing happened," the answer is almost always a stuck row in a queue collection or a stuck cron. See §6.
2. Generic queue state machine¶
All Prism queues implement the same lifecycle, but ⚠️ the field names and values are inconsistent across queues (see the table in §3). The abstract machine:
stateDiagram-v2
[*] --> queued: producer inserts (pending / status=0 / processed=false)
queued --> in_progress: cron picks batch (marks picked where supported)
in_progress --> done: success (done / processed=true / status=3 sent)
in_progress --> retry: transient failure (retry_count++, set next_retry_at)
retry --> in_progress: next tick, once next_retry_at <= now (backoff)
retry --> failed: retry_count >= MAX_RETRIES
in_progress --> failed: permanent/validation error
done --> [*]
failed --> [*]
Key mechanics that recur:
retry_count+next_retry_at— the richest implementation iscrm_points_allocation_queue(MySQL): on a transient miss (e.g. the order document has not yet landed in Mongo) it doesretry_count = retry_count + 1, next_retry_at = DATE_ADD(NOW(), INTERVAL 15 MINUTE), and only marksfailedonceretry_count >= 5. ✅💻- Batch draining — consumers pull a bounded batch per tick (
LIMIT 5000for points, batches of 8000 for SMS) rather than the whole backlog. ✅📄 - No global Dead Letter Queue. ⚠️ A
failedrow simply sits in the table; there is no DLQ and no automatic replay. Recovery is manual or via cleanup crons. - Cron overlap lock — before draining, most crons check a status/lock document (
cron_statuses,sms_cron_status,whatsapp_cron_status,push_cron_status) for a prior run still markedstatus: falseand skip if found. ✅💻
3. The real queues¶
| Queue | Store | Status field & values | Cron (consumer) | Cadence | Batch / retries |
|---|---|---|---|---|---|
crm_queue (orders) |
MongoDB | status (null/pending → 1 processed); source 1=Swiggy 2=Zomato 3=POS 4=Dashboard 5=Manual 6=External webhook |
ingestion path + deleteCrmQueue.js cleanup |
frequent poll | — |
crm_points_allocation_queue |
MySQL | status 'pending' | 'done' | 'failed'; processed 0/1 |
crmPointsAllocationQueueCron.js |
*/2 * * * * (2 min) |
LIMIT 5000; 5 retries, 15-min backoff via next_retry_at |
loyalty_points_allocate ♻️ |
MongoDB | status 0=pending 1=done -1=error |
pointAllocation.js |
*/1 * * * * (1 min) |
legacy; superseded by the MySQL queue |
whatsapp_queue_YYYYMMDD (marketing) |
MongoDB | process_status 0=pending 2=picked 3=sent/pending-webhook 4=skipped; type absent |
sendWhatsappMessageCron.js |
~1–3 s poll | groups per send |
whatsapp_queue_YYYYMMDD (transactional) |
MongoDB | same; type present |
sendWhatsappTransactionalCron.js |
~1 s poll ⚠️ | order-related msgs |
sms_queue_YYYYMMDD |
MongoDB | processed bool (false→true) + execution_time <= now |
smsQueueCron.js |
*/3 * * * * (3 min) |
batches of 8000 |
push_noti_queue_YYYYMMDD |
MongoDB | processed bool; grouped by (businessId, os, title, message, campaign_id, fcm_token) |
pushQueueCron.js |
*/5 * * * * (5 min) |
grouped Firebase sends |
notiJunk |
MongoDB | processed/junk markers | processNotiQueue + delete crons |
periodic | notification overflow / dead records |
Date-partitioned collections ⚠️ — SMS, Push and WhatsApp queues are suffixed with
YYYYMMDD(producers usually write tomorrow's collection). This spreads load but means every query must know the correct date, and stale collections need a cleanup strategy. See Technical Debt.
3.1 crm_points_allocation_queue in detail ✅💻¶
The most important — and most complex — queue. Producer is order ingestion (processCommonIngestion.js); consumer is crmPointsAllocationQueueCron.js (every 2 min, LIMIT 5000). It multiplexes on a type column:
type |
Handler | Effect |
|---|---|---|
uen_order |
fraud check → order lookup → handleWalletPointsFlow() |
credits earned points, sets addo_orders.cashbackReceived=1 |
order_reward |
handleOrderRewards() (from helpers.js) |
order-driven reward grant |
welcome_point |
wallet-rules lookup → wallet insert/update | one-time welcome bonus (guarded by customers.welcome_points=1) |
refer_earn / refer* |
wallet insert/update | referral points |
Retry logic (verified in source): for uen_order/order_reward, if the matching order document is not yet in Mongo, the row is not failed — it schedules next_retry_at = NOW() + 15 min and increments retry_count, giving up (mark failed) only after 5 attempts (~ up to a couple of hours). Validation errors (bad mobile, blocked customer, missing wallet rules, missing contact mapping) fail immediately with an error_message.
⚠️ Non-atomic across stores. A single row touches MySQL (wallet, wallet_history, wallet_rules) and MongoDB (customers, orders) with no distributed transaction. A crash mid-flow can leave wallet updated but the queue row not marked, or vice versa. Idempotency is only partially enforced (e.g. the welcome_points flag). See Technical Debt.
4. Producers & helpers (commonFunctions/, processLibrary/)¶
Queues do not fill themselves. The main producers:
| Producer | Writes to | Notes |
|---|---|---|
processCommonIngestion.js |
orders, crm_queue, crm_points_allocation_queue |
order ingestion from Swiggy/Zomato/POS/dashboard |
automatedCampaignQueueNew.js ✅ |
sms_queue_*, whatsapp_queue_*, push_noti_queue_* |
main automated-campaign builder; validates customer/wallet/opt-in, substitutes template variables, deducts wallet balance |
automatedCampaignQueue.js ♻️⚠️ |
same | legacy duplicate of the above — see Cron Catalog tech-debt |
smsCampaignQueue.js / pushCampaignQueue.js |
sms_queue_* / push_noti_queue_* |
thin insert wrappers (write tomorrow's collection) |
uengageProcess.js / processCampaign.js |
all three channel queues + auto_campaign_roi |
large journey/campaign orchestrators |
abandonCartCron.js |
sms_queue_*, whatsapp_queue_* |
abandoned-cart re-engagement |
Consumers of note: whatsappMsgProcess.js (WhatsApp send via Gupshup/Facebook), processNotiQueue.js (Firebase Admin push), sendAutoCampaignSms.js (SMS send). Full inventory in the Cron Catalog.
5. The four core flows¶
5.1 Order ingestion → points allocation¶
flowchart TD
SRC["Order sources<br/>Swiggy · Zomato · POS · Dashboard"] --> ING["processCommonIngestion<br/>(HTTP / Lambda)"]
ING --> ORD[("orders — MongoDB")]
ING --> PQ[("crm_points_allocation_queue<br/>MySQL · status=pending")]
PQ -->|every 2 min, LIMIT 5000| CRON["crmPointsAllocationQueueCron"]
CRON --> T{type?}
T -->|uen_order| F["fraud check → order lookup<br/>→ handleWalletPointsFlow()"]
T -->|order_reward| OR["handleOrderRewards()"]
T -->|welcome_point / refer_earn| WP["wallet insert/update"]
F --> W[("MySQL wallet tables")]
OR --> W
WP --> W
F --> M["handleMilestoneUpgrade()"]
CRON -->|order not yet in Mongo| RETRY["retry_count++<br/>next_retry_at +15 min<br/>(max 5)"]
RETRY -.-> PQ
5.2 Campaign / journey → dispatch¶
flowchart TD
J[("campaign_journeys config")] -->|10 PM daily| PAC["processAutomatedCampaigns"]
PAC --> BLD["automatedCampaignQueueNew<br/>(audience, template, wallet deduct)"]
BLD --> SQ[("sms_queue_YYYYMMDD")]
BLD --> WQ[("whatsapp_queue_YYYYMMDD")]
BLD --> PN[("push_noti_queue_YYYYMMDD")]
SQ -->|3 min| SC["smsQueueCron"] --> SMSP["SMS provider"]
WQ -->|1–3 s| WC["sendWhatsappMessageCron /<br/>sendWhatsappTransactionalCron"] --> GS["Gupshup / Facebook Graph"]
PN -->|5 min| PC["pushQueueCron"] --> FB["Firebase Admin SDK"]
GS -.->|delivery webhook ⚠️ no handler cron| WQ
5.3 Loyalty tier mechanics¶
flowchart TD
W["Customer wallet state<br/>LTO · LTV · lastOrderDate"] --> RFM
W --> DG
W --> CI
subgraph nightly
RFM["rfmSegments.js — 3 AM<br/>R/F/M over 90 days"] --> SEG["Segments: new · promising · regular ·<br/>champions · need_attention · at_risk · lost"]
SEG --> RT[("rfm_metrics_tmp · rfm_job_log")]
DG["loyalty_milestone_downgrade.js — 2 AM<br/>downgrade 1 tier if inactive ≥ downgrade_days"] --> DGO[("customers · addo_contacts_mapping · milestone_logs")]
CI["customerInsightsCron / segments_count<br/>advanced + channel segment counts"] --> SC[("segment_counts")]
end
UP["handleMilestoneUpgrade()<br/>(inline on order points)"] --> DGO
5.4 Analytics / MIS aggregation¶
flowchart TD
ORD[("orders")] --> AGG["aggregationProcess.js<br/>(IST date math, ETL)"]
AGG --> A1[("orders_by_day_business")]
AGG --> A2[("fulfillment_breakdown_by_parent")]
AGG --> A3[("outlet_metrics_by_day · outlet_toplist_by_day")]
AGG --> A4[("hour_of_day heatmap · meal_time")]
AGG --> A5[("top_selling_items · item_revenue · product_pairing")]
AGG --> A6[("daily_summary_by_parent")]
SENDS["SMS/WhatsApp/Push sends"] --> CAGG["campaignAggregation.js"] --> CA[("campaign_aggregation")]
CAGG --> CMIS["campaignMis.js"] --> CM[("campaign_mis")]
A6 --> DMIS["dashboardMIS.js"] --> DM[("dashboard_mis")]
DM --> DASH["dashboardControllerNew<br/>(Redis-cached reads)"]
6. Debugging "nothing happened"¶
| Symptom | Where to look |
|---|---|
| Order didn't appear | crm_queue for pending (status:null/0) stuck docs; ingestion cron logs |
| Points not credited | crm_points_allocation_queue status/retry_count/error_message; handleWalletPointsFlow logs; check the order actually landed in Mongo (points wait up to 5×15 min for it) |
| Campaign not delivered | channel queue status (processed / process_status); business wallet_balance; provider errors |
| Cron seems dead | its *_cron_status / cron_statuses doc — a prior run stuck at status:false blocks the next tick |
| Segment stale | rfmSegments / customerInsightsCron last-run status doc |
⚠️ Stuck-cron detection is reactive. Crons alert by email only after ~20 minutes of a lock not clearing (e.g. smsQueueCron emails on subtract_time > 20), and only dedupCustomers has a force-close fallback (~4 h). There is no circuit breaker or auto-restart. See Technical Debt.