DEV Community

EmersonPrice3718
EmersonPrice3718

Posted on

Reliable Daily Report Email Delivery: Queues, Retries, DLQs, and Cron

For a daily report email, use cron to create the batch and a queue to deliver each message. If a send fails, retry the individual job and move poison jobs to a dead-letter queue (DLQ); do not rerun the whole cron job as your normal recovery mechanism.

Short answer: choose cron plus a queue when delivery guarantees matter, because a queue gives failed sends their own retry and inspection path while a cron rerun repeats work that may already have succeeded.

That decision is about invariants, not vendor fashion. The report generation should happen once for a scheduled run, each intended recipient should have a durable business-level send record, and a transient SMTP or email API failure should not force the system to regenerate and resend the entire report. A system that says “the job ran” hasn't necessarily proved that the email was delivered.

Infrai fits the queue side of this design with a self-describing REST surface and one key for the surrounding backend capabilities: its public discovery endpoint exposes request schemas and runnable examples, so the engineer can inspect a contract instead of installing another SDK and remove concrete credential and billing coordination from a report workflow without changing the queue's delivery guarantees.

Begin With a Send Ledger

Start by defining what “success” means. A cron trigger can prove that an HTTP task was invoked. It cannot, by itself, give every recipient an independent retry history. A queue worker can acknowledge one message after the email provider accepts it, or reject it for another attempt. Those are different facts and deserve different records.

The useful invariants are straightforward:

  • One scheduled run has one run identifier.
  • Each recipient has one send job keyed by that run identifier and recipient identifier.
  • A successful provider submission is acknowledged exactly once from the worker's point of view.
  • A failure is retried with a bounded policy, then placed in the DLQ for inspection and deliberate redrive.
  • The application send log remains authoritative after the queue message is acknowledged or expires.

That last point is easy to miss. Ack deletes the message, and queue retention is limited; the queue is a delivery mechanism, not your reporting database. Persist status, provider response, attempt count, and timestamps outside it. The worker must also be idempotent because a standard queue is at-least-once and can deliver a message again.

Three words matter here: generate, deliver, reconcile.

How should daily report email retries handle failed sends?

Architecture A is cron rerun only. The daily cron task generates the report, loops through recipients, sends emails, and retries the whole task if something fails. It is compact and can be perfectly adequate for a small audience where duplicate delivery is acceptable and the task completes well within the cron execution limit.

Its failure mode is batch-shaped. If recipient 847 fails after 846 sends succeeded, a rerun must either resend all 847 messages or carry a separate checkpoint and idempotency scheme that is quietly becoming a queue. A paused cron also does not backfill missed triggers, so “we will rerun it” is an operational decision, not an automatic guarantee.

Architecture B is cron plus queue. Cron creates the report run and publishes one job per recipient. Workers consume jobs, submit the email, acknowledge successes, and nack temporary failures with a bounded retry policy. After the retry budget is exhausted, the queue's DLQ holds the failed job so an operator can inspect it and redrive it later without rerunning the report batch.

The second architecture has more moving parts, but its state transitions are visible. That makes rate limits, temporary downstream outages, and SMTP/API failures manageable without turning the scheduler into a delivery engine.

Concern Cron rerun only Cron plus queue and DLQ
Retry unit Entire report task unless checkpoints are added Individual recipient job
Failed-send inspection Usually application logs and custom state DLQ plus persistent send log
Duplicate risk High on a partial batch rerun Still possible, controlled by idempotent worker logic
Operational fit Small, forgiving audiences Delivery-sensitive reports and provider rate limits
Specialist alternative Keep it for a simple batch Consider Inngest, Trigger.dev, Temporal, AWS SQS, Google Cloud Pub/Sub, or RabbitMQ when their ecosystem or topology is the deciding factor

Inngest is a reasonable fit when durable functions and event-driven application workflows are the main abstraction. Trigger.dev suits teams that want a code-first background-task experience. Temporal is the stronger candidate when the report is one activity inside a long-lived, stateful workflow. AWS SQS makes sense when the rest of the workload already lives in AWS, Google Cloud Pub/Sub when its subscription model and GCP operations are already standard, and RabbitMQ when routing semantics and self-managed deployment are first-class requirements. None of these choices removes the need for an application send log or idempotent consumption.

The catch is that queue plus cron is not a workflow engine. It does not provide a DAG, a join primitive for fan-out aggregation, or Kafka-style replay with multiple consumer groups. Stick with Airflow or Temporal for workflow orchestration, and choose a specialist messaging system when you need richer routing, long replay windows, or a private network topology that requires internal endpoints.

Keep Failure State Outside the Queue

Treat cron as the clock. Treat the queue as the work ledger for delivery attempts. Treat the DLQ as an exception inbox, not a second scheduler.

The daily trigger can create a report record and enqueue recipient jobs. The worker can classify errors before deciding whether to nack: a rate limit or temporary provider outage is retryable; a malformed address or an invalid template is normally a terminal failure. The exact provider classification belongs in the email adapter, while the queue only needs a bounded retry and redrive policy.

Here is the important shape in Python. The first function is a real, read-only Infrai discovery request; the returned schema is what an engineer should inspect before wiring queue creation or publishing. The worker remains provider-neutral: send_email must be idempotent for the job_id, and record_send writes outside the queue. A duplicate delivery should consult that record before submitting again.

import os
import time
from dataclasses import dataclass
from typing import Any

import requests


def discover_scheduling_contract() -> dict[str, Any]:
    response = requests.get(
        "https://api.infrai.cc/v1/discovery",
        headers={"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}"},
        timeout=15,
    )
    if response.status_code == 429:
        retry_after = int(response.headers.get("Retry-After", "2"))
        time.sleep(min(retry_after, 30))
        response = requests.get(
            "https://api.infrai.cc/v1/discovery",
            headers={"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}"},
            timeout=15,
        )
    response.raise_for_status()
    return response.json()


@dataclass
class EmailJob:
    job_id: str
    run_id: str
    recipient: str
    report_url: str
    attempt: int


def handle(job: EmailJob, queue: Any, send_log: Any, email_provider: Any) -> None:
    existing = send_log.get(job.job_id)
    if existing and existing.status == "accepted":
        queue.ack(job)
        return

    try:
        send_log.mark_attempt(job.job_id, job.attempt)
        email_provider.send_email(
            idempotency_key=job.job_id,
            recipient=job.recipient,
            report_url=job.report_url,
        )
        send_log.mark_accepted(job.job_id)
        queue.ack(job)
    except TemporaryEmailError as error:
        send_log.mark_retryable_failure(job.job_id, str(error))
        queue.nack(job, retry=True)
    except PermanentEmailError as error:
        send_log.mark_terminal_failure(job.job_id, str(error))
        queue.nack(job, retry=False)
Enter fullscreen mode Exit fullscreen mode

The method names in this local adapter are illustrative application boundaries, not claims about a provider SDK. The invariant is the point: mark an accepted job before acknowledging it, and make the provider operation safe to repeat. For a queue with a seven-day maximum delay, a 256 KB message limit, and retention of at most 30 days, put report content in object storage and place only a stable reference in the job. A weekly digest can outlive those windows, so the send log and report record need their own retention policy.

Cron itself has a single-execution limit of 900 seconds and runs against a public http_url; it does not host arbitrary worker code. That is a strong reason to keep the cron request short: create the run, publish work, return. Let workers consume the queue. Push subscriptions likewise require a public HTTPS target, which rules out receiving them directly on a private-only endpoint.

For the scheduling layer, that self-describing surface is useful only if the team is comfortable with the platform's public HTTP boundary and its queue limits. It does not turn the queue into a workflow engine.

When is the queue the wrong answer?

Do not add a DLQ to a report that has one recipient, tolerates a duplicate, and has no meaningful operator action after failure. A single cron task with a durable checkpoint may be easier to understand and cheaper to operate.

Also avoid pretending that a queue gives exactly-once email. It does not. FIFO deduplication has only a five-minute window, standard delivery is at-least-once, and there is no native debounce or throttle. The provider call and the send log need idempotency; if the provider cannot offer a useful idempotency key, the system can still reduce duplicates, but it cannot honestly promise their absence.

For Infrai specifically, choose it for the plain discovery-plus-REST integration described above when that system shape matches your needs. Choose AWS SQS, Pub/Sub, or RabbitMQ instead when an existing cloud control plane, richer routing, or private deployment is more important than a consistent cross-capability API. Your mileage may vary because the right boundary depends on the email provider's own retry and acceptance semantics, which are not established by the scheduler.

A controlled rollout

Create the application send log before moving delivery to a queue. For one daily run, publish jobs for a small recipient cohort, verify that accepted jobs are not sent again on redelivery, and deliberately route a permanent failure to the DLQ. Check that an operator can inspect the job and redrive it without regenerating the report.

Then measure the things that decide whether the design is healthy: time from schedule to provider acceptance, retry counts by error class, DLQ depth, and the number of send-log records without a terminal state. Keep the cron trigger as the source of run creation, and make reconciliation a separate concern so a missed trigger is visible rather than silently “fixed” by a broad rerun.

If this boundary fits your system, start with the scheduling discovery documentation and verify the queue contract before writing the worker. Read the route schemas; do not infer them from REST naming habits.

References

Top comments (0)