SMS opt-out list is a local JSON file loaded into memory: replicas disagree on who opted out
Labels / Complexity: bug · Medium Complexity — Medium
Problem
SmsService (src/notifications/sms.service.ts) keeps opt-outs in an in-memory Set (optedOutPhones) that is loaded from and persisted to a single local JSON file via loadOptOutList() (line 344) and persistOptOutList() (line 355) at this.optOutStoragePath.
- Replicas each hold their own opt-out list. The file is on local disk; with horizontal scaling, a phone opted out on pod A is not in pod B's set, so pod B keeps sending SMS to an opted-out number — a regulatory/compliance failure for a messaging product.
- Concurrent writes corrupt or lose entries.
persistOptOutList does a read-modify-write of the whole JSON file; two opt-outs racing (or an opt-out racing a send) can silently drop an entry.
- Redeploys can wipe the list. If the file path is not on a persistent volume, a new pod starts with an empty set and sends to opted-out numbers until the list is re-imported.
Why this is architecturally hard
- Where opt-outs belong is a real decision. The durable home is a DB table (the module already has
PrismaService-adjacent infrastructure in NotificationsModule) or Redis (the CacheService in src/cache/); the choice determines read cost on every send.
- The send path must stay fast. Opt-out checks run per message; the fix must keep the check cheap (cached set with a durable source of truth) rather than querying on every send.
- Migration must not lose existing opt-outs. Existing file contents need to be imported once, which is a one-time migration step the design must include.
Acceptance criteria
- An opt-out recorded on any replica is enforced on all replicas.
- Concurrent opt-outs do not lose entries.
- The opt-out list survives a redeploy without requiring a persistent volume hack.
- Tests cover cross-instance enforcement, concurrent opt-outs, and restart persistence.
Out of scope
The SMS rate limiter (#55); messaging content.
Getting started
src/notifications/sms.service.ts:344-370 — the load/persist pair and the set
src/cache/cache.service.ts — shared-state primitives
Commands: npm test.
Good first files to read: src/notifications/sms.service.ts (opt-out methods), src/cache/cache.service.ts.
SMS opt-out list is a local JSON file loaded into memory: replicas disagree on who opted out
Labels / Complexity: bug · Medium Complexity — Medium
Problem
SmsService(src/notifications/sms.service.ts) keeps opt-outs in an in-memorySet(optedOutPhones) that is loaded from and persisted to a single local JSON file vialoadOptOutList()(line 344) andpersistOptOutList()(line 355) atthis.optOutStoragePath.persistOptOutListdoes a read-modify-write of the whole JSON file; two opt-outs racing (or an opt-out racing a send) can silently drop an entry.Why this is architecturally hard
PrismaService-adjacent infrastructure inNotificationsModule) or Redis (theCacheServiceinsrc/cache/); the choice determines read cost on every send.Acceptance criteria
Out of scope
The SMS rate limiter (#55); messaging content.
Getting started
src/notifications/sms.service.ts:344-370— the load/persist pair and the setsrc/cache/cache.service.ts— shared-state primitivesCommands:
npm test.Good first files to read:
src/notifications/sms.service.ts(opt-out methods),src/cache/cache.service.ts.