Skip to content

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:

  1. 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 explicit EXPIRE.
  2. Overwrite-on-recompute. dashboardControllerNew uses get-or-compute: a miss computes and client.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: 📄

readPreference: "secondaryPreferred",
maxPoolSize: 400,
  • SECONDARY_PREFERRED routes 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 crmPointsAllocationQueueCron may not yet find a just-ingested orders document and reschedules (next_retry_at +15 min, up to 5×). See Queues.
  • maxPoolSize: 400 gives 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: crmPointsAllocationQueueCron opens a fresh mysql.createConnection per run rather than borrowing from jobsPool (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:

  1. Business config & wallet rules. business_configs, wallet_rules, loyalty_milestones are 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.
  2. Auth / token lookups. authMiddleware and 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.
  3. Fraud-rule checks. fraudRulesController.checkCustomerIsBlocked runs on every uen_order points allocation with no caching of block-lists.
  4. Provider config (Gupshup/Facebook, FCM certs). WhatsApp/push sends reload per-business provider config and Firebase credentials repeatedly (processNotiQueue even re-inits a Firebase app per batch ⚠️). These should be cached/pooled per parent business.
  5. 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.
  6. No TTLs on existing Redis keys (see §1.2) — an existing cache correctness/hygiene gap, not a missing cache.