DEV Community

WindwhisperBoren33
WindwhisperBoren33

Posted on

Bulk Password Reset Email and SMS Delivery (Queue Workers with Cron Polling)

Short answer: put each marketplace password-reset request in a durable queue, let workers claim bounded batches, and use cron polling only as a recovery sweep; the reset's short expiry must govern retries, batching, and telemetry retention. This costs a little more integration effort than calling email and SMS providers inside the request handler, but it keeps an overloaded channel from extending API latency or consuming an already-expired reset.

The important unit is not “a message.” It is one reset intent with an expiry, an idempotency key, and one or more delivery attempts. Email and SMS are transports attached to that intent. That distinction prevents a retry from quietly becoming a second reset flow, and it gives the worker enough information to stop work that can no longer help the buyer or seller.

Keep it boring.

How can an API batch email and SMS event notifications?

The API should create the reset intent and enqueue a reference to it in one logical operation. A worker then claims a small batch, reloads each intent, rejects expired work, and submits a channel-specific payload. A periodic poller searches for eligible intents that are neither completed nor currently leased. It is a backstop for missed wake-ups, not a second dispatcher running a competing algorithm.

The integration boundary can stay plain HTTP. These examples use pseudonymous internal endpoints; they describe the contract between application components, not a commercial service:

curl --request POST 'https://notifications.example/internal/reset-intents' \
  --header 'Content-Type: application/json' \
  --header 'Idempotency-Key: reset_7d9c' \
  --data '{"account_id":"acct_1842","channels":["email","sms"],"expires_at":"2026-08-16T10:15:00Z"}'
Enter fullscreen mode Exit fullscreen mode

The response path should not wait for either transport. The queued record needs an opaque account reference, the absolute expiry, channel preference, attempt state, and template version. It should not contain a reusable credential in logs or metric labels. The worker obtains the current delivery data from the protected record when it owns the lease.

For the batch claim, make ownership explicit:

curl --request POST 'https://notifications.example/internal/delivery-work/claim' \
  --header 'Content-Type: application/json' \
  --data '{"worker_id":"worker_03","limit":50,"lease_seconds":30}'
Enter fullscreen mode Exit fullscreen mode

A claim size of 50 and a 30-second lease are example configuration values, not universal recommendations. The useful rule is measurable: a worker should normally finish well inside its lease, and a retry should begin only after ownership is unambiguous. If the short reset lifetime leaves less time than the next backoff plus expected delivery latency, mark the intent expired instead of sending a message whose link will be dead on arrival.

Batching belongs at the queue boundary, while provider submission may still be one request per recipient. This separation matters because email and SMS transports have different payload rules, rate controls, and partial-failure semantics. Do not assume that a provider's “batch” is atomic. Persist an outcome per intent and per channel, then acknowledge only the queue items whose state was durably updated.

Retry reliability starts with the expiry clock

A cron expression is not a reliability model. Suppose the recovery poll runs every minute and a reset expires quickly: the maximum useful retry window has already lost up to one polling interval before the poller even sees the row. Add queue delay, lease time, backoff, and transport latency. If that sum reaches the remaining lifetime, another attempt creates traffic without improving the user's chance of completing the reset.

That is the first piece of retention math. The second concerns operational data. Keep the minimum event fields long enough to reconcile delivery outcomes and investigate abuse, then remove or aggregate them under the marketplace's policy. A raw recipient address, account ID, reset token, idempotency key, and provider response all have different diagnostic value and exposure. Treating them as one log blob is easy to integrate and expensive to retain.

The catch is that cron polling is not suitable when the expiry is shorter than the poll interval plus normal processing time. Use immediate queue notification for the primary path, or a scheduler capable of waking at the required precision; keep polling for reconciliation. Conversely, a small marketplace with low reset volume may reasonably use a database-backed poller first, provided row claiming is atomic and the team accepts polling latency. A dedicated broker adds moving parts before it adds value.

Govern reset data before adding telemetry

I count cardinality before I add a dimension. A metric such as delivery_attempts_total{channel,outcome,template_version} has a bounded combination count that can be estimated from the allowed values. Adding account_id, recipient, intent ID, or error text changes the series count with traffic and turns a useful counter into an observability bill indexed by users.

Logs answer individual-event questions; metrics answer population questions. A structured attempt log can carry an opaque trace reference, channel, queue delay, remaining lifetime, attempt number, and normalized outcome. Metrics should retain the low-cardinality subset. Traces can connect API admission, queue wait, worker processing, and channel submission, but sampling must preserve rare terminal outcomes deliberately. Uniform sampling is simple and can discard exactly the failures an operator needs.

There is no honest fixed sampling percentage for every marketplace. I'm not sure what rate is appropriate until the team knows reset volume, the frequency of each outcome, storage cost per retained byte, and the investigation window required by policy. Start with an explicit byte budget: estimated events per day multiplied by average encoded event size and retention days. Then decide which fields to drop, which successes to sample, and which security-relevant outcomes to retain. Measure the encoded size rather than estimating from the pretty-printed development log — punctuation, duplicated field names, indexing, and replicas all count somewhere.

SMS adds another cost and correctness edge. The character encoding affects segmentation: GSM-7 messages have different single-message and concatenated-message limits from UCS-2 messages. A localization or a typographic character can therefore change the number of segments. Validate the rendered reset text and its encoding before submission, and record a low-cardinality segment-count bucket rather than the message body. For email, sign and verify the production path consistently; DKIM defines a domain-level signature mechanism, but a queue worker still needs stable message construction and key handling around it.

One line is enough for a pulse check.

curl --request GET 'https://notifications.example/internal/delivery-health?window=5m'
Enter fullscreen mode Exit fullscreen mode

That endpoint should return aggregates, not recipient-level records. A health view can expose queue age, useful-expiry rejections, attempts by channel and outcome, and lease recovery counts. The deeper event trail belongs behind narrower access controls and a shorter, justified retention period.

Roll out one delivery boundary at a time

Compare designs on integration effort after writing down the failure boundary. The useful comparison is ownership, because every box that looks absent from a diagram usually reappears as application code or operational work.

Boundary Initial integration work Work the team continues to own
In-process sender Transport calls in the request path Latency coupling, retry state, and deployment coupling
Database queue Intent table, atomic claims, and worker Claim queries, indexes, leases, and cleanup
Broker-backed worker Broker, producer, consumer, and intent store Cross-system state coordination and another operated integration

An in-process sender has the least initial plumbing, but request latency and deployment lifecycle are coupled to both transports. A database queue reuses familiar persistence and can support atomic intent creation, though careful claim queries, indexing, and cleanup become application responsibilities. A broker-backed worker separates scaling and wake-up behavior, at the cost of operating another integration and coordinating state between the queue and the intent store. None is categorically best.

Roll out in compact steps. First, create reset intents and idempotency keys while the existing sender remains authoritative. Next, run the worker in a non-sending validation mode that records only bounded aggregate counts; compare admission, expiry, and claim totals without retaining payloads. Then enable one channel for a small traffic slice, preserve terminal failures in the sample, and watch queue age relative to remaining reset lifetime. Finally, enable the recovery poller and verify that it claims only abandoned eligible work. Do not dual-send as a migration test.

The decision rule is straightforward: choose the design whose worst normal queue delay plus processing and retry budget fits inside the useful expiry, and whose per-attempt state can be reconciled without unbounded labels or indefinite raw logs. If the team cannot demonstrate both properties, lowering integration effort has merely moved the cost into delivery ambiguity and telemetry.

Sources

Top comments (0)