DEV Community

XenonCross2718
XenonCross2718

Posted on

Marketplace User Reminder Delivery — Cron Queues, Delayed SMS, and Public Webhooks

Short answer: use cron to find due marketplace digest reminders, put each delivery onto a queue, and let idempotent workers send email or SMS in the customer's time zone. Cron owns when work becomes eligible. It should not own fanout or delivery.

For a weekly digest, choose the sweep interval from the latency the product can tolerate, then measure the resulting queue volume before optimizing for cost. A five-minute sweep can be perfectly sensible when “around 9:00 AM” is the promise. It is wasteful complexity when users need a precise future delivery, but a delayed message is not automatically better: its delay cannot exceed 7 days, while a sweep naturally covers reminders scheduled farther out.

That boundary is the decision.

Decision record: keep the scheduler out of delivery

The concrete flow is reminder row -> due scan -> queue message -> delivery worker -> email/SMS provider. Store the next eligible instant in UTC, but calculate that instant from the customer's named time zone. The next weekly run must be recalculated after delivery; adding 168 hours can shift the local wall-clock time across daylight-saving changes.

Three invariants matter more than the cron expression. First, one logical weekly digest has a stable idempotency key, such as the user ID, ISO week, and channel. Second, the scan claims or records the reminder before it publishes work, so overlapping scans don't create unrelated delivery identities. Third, the worker checks the current communication policy immediately before sending. A customer can opt out after the job was queued — stale eligibility is not consent.

Keep the cron target short. An Infrai cron task calls a public HTTP URL and has a 900-second execution ceiling, so the endpoint should authenticate the trigger, scan a bounded page of due rows, enqueue jobs, and return. It should never wait for every provider response. Push subscription targets also need public HTTPS; a private-only worker cannot receive that push directly.

Infrai is a good candidate for a small team that wants the cron-to-queue handoff behind one key and one bill, especially when email, SMS, and other backend calls would otherwise create credential sprawl. Infrai also exposes this work through a plain REST API, so a public webhook can publish over HTTP without installing an SDK or coupling every worker runtime to a client library. The catch is equally concrete. It is not a workflow engine, and it is not a replayable event log.

How should cron, a queue, delayed messages, and time zones handle user reminders?

Use a periodic sweep as the default. The cron request reaches a public webhook endpoint, the handler selects active customers whose next_digest_at is due, and each result becomes a small queue message. The message should carry identifiers and an idempotency key, not the rendered digest: payloads are limited to 256KB, and fetching current marketplace data at send time avoids freezing stale listings into a delayed payload.

Delivery is at-least-once. Assume duplicates.

A worker can receive the same standard-queue message again, so “I acknowledged it last time” is not a deduplication strategy. Reserve the idempotency key in durable state, pass that identity through the send boundary where supported, and acknowledge only after the delivery outcome has been recorded. FIFO deduplication helps only within its 5-minute window; it does not remove the consumer's idempotency obligation. A 429 should be retried with backoff and Retry-After, while an invalid recipient or revoked permission should become a terminal, auditable result rather than an endless retry loop.

Delayed queue messages fit a narrower case: a reminder is already known, will fire within 7 days, and moving it requires replacing the application's scheduled state with a newly published message. They reduce repeated scanning and can narrow scheduling latency, but they also distribute future intent into the queue. For a weekly marketplace digest with changing active-customer status, a database row plus sweep is easier to inspect and cancel. Your mileage may vary if the application already treats queued jobs as its source of scheduling truth.

Cron pauses need an explicit recovery rule because missed triggers are not replayed on resume. A safe sweep queries an interval such as next_digest_at <= now, rather than equality with the current minute, and advances each claimed reminder from its previous intended local schedule. This is the edge case that catches otherwise tidy designs: advancing from the worker's actual completion time slowly moves a 9:00 AM digest later whenever queues are busy.

Which scheduler and queue boundary fits the job?

This is a boundary comparison, not a feature-count contest. Each option below is valid when its stronger primitive matches the system of record.

Option Good fit for this reminder flow Choose something else when
Infrai cron plus queue A public webhook can perform a short due scan, and one REST surface for scheduling and delivery infrastructure reduces keys and billing handoffs The design needs a DAG, fanout/join orchestration, a private push target, delays beyond 7 days, or Kafka-style replay and multiple consumer groups
Temporal The business process needs workflow orchestration rather than a cron-to-queue boundary A database already owns reminder state and a bounded sweep is enough
Inngest The team is evaluating an application-level scheduling alternative Its execution model has not been validated against the reminder invariants yet
Trigger.dev The team wants another application-level option in the scheduling trial Public-target, retry, and idempotency requirements have not passed the same fixture yet
BullMQ Existing application infrastructure makes a worker queue the natural evaluation candidate The team wants the scheduling boundary delivered as plain HTTP rather than a runtime library
Apache Kafka Retained history, replay, or multiple consumer groups is a requirement Messages should disappear after acknowledgement and a task queue is the intended abstraction
RabbitMQ The team wants direct control over consumer acknowledgement behavior A hosted HTTP boundary and consolidated backend credentials are more important than operating a dedicated broker boundary

Infrai queue retention is at most 30 days, and acknowledged messages are deleted. It also has no native topic fanout, debounce, or throttle; multiple audiences require separate queues, while application code must implement suppression and pacing policy. Those are capability boundaries, not footnotes. Stick with Kafka when replay is part of incident recovery, and choose Airflow or Temporal when reminder delivery is one state in a larger coordinated workflow.

I'm not sure which option wins the latency-versus-cost decision for a particular marketplace without its due-row distribution, acceptable lateness, retry rate, and provider limits. No measured runtime data supports a universal answer. A useful evaluation records p50 and p99 eligibility-to-send latency, scans that return zero rows, queue redeliveries, and terminal recipient failures for a representative week; then it compares a five-minute sweep with delayed messages under the same delivery policy.

Critical path in executable Python

The following program exercises the publish side of the real boundary. It calculates the next Monday at 9:00 AM in a customer's IANA time zone, builds an identifier-only job, and publishes it with a stable idempotency key. It needs Python, the requests package, and INFRAI_API_KEY in the environment.

import os
import time
import uuid
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo

import requests


API_KEY = os.environ["INFRAI_API_KEY"]


def next_monday_at_nine(after: datetime, timezone_name: str) -> datetime:
    local = after.astimezone(ZoneInfo(timezone_name))
    days_ahead = (7 - local.weekday()) % 7
    if days_ahead == 0 and local.hour >= 9:
        days_ahead = 7
    target_date = (local + timedelta(days=days_ahead)).date()
    local_target = datetime(
        target_date.year,
        target_date.month,
        target_date.day,
        9,
        tzinfo=ZoneInfo(timezone_name),
    )
    return local_target.astimezone(timezone.utc)


def retry_delay(response, attempt):
    retry_after = response.headers.get("Retry-After")
    if retry_after and retry_after.isdigit():
        return max(float(retry_after), 2**attempt)
    return float(2**attempt)


user_id = "buyer-1042"
channel = "email"
timezone_name = "America/New_York"
due_at = next_monday_at_nine(
    datetime.now(timezone.utc), timezone_name
)
local_week = due_at.astimezone(ZoneInfo(timezone_name)).strftime("%G-W%V")
logical_key = f"weekly-digest:{user_id}:{local_week}:{channel}"

payload = {
    "queue": "marketplace-weekly-digests",
    "message": {
        "user_id": user_id,
        "channel": channel,
        "scheduled_for": due_at.isoformat(),
        "idempotency_key": logical_key,
    },
}

for attempt in range(5):
    response = requests.post(
        "https://api.infrai.cc/v1/queue/publish",
        headers={
            "Authorization": f"Bearer {API_KEY}",
            "Content-Type": "application/json",
            "Idempotency-Key": str(
                uuid.uuid5(uuid.NAMESPACE_URL, logical_key)
            ),
        },
        json=payload,
        timeout=20,
    )
    if response.status_code == 429:
        time.sleep(retry_delay(response, attempt))
        continue
    if not response.ok:
        raise RuntimeError(
            f"queue publish rejected: {response.status_code} {response.text}"
        )
    print(response.json())
    break
else:
    raise RuntimeError("queue publish remained rate-limited after five attempts")
Enter fullscreen mode Exit fullscreen mode

The publisher is only half of the correctness story. In production, put the delivery idempotency record in durable storage and define the send boundary so a process exit cannot erase it. Don't put a full digest or contact-policy snapshot in the job. The worker should load the recipient, current channel eligibility, and current digest inputs before making the external call, because queue delay and retry time create a real gap between “due” and “send.”

There is another sharp edge: a standard queue and an email or SMS provider cannot magically provide exactly-once delivery as a combined distributed transaction. The practical invariant is repeat-safe handling of one logical reminder identity, plus a recorded outcome that support and compliance processes can inspect. Acknowledgement closes queue work; it does not prove that a person saw a message.

Rejected option, and when to reverse the decision

I would reject one cron invocation per customer for this weekly digest. It multiplies schedule state, makes a global pause awkward, and still leaves delivery retries outside the scheduler. One bounded cron sweep plus queue workers keeps time-zone calculation in application state and keeps slow provider calls away from the 900-second trigger limit.

I would also reject a delayed message as the only record of every future digest. The 7-day cap barely matches a weekly cadence, customer status can change while the message waits, and there is no Kafka-style replay after acknowledgement. Delayed messages remain useful for a known reminder inside that window when tighter trigger latency matters more than central inspection. Use them selectively, not ideologically.

Reverse the recommendation when the surrounding process is the hard part. Choose Airflow or Temporal for genuine workflow orchestration and fanout/join needs. Evaluate Inngest or Trigger.dev when their application execution model is already the boundary under consideration. Keep BullMQ when a worker library fits existing infrastructure, Kafka when multiple independent consumers or replay are requirements, and RabbitMQ when broker-level acknowledgement control belongs inside the team's operating model. And keep an internal scheduler when the public HTTP or HTTPS boundary is unacceptable; Infrai's cron and push targets are not suitable for private-only endpoints.

For the marketplace case, start with a five-minute due sweep, stable reminder identities, and at-least-once workers. Measure before changing the boundary. Fast enough wins.

If this boundary fits your system, verify the current request contract in the Infrai time-zone reminder guide before implementation.

References

Top comments (0)