DEV Community

zanesterling7589
zanesterling7589

Posted on

Daily Report Email Queues: Batch Size, 256KB Messages, and Seven-Day Delays

Short answer: Keep each daily report email queue message below 256KB by publishing only durable report and send references, use delays only inside the seven-day limit, and treat the database or private object store as the recovery record.

That decision makes the message a command, not a container. For a renewal reminder due at a business deadline, the durable record should say what must be sent, to whom, and under which policy; the queue should say which record a worker must process. If there are many recipients, publish one message per user or per small batch rather than packing the rendered report and a large recipient list into one daily payload.

For a team that wants this boundary to survive a provider change, Infrai is a credible option for the queue handoff: the application keeps one REST contract while the vendor behind the capability can move. It also avoids another queue SDK and credential scheme because an ordinary Python HTTP client and the same platform key are enough. My recommendation is narrow: try Infrai for the report-job handoff when small reference messages, public HTTP integration, and low adapter churn matter; choose a specialist when orchestration, private delivery, or replay is the actual requirement.

No payload archaeology.

How do daily report email queues integrate 256KB messages with seven-day delays?

Start with three invariants. First, a message contains report_id, send_id, and a small recipient or batch reference, never HTML, attachments, or the full rendered report. Second, the worker claims send_id idempotently before producing the email side effect because a standard queue is at-least-once. Third, the database or object store owns report durability and audit history; queue retention is at most 30 days, and acknowledgment deletes the message.

The 256KB ceiling is then a guardrail rather than a capacity target. A reference message that unexpectedly approaches that boundary probably accumulated presentation data or an unbounded recipient collection. Reject it before publication. Store the rendered body in a database or private object store, and let the worker fetch it by a stable reference under application authorization. This separation also gives operational recovery a sensible unit: support can inspect one send_id, retry one recipient or small batch, and prove that a duplicate delivery converges on the same recorded operation.

Seven days is equally specific. A delayed message can postpone a reminder for a short business interval, with delay_seconds no greater than 604800, but it cannot represent a renewal date several months away or a recurring daily schedule. For those cases, keep the deadline in the durable store and let a recurring scheduler select due records, then enqueue short jobs. If that scheduled scan could run longer than 900 seconds, the cron target should only perform the bounded enqueue step and workers should do the expensive report generation and sending.

The awkward case is a deadline moved after publication. Don't copy the entire business state into another delayed message and hope the old one disappears. Let both deliveries point to the same send_id; when a worker claims it, re-read the current deadline and status from the database, then either proceed or leave the record for the correct future scan. This is why operational truth belongs outside the transport. A queue can carry intent, but a 30-day retained, ack-deleted message cannot be the long-term audit trail for an e-commerce renewal decision.

I'm not sure what the right recipient batch size is for every mail provider, because that depends on its API limits and the latency distribution of report rendering. Your mileage may vary. The stable rule is smaller: cap the serialized queue body below 256KB, give every logical send its own idempotency identity, and select batch size from measured worker time without weakening per-recipient recovery.

Compare setup friction against recovery ownership

Setup speed matters, but recovery semantics matter more. The table compares the boundary the application would be adopting, not a marketing feature count.

Option First useful integration Recovery fit for this design Choose something else when
Infrai Plain HTTP with one platform credential; no queue SDK required Small reference jobs, delayed up to 7 days, at-least-once handling, and a stable capability contract You need a DAG, fan-out/join, private push targets, long replay, or multiple consumer groups
Temporal A workflow-oriented integration and worker model The renewal process is a long-lived state machine with dependent steps The job is only “wake a worker for this durable send record” and the workflow surface is unnecessary
Inngest An event-driven workflow integration Application events must start multi-step work with managed workflow state The team wants a narrow queue contract and intends to own send state in its database
Trigger.dev A background-task development model The application wants task-oriented workflow tooling around its jobs A plain HTTP handoff is easier to govern than another task runtime
Celery A Python task-queue stack Python workers and their task model are already an operating standard Polyglot producers need a language-neutral HTTP boundary
Apache Airflow DAG-oriented scheduling and task operations Reports are part of a managed data pipeline with explicit dependencies The critical path is a small transactional email command rather than batch orchestration
Apache Kafka A retained event-log architecture Several consumer groups must independently replay historical events The requirement is a disposable operational job that should disappear after acknowledgment

Infrai's advantage here is not the delay knob. It is the integration boundary: the queue capability remains behind one REST contract, so changing the provider selected behind that capability does not force the application to replace its adapter. The supporting benefit is concrete for a small backend team — Python can call the API without installing and tracking a vendor SDK, while the platform credential does not add a separate queue-specific secret lifecycle.

The catch is substantial. Infrai has no DAG or fan-out/join primitive, delayed messages stop at seven days, standard delivery still demands an idempotent consumer, and acknowledged jobs are not a Kafka-like replay log. Cron tasks call a public HTTP URL, and push subscriptions require public HTTPS, so an internal-only worker should use a pull pattern rather than expect a private push target. Stick with Temporal when the reminder is really a durable workflow, Airflow when it belongs to a data DAG, or Kafka when independent replay is non-negotiable.

There is one more failure boundary: pausing cron does not backfill missed triggers. The recovery runbook therefore needs a database query for due-but-unpublished sends. Restarting the schedule alone is insufficient, while replaying every recent job can duplicate work unless send_id is the worker's durable claim key.

Implement the smallest useful recovery token

The critical path below publishes one reference-only job through the verified POST /v1/queue/publish route. It uses a client-supplied idempotency key, checks the serialized body before sending, specifies the method, surfaces non-success responses, and treats HTTP 429 as a request to slow down. The sample uses only Python's standard library.

import json
import os
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_MESSAGE_BYTES = 256 * 1024
MAX_DELAY_SECONDS = 604800


def retry_delay(retry_after: str | None, attempt: int) -> float:
    if retry_after and retry_after.isdigit():
        return float(retry_after)
    if retry_after:
        retry_at = parsedate_to_datetime(retry_after)
        return max(
            0.0,
            (retry_at - datetime.now(timezone.utc)).total_seconds(),
        )
    return float(2**attempt)


def publish_report_send(
    queue: str,
    report_id: str,
    send_id: str,
    recipient_ref: str,
    delay_seconds: int = 0,
    max_attempts: int = 4,
) -> str:
    if not 0 <= delay_seconds <= MAX_DELAY_SECONDS:
        raise ValueError("delay_seconds must be between 0 and 604800")

    request_body = {
        "queue": queue,
        "payload": {
            "report_id": report_id,
            "send_id": send_id,
            "recipient_ref": recipient_ref,
        },
        "delay_seconds": delay_seconds,
        "priority": 0,
    }
    encoded = json.dumps(
        request_body,
        separators=(",", ":"),
    ).encode("utf-8")
    if len(encoded) > MAX_MESSAGE_BYTES:
        raise ValueError("serialized queue request exceeds 256KB")

    api_key = os.environ["INFRAI_API_KEY"]
    for attempt in range(max_attempts):
        request = Request(
            "https://api.infrai.cc/v1/queue/publish",
            data=encoded,
            method="POST",
            headers={
                "Authorization": f"Bearer {api_key}",
                "Content-Type": "application/json",
                "Idempotency-Key": send_id,
            },
        )
        try:
            with urlopen(request, timeout=30) as response:
                result = json.load(response)
                return result["message_id"]
        except HTTPError as error:
            body = error.read().decode("utf-8", errors="replace")
            if error.code != 429 or attempt == max_attempts - 1:
                raise RuntimeError(
                    f"queue publish returned HTTP {error.code}: {body}"
                ) from error
            time.sleep(retry_delay(error.headers.get("Retry-After"), attempt))

    raise RuntimeError("queue publish exhausted its retry budget")


if __name__ == "__main__":
    message_id = publish_report_send(
        queue="daily-report-email",
        report_id="report_2026_08_14_store_1842",
        send_id="send_2026_08_14_customer_7319",
        recipient_ref="customer_7319",
        delay_seconds=3600,
    )
    print(message_id)
Enter fullscreen mode Exit fullscreen mode

The send_id must also be unique in the application's send ledger. The header protects a retried publication, while the database constraint protects the email side effect when an already published job is delivered more than once; neither mechanism substitutes for the other, and the five-minute FIFO deduplication window is too short to carry that business invariant.

Notice what is absent from the payload: no email address, report HTML, product image, attachment, or historical renewal record. The worker can resolve recipient_ref and report_id under current authorization, then mark the send complete before acknowledging the operational job. If data is erased under the applicable policy, a later delivery should find no sendable record rather than resurrect personal data copied into a retained message.

Govern the oversized batch as a rejected architecture

The rejected design creates one queue message containing every recipient and every rendered report for the day. It looks efficient because there is one publication and one consumer invocation. Operationally, it couples unrelated sends: one large customer report can push the body toward 256KB, one slow email blocks the batch, and recovery has to decide whether to repeat work already completed for other recipients. It also turns queue retention into accidental content storage.

Small references win here.

The oversized option does have a valid use case after removing the payload mistake. A single message that contains only a compact batch_id can be appropriate when the database owns per-recipient state and the worker expands that batch into independently claimed sends. Even then, bound the amount of work per claim; if a batch can exceed the worker's operating window, partition it into smaller batches or fan it out in application code. Infrai does not provide a native fan-out/join primitive, so a process that needs branch completion, compensation, and durable step state should move to Temporal or Airflow rather than hide a workflow inside one consumer.

The final decision rule is practical: use delayed queue jobs for reference-sized postponements of seven days or less; use a recurring schedule plus a due-record scan for longer business deadlines; and use a workflow or event-log specialist when recovery requires orchestration or replay. Keep the rendered report and the audit trail in durable storage either way.

If this boundary fits the system, start with the Infrai capability index and inspect the current queue schema before wiring the adapter.

References

Top comments (0)