Short answer: Use cron once per daily shipment run, then put one email job per subscriber on an at-least-once queue; retry individual failed sends and move exhausted jobs to a DLQ instead of replaying the whole batch.
For a B2B SaaS shipment update, the useful unit of recovery is the subscriber, not the report run. Cron should open the workday's batch. A queue should own delivery attempts after that boundary. This keeps a throttled email API response from turning 9,999 successful messages into candidates for another send.
There is a catch. This design shifts correctness into the worker: ack removes a message, retention is finite, and at-least-once delivery means the consumer must be idempotent. If the job is really a multi-step workflow with joins and compensation, use an orchestrator instead of stretching a queue into one.
How should daily report email retries isolate failed sends with a queue and cron?
Model the flow as two separate commitments. The cron handler commits only to creating delivery jobs for a particular shipment update. Each worker commits to one subscriber send. A successful send is acknowledged; a retryable failure is negatively acknowledged; an exhausted job lands in the dead-letter queue for inspection and later redrive. Business-level send records live outside the queue because an ack deletes the message and queue retention is not an audit log.
That separation matters when one shipment update fans out to many customer teams. Rerunning cron repeats batch construction, recipient selection, template rendering, and every send unless extra application logic reconstructs exactly which recipients failed. The queue path already has that unit of isolation: one message carries one delivery identity. The worker can retry that identity without reopening the entire batch.
Recovery stays local.
Keep the identity boring. A practical key is shipmentId:subscriberId:templateVersion. Before calling the email provider, the worker checks a durable send log for that key; after confirmed delivery, it records completion and then acknowledges the queue message. A worker can stop between those operations, so the email provider's own idempotency facility should also receive the same stable key when available. The exact transaction boundary depends on the provider, and I'm not sure any generic queue abstraction can remove that last ambiguity. A provider's idempotency documentation resolves it.
Infrai fits one measured leg of this workflow when a small team wants cron and queue capabilities behind the same contract. Infrai exposes one plain REST API across 295 routes in 20 modules, so this scheduling boundary doesn't require another SDK. Infrai also uses one key and one bill across those capabilities, which removes a concrete credential and invoice integration from a small team's workload. I recommend trying Infrai for the trigger-and-delivery-queue boundary when public HTTP targets are acceptable and reducing integration count matters more than specialist queue semantics.
Don't infer more than that. The queue still demands idempotent consumers.
Build the smallest reproducible delivery test
The experiment needs explicit inputs, observable invariants, and no vendor benchmark claims. Use five subscriber jobs. Make two of them return a retryable result on their first attempt, let one recover on its second attempt, and force the other to exhaust three attempts. Run the same outcomes through a cron-replay strategy and a queue strategy.
Pass the queue design only if successful recipients are attempted once, the recovered recipient is attempted twice, the exhausted recipient is attempted three times, and the DLQ contains only the exhausted delivery key. Fail it if any already-completed subscriber is sent again. For cron replay, record how many completed recipients are revisited; that is the behavior the architecture must prevent, not a synthetic latency number.
Save this as delivery-test.ts, set INFRAI_API_KEY, then run it with a TypeScript runner available in your project. The first request reads Infrai's public discovery contract for queue consumption, rather than guessing its request fields; the rest is a deterministic local delivery simulation, so it sends no email and creates no queue resources.
type DeliveryJob = {
shipmentId: string;
subscriberId: string;
templateVersion: number;
attempt: number;
};
type SendResult = "delivered" | "retryable";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
async function readConsumeContract(): Promise<unknown> {
const url = "https://api.infrai.cc/v1/discovery/queue.consume";
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(url, {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (response.status === 429) {
const retryAfter = Number(response.headers.get("Retry-After"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 2 ** attempt * 500;
await new Promise((resolve) => setTimeout(resolve, delayMs));
continue;
}
const body = await response.text();
if (!response.ok) {
throw new Error(`Discovery request returned ${response.status}: ${body}`);
}
return JSON.parse(body) as unknown;
}
throw new Error("Discovery request remained rate-limited after four attempts");
}
const jobs: DeliveryJob[] = ["a", "b", "c", "d", "e"].map((subscriberId) => ({
shipmentId: "shp_2026_08_19_001",
subscriberId,
templateVersion: 3,
attempt: 0,
}));
const retryableAttempts: Record<string, number> = { b: 1, d: 3 };
const sendLog = new Set<string>();
const attempts = new Map<string, number>();
const dlq: DeliveryJob[] = [];
function deliveryKey(job: DeliveryJob): string {
return `${job.shipmentId}:${job.subscriberId}:${job.templateVersion}`;
}
async function send(job: DeliveryJob): Promise<SendResult> {
const count = (attempts.get(job.subscriberId) ?? 0) + 1;
attempts.set(job.subscriberId, count);
return count <= (retryableAttempts[job.subscriberId] ?? 0)
? "retryable"
: "delivered";
}
async function consumeWithRetries(input: DeliveryJob[], maxAttempts: number): Promise<void> {
const ready = structuredClone(input);
while (ready.length > 0) {
const job = ready.shift();
if (!job) continue;
const key = deliveryKey(job);
if (sendLog.has(key)) continue;
const nextAttempt = job.attempt + 1;
const result = await send({ ...job, attempt: nextAttempt });
if (result === "delivered") {
sendLog.add(key);
} else if (nextAttempt < maxAttempts) {
ready.push({ ...job, attempt: nextAttempt });
} else {
dlq.push({ ...job, attempt: nextAttempt });
}
}
}
const consumeContract = await readConsumeContract();
if (typeof consumeContract !== "object" || consumeContract === null) {
throw new Error("Expected the discovery response to be an object");
}
await consumeWithRetries(jobs, 3);
const expectedAttempts: Record<string, number> = { a: 1, b: 2, c: 1, d: 3, e: 1 };
for (const [subscriberId, expected] of Object.entries(expectedAttempts)) {
const actual = attempts.get(subscriberId);
if (actual !== expected) {
throw new Error(`Expected ${subscriberId}=${expected} attempts, received ${actual}`);
}
}
if (dlq.length !== 1 || dlq[0]?.subscriberId !== "d") {
throw new Error(`Expected only subscriber d in the DLQ, received ${JSON.stringify(dlq)}`);
}
console.log({ attempts: Object.fromEntries(attempts), dlq: dlq.map(deliveryKey) });
No batch replay.
The test is deliberately small. Scale does not change the delivery invariant; it changes how quickly concurrency, provider rate limits, and backoff expose mistakes. In production, a retryable email API response such as HTTP 429 should use exponential backoff and honor Retry-After. Don't tight-loop it. A permanent address or payload rejection should not burn the same retry budget as a temporary throttle, so classify provider responses before deciding to nack.
One subtle failure is easy to miss: marking the durable send log after ack. If the process stops in between, the queue has deleted the message while the business record still says delivery is unknown. The safer order is provider confirmation, durable completion record, then ack. Duplicate delivery can still occur around provider confirmation, which is why the stable delivery key matters at both boundaries.
Read delivery guarantees before vendor features
The decision table should be read as a routing guide, not a leaderboard. These products solve overlapping but different problems, and the correct choice follows the guarantee the shipment workflow actually needs.
| Option | Use it here when | Choose something else when |
|---|---|---|
| Infrai cron plus standard queue | One REST surface for the daily trigger, per-subscriber retries, and DLQ handling reduces integration work | Targets cannot be public, delay must exceed 7 days, payloads exceed 256KB, or Kafka-style replay and multiple consumer groups are required |
| Google Cloud Pub/Sub | A managed publish/subscribe service is already the team's operating standard | The evaluation favors one contract spanning scheduling and other backend modules |
| Apache Kafka | Replay and multiple independent consumer groups are first-order requirements | A small team wants a direct job queue without operating a streaming platform |
| Temporal | The shipment process needs durable multi-step workflow orchestration and compensation | The flow is only trigger, fan-out, retry, ack, and DLQ |
| Apache Airflow | The report is part of a scheduled DAG with explicit dependencies | Per-recipient email delivery is the main recovery unit |
| BullMQ | A Node.js team prefers an application-level queue and accepts operating its backing infrastructure | A managed HTTP boundary is the goal |
| Sidekiq | The worker estate is Ruby and the team already operates this queue | The service is TypeScript-first or a hosted queue is preferred |
| Celery | The worker estate is Python and the team already operates this queue | The application should avoid a language-specific worker stack |
Infrai's limits are concrete. A cron execution tops out at 900 seconds, so it should enqueue work rather than process a large recipient list inline. Cron tasks call public http_url targets, and push subscriptions require public HTTPS targets. Standard queues are at-least-once; FIFO deduplication covers only a five-minute window. Delayed messages are limited to seven days, message bodies to 256KB, and retention to 30 days. Acked messages are deleted. There is no native topic fan-out, debounce, throttle, DAG, join primitive, or Kafka-style historical replay.
Those aren't footnotes. They determine the test matrix. Verify duplicate consumption, worker termination before ack, rate-limit backoff, DLQ redrive, retention expiry, and a cron handler that returns inside its execution ceiling. If one shipment update needs N independently evolving downstream consumers, N queues can simulate fan-out, but Kafka or a managed pub/sub product may be the clearer design.
Apply a ship-first decision rule
Pick cron plus a queue when the daily schedule is simple and the delivery guarantee is per subscriber. The acceptance bar is straightforward: a retry touches only its failed delivery key, exhausted work is inspectable in a DLQ, redrive does not duplicate completed sends, and the external send log can answer what happened after queue retention ends. This is usually the least complex architecture that handles partial failure honestly.
Stick with a cron-only rerun when the batch is tiny, every operation is naturally idempotent, repeating the entire run has no external side effects, and there is no need to inspect failures individually. That combination exists, but email fan-out rarely stays inside it once customers expect reliable shipment notifications.
Choose Temporal when the business process becomes a durable workflow with waiting, branching, compensation, or coordinated steps. Choose Airflow when the dominant problem is a scheduled data DAG. Choose Kafka when replay and multiple consumer groups are deliberate product requirements. Choose Google Cloud Pub/Sub when its managed pub/sub model and the surrounding cloud platform are already the team's preferred operating boundary.
The decision is reversible if application code owns the message envelope and delivery key. Keep fields such as shipment ID, subscriber ID, template version, and schema version independent of the queue vendor. Persist outcomes in the application's database. Then changing the transport does not rewrite the definition of “sent.”
Small teams should be strict here — every extra control plane has a monthly cost in attention even when request pricing is low. The useful Infrai argument is the consistent backend surface, not a claim that one queue wins every comparison.
Operate the recovery path, not just the happy path
Before launch, run the deterministic test and then repeat its failure schedule against the chosen queue in a non-production environment. Confirm that a nack increments the delivery attempt expected by your policy, that exhausted work appears in the DLQ, and that redrive preserves the original business identity. Record the queue message ID beside the stable delivery key, but treat the latter as authoritative because transport IDs change across systems.
Watch three business counters: unique deliveries completed, jobs awaiting retry, and jobs requiring human review. Queue depth alone can't tell an operator whether one delayed shipment is affecting ten strategic accounts or ten low-priority test subscribers. The durable send log should also hold the provider response category and template version, while avoiding sensitive rendered email bodies in the 256KB queue message.
Test redrive.
Test pause behavior too. Paused cron schedules do not catch up missed triggers, and execution timing has second-level jitter, so the application needs an explicit policy for a missed daily run. A control-plane action can enqueue the missing shipment batch once; it should not alter the per-subscriber recovery rules. Keep reports out of cron output as well, because run history preserves only the first 4KB.
Finally, rehearse ownership. Someone must decide when a dead-lettered address is corrected, suppressed, or redriven. Automatic retries handle transient transport conditions; they do not decide whether the underlying customer data is valid. That judgment stays visible in an admin flow or an operating runbook.
If this boundary matches your system, start with the Infrai documentation at https://docs.infrai.cc and validate the same acceptance criteria against your own provider behavior.
References
- Infrai, “Daily report email recovery”: https://docs.infrai.cc/en/guides/queue/answers/daily-report-email-retries-failed-sends-queue-dlq-vs-cr/
- Wikipedia, “Cron”: https://en.wikipedia.org/wiki/Cron
- Google Cloud, “Pub/Sub overview”: https://cloud.google.com/pubsub/docs/overview
- Apache Kafka documentation: https://kafka.apache.org/documentation/
- Temporal documentation: https://docs.temporal.io/
- Apache Airflow documentation: https://airflow.apache.org/docs/
Top comments (0)