Daily report emails: one cron run, or a queue job per tenant?

A nightly report job that grows with your tenant count needs a queue behind the cron trigger. The decision rule, the idempotency key that stops double sends, and the Node worker.

Keep the cron. Move the work. A scheduled trigger is the right way to say “it’s 06:00, produce yesterday’s reports”, and completely the wrong way to actually produce four thousand of them — because one slow tenant, one timeout, one malformed dataset takes the whole run down with it. Publishing one message per tenant onto Infrai’s queue turns that into four thousand independent outcomes, each retried on its own, with the ones that never succeed collected somewhere you can look.

The threshold where this stops being over-engineering is lower than people expect: roughly the point where a single run takes longer than the gap between runs.

When one cron run is genuinely fine

Don’t split a job that doesn’t need splitting. If the report is one query and one email to your own team, a cron entry and twenty lines of Node is the correct architecture and a queue is ceremony.

Three conditions make it fine: the whole run finishes comfortably inside its window, a rerun is harmless, and nobody notices if it’s late. Break any one of them and the calculus changes.

Blast radius of one failureCost of a restartWhere you look when it’s wrongOps surface
Single cron scriptthe entire runredo everythingone log filea scheduler
Cron trigger + queueone tenantredo one messagethe DLQ, per tenanta scheduler + a queue
Queue with a staggered publishone tenantredo one messagethe DLQ, plus arrival timesas above
Workflow engine (Temporal)one workflowresume mid-stepthe workflow historya cluster to run

Amazon SQS gives you the second row with native dead-letter support, and it’s the obvious choice if the reports are already generated inside Lambda. BullMQ gives you the second row plus concurrency controls in-process, at the price of running Redis. The rows differ less in capability than in how much of your evening they consume.

The planner publishes, then exits

The nightly trigger should be the dullest code in the repo: list tenants, publish one message each, log the count, exit. It shouldn’t render anything.

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X POST "https://api.infrai.cc/v1/queue/publish" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"queue":"daily-reports","body":{"tenant_id":"t_2049","report_date":"2026-07-25","recipients":["ops@example.com"]}}'

Two properties of that publish matter for a report job. The queue and its daily-reports.dlq companion are created implicitly the first time you call it, and the response comes back with a message_id you can store against the tenant row.

{
  "ok": true,
  "data": {
    "message_id": "qmsg_mLM2Yikf0cUxC8AwGtuRypPk",
    "queue": "daily-reports",
    "payload": {
      "tenant_id": "t_2049",
      "report_date": "2026-07-25",
      "recipients": ["ops@example.com"]
    },
    "status": "available",
    "delivery_count": 0,
    "published_at": "2026-07-26T00:56:28.675108Z"
  }
}

The key that stops the double send

A daily report has a natural unique identity — this tenant, this date — and publish accepts an idempotency_key. Send the same key twice and the second call returns the same message_id rather than enqueueing a second report, which we verified against the live API. That single field removes the most common outage in nightly jobs: the planner half-finished, somebody reran it, and every tenant who was already done got a duplicate.

Two caveats attach to it. The batch endpoint doesn’t apply the same check per entry, so a retried batch really does duplicate — loop single publishes if the guarantee matters. And a delayed publish is capped at 604800 seconds, seven days; ask for more and you get an HTTP 400 whose text claims the queue already exists, which is not what went wrong and will send you looking in the wrong place.

import process from "node:process";
import pg from "pg";

const API = "https://api.infrai.cc";
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const key = process.env.INFRAI_API_KEY;
if (!key) throw new Error("INFRAI_API_KEY is required");

const reportDate = new Date(Date.now() - 86400_000).toISOString().slice(0, 10);

export async function plan() {
  const { rows } = await pool.query(
    "SELECT id, report_recipients FROM tenant WHERE plan != 'free' AND reports_enabled ORDER BY id",
  );
  let published = 0;
  for (const [i, t] of rows.entries()) {
    const message = {
      queue: "daily-reports",
      body: { tenant_id: t.id, report_date: reportDate, recipients: t.report_recipients },
      idempotency_key: `report:${reportDate}:${t.id}`,
      delay_seconds: Math.min(600, Math.floor(i / 50) * 30),   // stagger, 50 per half minute
    };
    const res = await fetch(`${API}/v1/queue/publish`, {
      method: "POST",
      headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
      body: JSON.stringify(message),
    });
    const out = await res.json();
    if (!out.ok) { console.error(`tenant ${t.id}: ${out.error.code} ${out.error.message}`); continue; }
    published += 1;
  }
  console.log(`planned ${published}/${rows.length} reports for ${reportDate}`);
  return published;
}

The stagger is worth explaining. Publishing 4,000 messages that all become available at 06:00 means your workers hammer the mail provider in one burst and start collecting 429s; spreading availability across ten minutes costs nothing and keeps the send rate under whatever your provider allows.

Cheap insurance.

The worker owns one report

import process from "node:process";

const API = "https://api.infrai.cc";
const headers = {
  Authorization: `Bearer ${process.env.INFRAI_API_KEY}`,
  "Content-Type": "application/json",
};

async function call(path, payload) {
  const res = await fetch(`${API}${path}`, { method: "POST", headers, body: JSON.stringify(payload) });
  const out = await res.json();
  if (!out.ok) throw new Error(`${path}: ${out.error.code} ${out.error.message}`);
  return out.data;
}

async function buildAndSend(job) {
  const renderUrl = `${process.env.REPORT_SERVICE}/render`;
  const report = await fetch(renderUrl, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ tenant_id: job.tenant_id, date: job.report_date }),
    signal: AbortSignal.timeout(120_000),
  });
  if (!report.ok) throw new Error(`render failed with ${report.status}`);
  const { html } = await report.json();

  const mailUrl = `${process.env.MAILER_URL}/send`;
  const mail = await fetch(mailUrl, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ to: job.recipients, subject: `Daily report ${job.report_date}`, html }),
    signal: AbortSignal.timeout(30_000),
  });
  if (!mail.ok) throw new Error(`mailer returned ${mail.status}`);
}

export async function drain() {
  const { items } = await call("/v1/queue/consume", { queue: "daily-reports", max_messages: 5 });
  for (const msg of items) {
    try {
      await buildAndSend(msg.payload);
      await call("/v1/queue/ack", { queue: "daily-reports", receipt_handle: msg.message_id });
      console.log(`sent ${msg.payload.tenant_id}`);
    } catch (err) {
      console.error(`attempt ${msg.delivery_count} for ${msg.payload.tenant_id}: ${err.message}`);
      await call("/v1/queue/nack", { queue: "daily-reports", message_id: msg.message_id });
    }
  }
  return items.length;
}

Rendering a large report can outrun the 300-second default visibility timeout, at which point the lease lapses, the message is handed to a second worker, and one tenant gets two identical emails. If a render can take minutes, ack after the render and record the send state yourself rather than holding the lease across both steps.

The morning check

curl -sS "https://api.infrai.cc/v1/queue/stats/daily-reports" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

curl -sS -X POST "https://api.infrai.cc/v1/queue/consume" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"queue":"daily-reports.dlq","max_messages":25}'

Two counters carry the whole verdict. available_count at 0 by 07:00 means every message was picked up and the night went well; anything still sitting there means the workers stopped, scaled to zero, or are pointed at a queue name that doesn’t match what the planner published to, and the fastest way to tell those apart is whether in_flight_count is moving at all. Anything in dlq_count is a named tenant who didn’t get their report — read the payloads by consuming the .dlq queue by name, since the dedicated listing route came back empty in our testing even when the counter said otherwise. Once the cause is fixed, queue.dlq.redrive puts a message back one message_id at a time; the bulk form currently fails.

What the split costs

Publishing is the only billable call here: $0.00002 per message, verified 2026-07-26. Consume, ack, nack, stats and DLQ reads are free within rate limits, and retries cost nothing extra because only the original publish was metered. Four thousand tenants every day is about 120,000 publishes a month, roughly $2.40 — less than the compute you’ll save by not rerunning a six-hour job from the top.

curl -sS "https://api.infrai.cc/v1/account/usage" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

Read that rather than this paragraph when you budget; rates on this platform have moved down over time and campaigns run.

The other half of the bill is the mail itself, and transactional email runs on the same key and the same invoice as the queue — so per-tenant cost for the nightly report is a query against one account rather than a reconciliation between a queue vendor and an email vendor.

References

Browse more queue developer guides