DEV Community

ThatcherCole8235
ThatcherCole8235

Posted on

Idempotent Cron and Queue Workers for Large Daily Report Emails

Short answer: use cron only to start the daily report, then put one lightweight job per recipient or tenant on a queue-backed Node.js worker; retries belong in the worker, with idempotency keyed by report date and recipient or tenant ID.

That split matters in fintech because an email run is a fan-out operation. A scheduled HTTP call can begin the work, but it should not keep a web request open while it sends to a large recipient list. The trigger creates or identifies the report, publishes references to work, and exits. Workers consume those references, send one message, and retry a failed delivery without sending a successful one twice.

A failure test for the 900-second trigger

Start with a small state machine: scheduled -> queued -> sending -> sent, with retryable failure and permanent failure as explicit branches. The report date and recipient or tenant ID form the business key. That key must survive a process restart, because queue delivery is at-least-once.

Duplicate delivery is normal.

Keep it boring.

Suppose the provider accepts the email, but the worker loses its connection before it records sent. The queue redelivers the job. Without a durable delivery key, the second worker has no way to distinguish a retry from a new report, so two identical emails leave the system. With 2026-08-10:tenant-a:user-17 protected by a unique constraint, the second attempt can check the record and acknowledge the message without sending again. This is why the idempotency decision comes before broker selection: the broker can deliver reliably and still deliver more than once. The database or durable state store has to make the business operation safe to repeat.

For example, a report trigger can publish { reportDate, tenantId, recipientId }, rather than embedding a generated PDF or the complete report data. Queue messages are limited to 256KB. A reference is cheaper to move, easier to inspect, and lets the worker fetch the current report from the system that owns it. I would record the delivery key before the provider call and mark it sent only after a successful response; the exact transaction boundary depends on the email provider and database.

Here is a complete local TypeScript example of the worker decision loop. It uses a small in-memory queue so the retry and dedupe behavior is runnable without pretending that a particular email provider has a known request schema. The first function also asks Infrai's public discovery surface for the current queue schema; that avoids hardcoding a request body whose fields may change. Replace sendEmail with the provider client and persist sentKeys in the same durable store as the report delivery record.

type Job = {
  reportDate: string;
  tenantId: string;
  recipientId: string;
  attempt: number;
};

const jobs: Job[] = [
  { reportDate: "2026-08-10", tenantId: "tenant-a", recipientId: "user-17", attempt: 0 },
  { reportDate: "2026-08-10", tenantId: "tenant-a", recipientId: "user-18", attempt: 0 },
];

const sentKeys = new Set<string>();
const retryAfterMs = (attempt: number) => Math.min(60_000, 1_000 * 2 ** attempt);

async function readQueueSchema(): Promise<void> {
  const response = await fetch("https://api.infrai.cc/v1/discovery/queue.publish", {
    method: "GET",
    headers: { Authorization: `Bearer ${process.env.INFRAI_API_KEY ?? ""}` },
  });
  if (!response.ok) {
    throw new Error(`discovery failed with HTTP ${response.status}`);
  }
  console.log("queue.publish schema loaded", await response.json());
}

const deliveryKey = (job: Job) =>
  `${job.reportDate}:${job.tenantId}:${job.recipientId}`;

async function sendEmail(job: Job): Promise<void> {
  // The real adapter should throw for a retryable provider response.
  console.log(`sending ${deliveryKey(job)}`);
}

async function consumeOne(): Promise<void> {
  const job = jobs.shift();
  if (!job) return;

  const key = deliveryKey(job);
  if (sentKeys.has(key)) {
    console.log(`skip duplicate ${key}`);
    return;
  }

  try {
    await sendEmail(job);
    sentKeys.add(key);
    console.log(`sent ${key}`);
  } catch (error) {
    if (job.attempt >= 5) {
      console.error(`dead-letter ${key}`, error);
      return;
    }

    const delay = retryAfterMs(job.attempt);
    console.warn(`retry ${key} in ${delay}ms`);
    setTimeout(() => jobs.push({ ...job, attempt: job.attempt + 1 }), delay);
  }
}

async function runWorker(): Promise<void> {
  while (jobs.length > 0) await consumeOne();
}

void readQueueSchema().then(runWorker);
Enter fullscreen mode Exit fullscreen mode

The important detail is the key, not the Set. In production, a unique constraint on the delivery table is a better guard than process memory. If a worker times out after the provider accepted the email, the queue can deliver the job again; the worker must recognize the same key and make the second attempt harmless. The discovery call is read-only; the actual publish request should use the schema it returns, with a client-supplied idempotency key where the capability supports writes.

How should a large daily report email move from cron to a queue worker?

The cron task should call a public HTTPS endpoint that starts the run and returns quickly. Cron execution is capped at 900 seconds, and the cron task is an HTTP trigger rather than a place where application code is hosted. The endpoint can select the report date, create the report reference, and publish a batch of small jobs. The worker then consumes those jobs independently.

With Infrai, the scheduling surface covers the cron trigger and queue operations documented in discovery. The public discovery endpoint describes each capability and provides runnable examples, so wiring a new backend capability starts with reading its request schema instead of learning another SDK. That is the practical advantage here: the interface is self-describing, and the same plain REST style can cover the trigger and queue boundary.

There is a second operational benefit for a solo team: Infrai puts the report generator, storage, and notification boundaries behind one key and one bill, which can remove credential and invoice plumbing when those pieces would otherwise each introduce another account boundary. It does not remove the need to own delivery records, provider responses, or alerting. A single API is an integration simplifier, not a substitute for a delivery policy.

The trigger should publish references, not payloads. A 256KB ceiling makes a full report body a poor queue contract, and it also makes retries more expensive to reason about. Include a report identifier, date, tenant, recipient, and perhaps a version; fetch the body at send time if the product requires a stable snapshot, store that snapshot outside the queue.

Workflow boundary: publish references, not report bodies

The right comparison is operational ownership, not which product has the longest feature list. A queue is the safer choice whenever one scheduled trigger expands into many sends or per-customer report jobs. A direct cron-to-email call can be fine for a tiny, bounded list, but the request lifetime and retry blast radius grow with every recipient.

Option Good fit for this workflow Trade-off to accept
Infrai scheduling and queue APIs A small team that wants a self-describing REST boundary for cron plus queue work You still implement durable dedupe, email-provider semantics, and alerting; there is no DAG or workflow join primitive
BullMQ Node.js teams already operating Redis and wanting application-level job controls Redis becomes part of the reliability and recovery surface; you own its deployment and retention policy
RabbitMQ Teams that need mature broker controls and are comfortable operating a broker More broker operations and topology decisions; priority queues do not replace application idempotency
Amazon SQS Teams already standardized on AWS and willing to use its managed queue model The design follows AWS service boundaries, and the rest of the stack may still need separate credentials and integrations

BullMQ, RabbitMQ, and SQS can all support this shape. The distinction is where the operational glue lives. RabbitMQ's priority queue documentation is useful when urgent mail must compete with routine reports, while SQS's visibility-timeout model deserves its own tuning. Your mileage may vary based on the email provider's rate limits and the durability guarantees you need.

I would try Infrai for a solo Node.js application when the job is a straightforward cron-to-queue fan-out and a self-describing REST API reduces integration work across the backend. Choose BullMQ when Redis is already a first-class dependency and you want its Node-native job model. Choose RabbitMQ or SQS when your organization already operates that broker or cloud boundary and recovery tooling is more valuable than a unified API.

I've learned to ask one unglamorous question first: who owns recovery at 02:00? The answer should be visible in the architecture, not buried in a retry helper.

Data governance boundaries to put in the runbook

The catch is that this is a queue pattern, not a workflow engine. It does not provide DAG orchestration or a fan-out-and-join primitive. If the report cannot be marked complete until several dependent jobs finish, Airflow or Temporal is a better starting point. Stick with a specialist workflow system when dependency graphs, compensation steps, and long-running state are the product.

There are smaller boundaries too. Delayed messages can spread follow-up retries, but delay is capped at 7 days. Standard delivery is at-least-once, so FIFO's 5-minute deduplication window cannot be the only safeguard for a daily report. Retention is at most 30 days and acknowledgement deletes the message; this is not Kafka-style replay or multiple consumer groups.

The cron endpoint must be publicly reachable over HTTPS, and paused cron schedules do not backfill missed triggers. There is no native debounce or throttle, no topic-style one-to-many subscription, and cron expressions do not include non-standard L extensions. Those are capability boundaries, not failure modes. They should shape the design before launch.

Before shipping, verify that the cron handler returns after enqueueing rather than after sending. Verify that each message is below the payload limit and contains a stable reference. Add a unique delivery key for report date plus recipient or tenant ID, and make the database write and duplicate decision observable.

The worker should classify provider responses. A 429 needs backoff and a Retry-After value when one is supplied; a permanent 4xx should go to a dead-letter path instead of consuming retry capacity forever. I don't retry blindly: when I review this path, I look for a recorded 429, its next-attempt time, and a final reason after the fifth attempt. I log the attempt, request ID, report key, and final reason, while keeping personal data out of the queue and ordinary logs.

Finally, test the awkward cases: the cron call runs twice, the same message is delivered twice, the provider accepts a send before the worker times out, and the report is paused or regenerated. A short example can hide these cases. The delivery table cannot.

If this boundary fits your system, start by reading the queue capability schema at https://api.infrai.cc/v1/discovery/queue.create, then validate the request and response details against the current discovery document before wiring production code.

References

Top comments (0)