Caching & Read-Scaling¶
Prism has no general-purpose cache abstraction. Caching is applied surgically to a handful of expensive, read-heavy paths — chiefly dashboard aggregates — and the heavier lifting for read-scaling is done at the datastore layer (MongoDB secondary reads, MySQL connection pooling). This section covers all three techniques.
Validation legend¶
| Symbol | Meaning |
|---|---|
| ✅ | Verified against source in this review |
| 📄 | From the architecture map (map-01-infra) |
| 💻 | Behaviour confirmed by reading the file |
| ⚠️ | Tech-debt / operational risk — see Technical Debt |
1. Redis (ioredis + AWS ElastiCache)¶
Client: ioredis, initialized in utilities/redis.js. A single module-level client is created lazily by connectRedis() and returned by getRedisClient(). It is connected once at startup (startRedis() in index.js). ✅💻
// utilities/redis.js (verified)
client = new Redis({
host: "flash-c71njz.serverless.aps1.cache.amazonaws.com", // ⚠️ hardcoded
port: 6379,
tls: { rejectUnauthorized: false }, // ⚠️ ElastiCache serverless
connectTimeout: 1000,
maxRetriesPerRequest: 2,
});
| Setting | Value | Rationale / risk |
|---|---|---|
| Host | flash-c71njz.serverless.aps1.cache.amazonaws.com |
AWS ElastiCache (serverless, ap-south-1). ⚠️ Hardcoded — not read from env; a REDIS_HOST exists in .env.dev but is ignored here. |
| Port | 6379 |
Standard Redis |
| TLS | rejectUnauthorized: false |
⚠️ In-transit encryption on, but certificate not validated — accepts any cert. Matches the legacy PHP config. |
connectTimeout |
1000 ms |
Fail fast on connect |
maxRetriesPerRequest |
2 |
Cap per-command retries so a slow Redis can't stall request handling |
| Failure mode | logged, not fatal | ⚠️ startRedis() logs and continues if Redis is down; the error handler is also commented out, so Redis errors are silently swallowed. |
Resilience note: because a Redis outage is non-fatal and callers do getRedisClient() inline, a Redis failure surfaces as thrown errors at the call site (e.g. in dashboardControllerNew) rather than a startup crash. Read paths that assume Redis is up should be defensive.
1.1 What is cached¶
Redis is used narrowly, for dashboard aggregates and precomputed daily/monthly counts — not as a session store or an entity cache. Verified getRedisClient() consumers:
| Consumer | Key pattern | Written by / read by |
|---|---|---|
controllers/dashboardControllerNew.js ✅💻 |
customers_{parentId}__{month} and related |
read on dashboard load; get-or-compute (client.get → compute → client.set) |
controllers/loyaltyController.js ✅ |
loyalty read paths | transient loyalty read state |
crons/returningCustomerCron.js ✅📄 |
customers_{parent_business_id}__{MM-YYYY} |
producer (2 AM daily) — precomputes returning-customer counts |
crons/loyaltyCustomerPointsCron.js ✅📄 |
wallet_{parent_business_id}__YYYY-MM-DD |
producer (2:35 AM daily) — top spenders, expiring-soon, expired counts |
The dominant pattern is cron-precompute → Redis → dashboard read: nightly crons do the expensive aggregation and stash results under a per-business, per-period key; the dashboard controller reads those keys (or computes-and-caches on miss). This keeps interactive dashboard requests off the heavy orders/wallet scans.
1.2 Cache-invalidation patterns observed¶
⚠️ There is no explicit invalidation layer. Freshness is handled two ways, both implicit:
- Key-versioning by date/period. Keys embed the period (
__YYYY-MM-DD,__MM-YYYY,__{month}). A new day/month is simply a new key, so yesterday's value is never served for today — it just ages out unreferenced. ⚠️ No TTL is set on these keys in the verified paths, so stale-period keys accumulate until evicted by ElastiCache memory pressure. Consider explicitEXPIRE. - Overwrite-on-recompute.
dashboardControllerNewuses get-or-compute: a miss computes andclient.sets the value, effectively refreshing it. The nightly producer crons overwrite the same key each run.
There is no pub/sub or write-through invalidation tying entity mutations (a new order, a points allocation) to cache eviction. If underlying data changes mid-period, the cached aggregate can be stale until the next nightly recompute or key rollover.
2. MongoDB read-scaling: SECONDARY_PREFERRED¶
Independent of Redis, Prism scales reads at the driver level. The MongoDB connection (utilities/mongodb.js) is opened with: 📄
SECONDARY_PREFERREDroutes reads to replica-set secondaries when available, falling back to the primary only if no secondary is reachable. This offloads the large analytical/segment scans (RFM, segment counts, dashboard aggregation) from the primary, which is reserved for writes and ingestion.- ⚠️ Trade-off: replication lag. Secondaries can be seconds behind. A read immediately after a write may not see it — this is the root cause of the points-allocation retry loop, where
crmPointsAllocationQueueCronmay not yet find a just-ingestedordersdocument and reschedules (next_retry_at +15 min, up to 5×). See Queues. maxPoolSize: 400gives the monolith a wide Mongo connection pool to absorb concurrent web + cron load.
Database schema and collection detail live in Database.
3. MySQL connection pooling (webPool / jobsPool)¶
MySQL performance is managed via two separate pools (config/mysqlPool.js) — a deliberate isolation mechanism, not a cache, but a core throughput control: 📄
| Pool | Consumers | Connection limit | Why separate |
|---|---|---|---|
webPool (default export) |
web traffic, request-scoped controllers | WEB_CONNECTION_LIMIT (default 15) |
keeps interactive requests responsive |
jobsPool |
crons, processLibrary, queue workers |
JOBS_CONNECTION_LIMIT (default 10) |
background jobs cannot starve web requests |
Pool options (config/mysql.js): waitForConnections: true, enableKeepAlive: true, keepAliveInitialDelay: 10000, connectTimeout: 10000, queueLimit: 0 (unbounded ⚠️ — an unbounded acquire queue can build memory pressure under sustained overload). Pool health is emitted every ~60 s as pool_stats logs by utilities/poolMonitor.js (open / in-use / waiting / acquired / enqueued / created). 📄
⚠️ Caveat:
crmPointsAllocationQueueCronopens a freshmysql.createConnectionper run rather than borrowing fromjobsPool(verified). This bypasses the pool's isolation and monitoring for one of the hottest crons. See Technical Debt.
4. ⚠️ What is NOT cached but should be¶
Gaps worth closing, cross-referenced in Technical Debt:
- Business config & wallet rules.
business_configs,wallet_rules,loyalty_milestonesare read on nearly every ingestion, points, and campaign operation but are re-queried from MySQL/Mongo each time. These are low-churn and ideal for a short-TTL Redis cache. - Auth / token lookups.
authMiddlewareand token-based middlewares hit MySQL/Mongo per request (users,auth_tokens,authorization_token,addo_contacts_mapping). A per-token cache would cut a DB round-trip off every authenticated request. See Authentication. - Fraud-rule checks.
fraudRulesController.checkCustomerIsBlockedruns on everyuen_orderpoints allocation with no caching of block-lists. - Provider config (Gupshup/Facebook, FCM certs). WhatsApp/push sends reload per-business provider config and Firebase credentials repeatedly (
processNotiQueueeven re-inits a Firebase app per batch ⚠️). These should be cached/pooled per parent business. - Segment/RFM read paths. Interactive segment counts recompute from
getOnlySegmentCount(a very large query helper) rather than reading a cached snapshot for the common filters. - No TTLs on existing Redis keys (see §1.2) — an existing cache correctness/hygiene gap, not a missing cache.