Short answer: freeze the eligible audience in Postgres, let Node.js workers claim stable pages, resolve recipient preferences and suppression before any batch email or SMS attempt, and make status polling read the same evidence ledger that compliance reviewers will inspect.
Start with the bill. For a gaming account event, the useful cost model is attempts = eligible recipients x permitted channels + retries; retained bytes are job facts + recipient decisions + attempt transitions + redacted receipts. Recipient-channel attempts are the term that grows with both audience and fallback channels, so sending email and SMS to everyone doubles that term before a retry happens. Resolve the permitted channel first. Retention then becomes the second lever: keep evidence of a decision, not copies of reset tokens and rendered messages.
This design deliberately discards rendered content, raw destinations where a stable internal key works, and all token material after dispatch. The trade-off is uncomfortable but clear: a later investigation can prove the policy version, template version, eligibility decision, and state transitions, but it cannot recreate every personalized byte. If exact-content retention is a legal requirement, use a separately encrypted archive with narrower access and a tested deletion schedule.
How can a Node.js worker implement pagination for batch email and SMS recipients?
Create one notification_job row, then materialize an immutable audience as notification_recipient rows from a consistent selection. Each recipient row records the channel decision and begins either queued or in a terminal exclusion state such as suppressed or no_permitted_channel. Do not paginate a live users table: preferences can change while offset pages move under the worker, making the audit question “who was evaluated?” needlessly hard to answer.
Claim frozen rows with keyset pagination over a monotonically increasing recipient ID. A short Postgres transaction selects eligible rows in fixed order, skips rows another worker has locked, marks its page leased, sets a lease expiry, and commits. Network work starts only after that commit. Offset pagination is a poor fit here because late offsets become expensive and a changing source set can create gaps or repeats; freezing the audience handles the moving-set problem, while the keyset handles page traversal.
Here is a Python reference model for the claim boundary even when the production worker runs in Node.js. The repository contract is what matters, and keeping it explicit stops queue code from quietly redefining which rows are eligible.
from dataclasses import dataclass
from datetime import datetime
from enum import StrEnum
class State(StrEnum):
QUEUED = "queued"
LEASED = "leased"
ACCEPTED = "accepted"
SUPPRESSED = "suppressed"
NO_PERMITTED_CHANNEL = "no_permitted_channel"
RETRYABLE = "retryable"
FAILED = "failed"
EXPIRED = "expired"
@dataclass(frozen=True)
class Claim:
job_id: str
recipient_id: int
channel: str
template_version: str
token_expires_at: datetime
evidence_expires_at: datetime
def can_claim(state: State, token_expires_at: datetime, now: datetime) -> bool:
return state in {State.QUEUED, State.RETRYABLE} and now < token_expires_at
The claim and lease update must be atomic. A unique idempotency key such as (job_id, recipient_id, channel, template_version) closes the duplicate-enqueue path, while a provider-side opaque idempotency key is useful only as an additional boundary. Batch size comes from measured claim time, downstream limits, and the reset token's expiry; it isn't a number to inherit from sample code. A page of 500 may look efficient but still be wrong if queue age pushes its tail beyond a five-minute validity window.
Small batches win when expiry is tight.
Retry and failure drill: one password-reset job to expiry
Suppose a security rule requires password resets for a frozen set of game accounts. The coordinator records the event reason, policy version, template version, audience-selection version, token expiry policy, and evidence expiry, then creates one recipient decision per account. Recipient preferences, regional policy, and suppression are separate inputs: preference says which verified channel the player permits or favors; policy decides whether that message class may use the channel; suppression blocks an unsafe or unwanted destination. The coordinator resolves those inputs before creating sendable work, but each worker rechecks hard suppression immediately before dispatch because a block can arrive after the audience snapshot. It does not silently fall back from SMS to email. Fallback is a fresh policy decision with its own permission, template, and evidence. If the chosen channel is suppressed or unavailable, the row becomes no_permitted_channel, and the recovery UI can offer another verified path. That may lower the displayed completion count, yet it prevents “redundancy” from becoming an unauthorized second contact. The worker then claims a page, requests short-lived links from the reset service, renders without logging those links, and sends only while each token remains valid. It records an accepted response as accepted, a normalized retryable outcome as retryable, and an invalid destination or policy exclusion as terminal. Before every retry it compares current time with token_expires_at; after expiry it records expired and sends nothing. A bounded exponential backoff with jitter is appropriate only while useful validity remains. Increasing concurrency in response to rate limiting is backwards.
That whole paragraph describes one job, not six independent subsystems. Keeping the sequence visible makes race-condition tests much easier to name: preference changed after the snapshot, suppression added before dispatch, two workers claimed concurrently, a lease expired before completion, and the token expired before a retry. Tests should inject those transitions without requiring real email or SMS delivery. The invariant is compact: every frozen recipient has exactly one current state, every aggregate count is derivable from those rows, and no send starts after token expiry.
SMS reset traffic also needs an abuse boundary before the coordinator creates recipient work. Twilio's SMS-pumping guidance describes controls including rate limits and geo permissions for verification traffic. Apply those categories at the reset-request edge with per-account and per-destination controls, an allowed-country policy, and anomaly monitoring. The actual thresholds depend on the traffic distribution; there is no honest universal number to paste here.
No shortcut fixes abuse.
Postmark's transactional-email guidance classifies password resets as transactional traffic and recommends separating transactional and promotional streams. That operational boundary is useful even with a custom dispatcher: a marketing opt-out and a security-notice policy aren't interchangeable inputs. Applicable law and the documented message policy still determine what may be sent.
Cost accounting and status polling use the same counters
The create operation should return a job ID after the audience and policy version are recorded, not wait for every send. A polling endpoint reads a Postgres projection with aggregate counts and an updated_at value. It doesn't call downstream delivery services during each poll, and recipient-level inspection uses a stable cursor over the frozen rows.
Use narrow language. A job can be preparing, dispatching, complete, or complete_with_failures; recipient states can include queued, leased, accepted, retryable, failed, expired, and policy exclusions. “Accepted” means the downstream service accepted a request. It doesn't prove inbox placement, handset receipt, human attention, or reset completion. Even a later delivery signal should retain its precise meaning instead of being promoted to proof that a player acted.
The status response must not expose raw email addresses, phone numbers, reset links, unrestricted recipient enumeration, or downstream payload dumps. Authorize this endpoint as carefully as the reset action. Polling clients can back off when updated_at doesn't move, while operators watch oldest eligible row age, claim throughput, attempts by normalized outcome, suppression counts, retry age, and tokens expired before dispatch. One “success rate” hides too much.
Deploy with one worker pool and separate concurrency budgets for email and SMS. The channels' downstream limits don't necessarily move together, and one global knob lets a throttled channel starve the other. Add workers only when queue age rises while database claim time and accepted latency remain healthy. This is a capacity decision, not a reflex.
Retention governance: delete content, preserve decisions
Compliance evidence should answer why the job existed, who was evaluated, why each recipient was eligible or excluded, what transition occurred, and when the evidence will disappear. A provider receipt answers only a slice of that chain. For a short-expiry reset, keep a one-way recipient reference, selected channel, policy and preference snapshot versions, suppression result, template version, timestamps, attempt number, normalized outcome, and evidence expiry. Keep the token out of the notification row and application logs; the ledger needs its expiry timestamp, not the secret.
| Record | Keep | Avoid | Review value |
|---|---|---|---|
| Job | event reason, policy and template versions, counts | rendered content | Explains the bulk decision |
| Recipient decision | pseudonymous key, channel, preference version, exclusion reason | copied destination where an internal key works | Proves eligibility |
| Attempt | state, attempt number, timestamps, redacted receipt reference | token, reset URL, raw downstream payload | Reconstructs transitions |
| Aggregate | queued, excluded, accepted, terminal counts | inferred “delivered” totals | Keeps status claims honest |
Retention clocks should differ. Aggregate job facts can support longer operational analysis, while recipient-level evidence is more sensitive and should expire under the documented rule for its jurisdiction and message class. The correct duration is genuinely unresolved until counsel, security, and the data owner define it, so evidence_expires_at belongs in policy data rather than a worker constant.
This pattern fits when Postgres can absorb claim and evidence writes and a credential message should leave a narrow, queryable trail. It is not suitable when rules require an immutable exact copy of every outbound message, when the audience cannot be frozen, or when database write throughput is insufficient. Those cases call for a controlled content archive, a streaming audience protocol with explicit snapshot semantics, or a durable event log feeding a compliance projection. Each adds operating cost and another consistency boundary.
What is deliberately lost? Exact reconstruction after content deletion. If an investigation later needs the precise personalized body, a template version and decision trail won't supply it. Preserve exact content only when that investigative value outweighs the exposure of keeping security-message data; encrypt it separately, restrict access, and verify deletion. Otherwise, let it expire.
Top comments (0)