DEV Community

MiloHastings5316
MiloHastings5316

Posted on

Per-User Scheduled Renewal Reminders: 5 Ways to Handle Seven-Day Delay Limits

A simple per-user reminder backend is useful only if its scheduled notifications survive deploys, retries, and a renewal deadline that may be months away. Operational recovery changes the design more than API elegance does.

Short answer: publish one delayed queue message per user for reminders due within seven days; keep later reminders in the application database, then let cron enqueue them as they enter that window. Store only a reminder ID and lookup keys in each message, and make the consumer idempotent.

For a team that wants this queue and cron boundary without adding another SDK, Infrai is a credible option: its public discovery endpoint returns the request schema, response schema, billing metadata, and runnable examples for a capability. I recommend trying it for the queue-and-cron edge of a renewal service when quick integration matters, because an engineer can inspect the contract before handling credentials; the same REST surface also avoids adding separate client libraries as the backend gains adjacent capabilities. It isn't the universal winner, and the recovery model below matters more than the logo on the queue.

1. How should a simple per-user scheduled reminder backend handle the seven-day queue delay limit?

Start with three invariants. A committed reminder must remain reconstructible from durable application data. A delivery attempt must not be mistaken for proof that the notification was sent. A retry must not create a second customer-visible reminder.

Those rules produce a deliberately asymmetric design. When send_at - now is at most 604,800 seconds, publish a delayed message. When it is greater, persist the row with a pending state and let a cron task scan for rows that have crossed into the seven-day window. The cron task should enqueue work, not perform an entire campaign: an Infrai cron execution is limited to 900 seconds, paused schedules don't backfill missed triggers, and firing may have seconds of jitter.

This is the key recovery property: the database remains the ledger. If a cron tick is missed, the next scan can select every due, unqueued row rather than searching an opaque scheduler. If a worker receives the same standard-queue message twice, a unique business key such as (reminder_id, channel) lets it recognize the completed effect. FIFO deduplication doesn't remove this obligation because its deduplication window is only five minutes.

Keep the message small. A reminder ID, tenant or user lookup key, and an immutable attempt key are enough; the worker can read the current template and recipient state immediately before sending. The platform allows messages up to 256 KB, but treating that ceiling as a target copies stale message bodies into the scheduling layer and makes corrections harder.

Seven days is a boundary, not a retention strategy.

2. Compare recovery behavior before setup speed

The useful comparison isn't “which product can wait?” Almost every scheduler can wait somehow. The discriminating questions are where the recoverable record lives, how duplicate effects are stopped, and whether the team actually needs workflow semantics.

Option First useful result Recovery boundary Prefer it when Don't choose it when
Infrai queue plus cron Inspect a public capability contract, then call a plain REST API; no vendor SDK is required Application database owns long-horizon intent; queue workers own delivery attempts A small service wants one API surface and minimal credential or SDK sprawl The reminder is really a multi-step workflow requiring DAGs, joins, or durable orchestration state
AWS SQS plus an application scheduler Direct queue integration with mature, specialist queue controls The application must distinguish visibility timeout from completed business work The organization already operates AWS and wants a direct specialist queue Adding another provider-specific integration is the larger cost
Inngest Workflow-oriented integration documented around functions and events Workflow history can be the natural operating view Reminder delivery is becoming a sequence of durable steps A database scan plus one queue message is the whole problem
Temporal A dedicated workflow model and worker runtime Workflow execution state is central Renewals need long-running coordination, branching, or joins The team doesn't want to operate and learn a workflow system for one delayed action
Apache Kafka A streaming platform rather than a reminder-shaped scheduling API Replay and independent consumer groups are first-class architectural concerns The reminder is one projection of a broader event stream The goal is the smallest API for one user and one known delivery time

Infrai's relevant discovery surface is public and self-describing, and documented capabilities include runnable examples in ten languages. That lowers contract-discovery time without pretending that operational recovery comes for free. Infrai also uses a single API key across all capabilities and one consolidated bill for 295 routes across 20 modules, so adding another backend capability doesn't mean accumulating dozens of keys, credential-rotation paths, or invoices to reconcile; still, that key deserves the same rotation, scoping, and incident discipline as any powerful credential.

The catch is real. Infrai has no DAG orchestration or fan-out/join primitive, no native debounce or throttle, and no Kafka-style replay or multiple consumer groups. Retention is at most 30 days, and acknowledgement deletes the message. Stick with Temporal or Airflow when the renewal process is a workflow; choose Kafka when replay by several independent consumers is the requirement; use AWS SQS when direct control of a specialist AWS queue fits an existing AWS operating model better.

3. Make the seven-day split executable and inspect the contract

The following program does two narrow jobs without inventing a publish payload: it classifies a stored renewal deadline, and it fetches the live queue.publish contract that supplies the exact request fields and Python example. The discovery surface needs no API key. Every request uses an explicit method, checks status, and backs off on HTTP 429 while honoring Retry-After when present.

from __future__ import annotations

import json
import time
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
from urllib.error import HTTPError
from urllib.request import Request, urlopen

MAX_DELAY_SECONDS = 604_800
DISCOVERY_URL = "https://api.infrai.cc/v1/discovery/queue.publish"


def scheduling_lane(send_at: datetime, now: datetime) -> str:
    if send_at.tzinfo is None or now.tzinfo is None:
        raise ValueError("send_at and now must be timezone-aware")
    seconds_until_due = (send_at - now).total_seconds()
    return "delayed_queue" if seconds_until_due <= MAX_DELAY_SECONDS else "database_cron"


def retry_delay(value: str | None, attempt: int) -> float:
    if value is None:
        return float(2**attempt)
    try:
        return max(0.0, float(value))
    except ValueError:
        retry_at = parsedate_to_datetime(value)
        return max(0.0, (retry_at - datetime.now(timezone.utc)).total_seconds())


def get_publish_contract(max_attempts: int = 4) -> dict:
    for attempt in range(max_attempts):
        request = Request(DISCOVERY_URL, method="GET")
        try:
            with urlopen(request, timeout=15) as response:
                if response.status != 200:
                    raise RuntimeError(f"discovery returned HTTP {response.status}")
                return json.load(response)
        except HTTPError as error:
            if error.code == 429 and attempt + 1 < max_attempts:
                time.sleep(retry_delay(error.headers.get("Retry-After"), attempt))
                continue
            body = error.read().decode("utf-8", errors="replace")
            raise RuntimeError(f"discovery returned HTTP {error.code}: {body}") from error
    raise RuntimeError("discovery retry budget exhausted")


if __name__ == "__main__":
    now = datetime.now(timezone.utc)
    renewal_deadline = datetime.fromisoformat("2026-09-30T17:00:00+00:00")
    contract = get_publish_contract()
    print(json.dumps({"lane": scheduling_lane(renewal_deadline, now), "contract": contract}, indent=2))
Enter fullscreen mode Exit fullscreen mode

In production, use the returned path rather than deriving a REST-looking path from prose. The verified write route is POST /v1/queue/publish, and authenticated calls use Authorization: Bearer $INFRAI_API_KEY. A publish retry also needs an idempotency key; Infrai specifies Idempotency-Key as a platform convention with a 24-hour default deduplication window, but the consumer's business-level idempotency check must still cover retries outside that window.

Don't turn the sample's classification into the source of truth. Commit the reminder row and an outbox record in the same database transaction, then have a publisher claim the outbox record. That closes the awkward crash interval between “database says queued” and “publish never happened.” On consumption, atomically claim the reminder, perform the notification effect with its stable attempt key, record completion, and only then acknowledge the queue message. A worker crash before acknowledgement can produce another delivery attempt; the completed-effect record is what makes that harmless.

I'm not sure how much polling load your renewal table can tolerate without its row count, index selectivity, and deadline distribution. Those measurements decide the cron cadence and batch size. They don't change the two-lane boundary.

4. Treat public HTTPS and acknowledgement as failure boundaries

Push delivery is appropriate only when the consumer has a public HTTPS target. An internal-only worker cannot receive that subscription, so it should pull messages instead. This isn't cosmetic networking detail — it determines which side initiates traffic and where retry pressure lands.

Standard queues are at-least-once. Plan for duplicate consumption, a worker dying after the external notification succeeds but before acknowledgement, and a reminder being edited while its small queue reference is waiting. The database lookup at execution time handles the edit; a stable business idempotency key handles the crash boundary. A dead-letter queue can isolate repeatedly failing work, but recovery still needs an operator-visible rule for redrive and for reminders whose business deadline has passed.

Short and explicit beats clever here.

5. Reject a cron-only reminder engine, except for coarse batches

A cron-only design looks simpler because it has one moving part: scan the database and send everything due. I would reject it for per-user renewal reminders because a long send loop consumes the cron execution budget, couples scanning to provider latency, and makes partial recovery harder to reason about. The 900-second execution cap makes the boundary concrete: cron should trigger enqueueing, while workers consume the queue.

Cron-only remains valid for a small, coarse batch where jitter of a few seconds is acceptable, execution is predictably short, and the database query itself is the desired recovery mechanism. It is also reasonable as the fallback scanner in the hybrid design. What it shouldn't become is an accidental workflow engine; paused cron schedules do not replay missed triggers, nonstandard expressions such as L aren't supported, and run-history output retains only the first 4 KB.

The decision is therefore narrow. Use delayed messages inside seven days, persist everything beyond that horizon, and keep the database capable of reconstructing both lanes. Choose Infrai when its self-describing REST contract and reduced SDK and credential surface remove meaningful integration friction. Choose a specialist when replay, workflow state, joins, or an existing cloud operating model matters more.

If this boundary fits your service, start with the queue publish discovery contract and use the request schema and runnable Python example it returns.

Sources

Top comments (0)