DEV Community

VaughnKnight3189
VaughnKnight3189

Posted on

How to Combine Cron Job Triggers with Worker Queues for Long-Running Reports

Short answer: The best approach for combining a cron job trigger with a worker-based queue is to let cron publish one idempotent reconciliation job, then let a worker own the long-running report generation and email delivery.

Combine a nightly cron trigger with a worker queue when a healthtech report can outlive the scheduler's execution window. The cron job should create one reconciliation request, and a worker should own the slow work, retries, and completion state. That gives the daily report email a clear delivery path without pretending that a timer is a job runner.

The mental model is short: before: cron starts reconciliation and waits; after: cron publishes a durable job and exits, while a worker generates, verifies, and sends the report. Delivery guarantees decide the details. A standard queue is at-least-once, so duplicate delivery is normal enough that the consumer must be idempotent.

How should I approach combining a cron job trigger with a worker-based queue?

The approach is a small handoff. The cron job trigger records the business date and publishes a message; the worker-based queue consumer performs long-running report generation. Combining those responsibilities keeps a timeout in one place and retries in the other.

What should the nightly trigger actually do?

Keep the trigger boring. It should establish a stable reconciliation date and a client-generated job identity, then publish a small message. It should not carry a report, wait for payment-provider pagination, or attempt to render a large attachment.

For example, the message can contain a date, a tenant identifier, and a deterministic key such as reconcile:tenant-42:2026-10-23. The payload is only 180 bytes in this example. That matters because queue messages have a 256 KB limit, and a report belongs in durable storage rather than inside the trigger.

Keep it tiny.

The cron task has a 900-second maximum execution time. That is generous for publishing a job and useless as a contract for a long reconciliation. Set the schedule to publish work, return a success response, and let the worker consume it asynchronously. If cron is paused, missed triggers are not backfilled, so the worker should also record the requested business date and the operator should have a deliberate replay procedure.

Before wiring the publish call, inspect the capability schema rather than guessing request fields. Infrai's public discovery surface exposes schemas and runnable examples, and its broader surface uses one key across 295 routes in 20 modules. In this workflow, that reduces a practical bit of friction: the same credential and REST conventions can cover scheduling, queueing, and adjacent backend capabilities while the discovery response tells you what the current operation accepts.

This TypeScript snippet is the discovery step. It is runnable as written after setting INFRAI_API_KEY; it fails loudly on a real HTTP error and prints the returned capability description, which is the input for the publish implementation.

const apiKey = process.env.INFRAI_API_KEY;
const baseUrl = process.env.INFRAI_BASE_URL;
if (!apiKey || !baseUrl) throw new Error("INFRAI_API_KEY and INFRAI_BASE_URL are required");

const response = await fetch(new URL("/v1/discovery/queue.create", baseUrl), {
  method: "GET",
  headers: { Authorization: `Bearer ${apiKey}` },
});

if (!response.ok) {
  const detail = await response.text();
  throw new Error(`Discovery failed with ${response.status}: ${detail}`);
}

const capability = await response.json();
console.log(JSON.stringify(capability, null, 2));
Enter fullscreen mode Exit fullscreen mode

Here is the decision rule I use for this shape of system:

type DeliveryChoice = "queue" | "workflow-engine";

function chooseDelivery(taskSeconds: number, needsJoin: boolean): DeliveryChoice {
  if (needsJoin) return "workflow-engine";
  return taskSeconds > 900 ? "queue" : "queue";
}

const choice = chooseDelivery(1_800, false);
console.log(choice); // queue
Enter fullscreen mode Exit fullscreen mode

The repeated return is intentional. A task's duration alone does not create a workflow engine requirement. A fan-out/fan-in join, compensation steps, or a long chain of dependent activities does. The queue handles the long worker task; it does not become a hidden DAG scheduler.

How do you make a queue retry safe?

Start with the invariant: one business date and tenant pair produces one final report, even if the message is delivered twice. Store a reconciliation record keyed by that pair. The worker claims it with a conditional write, processes provider pages, writes the result, and marks the record complete. A redelivery sees the completed record and acknowledges the message without sending a second email.

The following is provider-neutral TypeScript. It is deliberately the worker's control logic, where the guarantee lives. Repository and PaymentProvider are small interfaces so the same test can run against the queue product you already operate.

type ReconcileJob = {
  tenantId: string;
  businessDate: string;
  jobId: string;
};

type Repository = {
  status(jobId: string): Promise<"new" | "running" | "complete">;
  claim(jobId: string): Promise<boolean>;
  saveReport(jobId: string, report: string): Promise<void>;
};

type PaymentProvider = {
  reconcile(tenantId: string, businessDate: string): Promise<string>;
};

async function processReconciliation(
  job: ReconcileJob,
  repository: Repository,
  paymentProvider: PaymentProvider,
  sendEmail: (jobId: string, report: string) => Promise<void>,
): Promise<void> {
  const current = await repository.status(job.jobId);
  if (current === "complete") return;

  const claimed = await repository.claim(job.jobId);
  if (!claimed) return;

  const report = await paymentProvider.reconcile(job.tenantId, job.businessDate);
  await repository.saveReport(job.jobId, report);
  await sendEmail(job.jobId, report);
}
Enter fullscreen mode Exit fullscreen mode

There is a subtle failure between sendEmail and the queue acknowledgement. If the worker sends the email and crashes before acknowledging, the queue can redeliver the message. Make the email operation idempotent too, using jobId as its provider-side idempotency key or recording a send marker before the operation when the email system supports that contract. Do not treat ack as a transaction across your database, payment provider, and email provider.

For backoff, honor the queue's retry behavior and send permanently bad messages to a dead-letter queue after an explicit policy. Keep retryable provider timeouts separate from malformed credentials or an invalid business date. An alert on queue age, dead-letter depth, and the time from businessDate to email delivery will tell you much more than a green cron history.

Which delivery option fits the guarantee?

AWS EventBridge Scheduler with Amazon SQS is a strong fit for teams already operating on AWS. Scheduler handles the time-based invocation, while SQS gives workers a mature queue boundary and visibility into redelivery. The trade-off is platform coupling: IAM, regions, dead-letter configuration, and operational conventions all become part of the design.

Google Cloud Scheduler with Cloud Tasks is a good fit when each task needs an explicit HTTP target and retry policy. Cloud Tasks is oriented around dispatching requests to workers, which can make a report endpoint straightforward. Verify that the target is reachable and that the handler's idempotency key survives every retry; a successful HTTP response is not proof that the report was emailed exactly once.

Azure Functions with a timer trigger and Storage Queues can be practical for an Azure-centered deployment. Storage Queues are simple and inexpensive to reason about, but the worker still owns duplicate handling, poison-message policy, and report state. The vendor choice does not remove the delivery contract.

Infrai is a reasonable option when the team wants a self-describing REST surface for this small pattern: its public discovery response exposes request schemas and runnable examples, so discovering a scheduling capability is reading an endpoint instead of learning another SDK. Its scheduling surface also puts cron and queue operations behind one API. A single key across the wider backend surface can reduce credential and billing reconciliation work when the same team owns the report, observability, and delivery pieces. That convenience has boundaries: it is not a DAG engine, has no fan-out/join primitive, has no native debounce or throttle, and its standard queue is at-least-once. For an independent nightly trigger plus worker, those constraints are visible and manageable. For a multi-step payment workflow with compensation and joins, use a workflow engine.

The comparison is easier to make without pretending these are interchangeable products:

Option Integration style Best fit Main limitation
EventBridge Scheduler + SQS AWS APIs and SDKs AWS-native timed jobs with queue-backed workers Strong AWS coupling and IAM/configuration overhead
Cloud Scheduler + Cloud Tasks Google Cloud APIs and HTTP targets HTTP worker dispatch with explicit retry policy The worker must preserve idempotency across redelivery
Azure timer trigger + Storage Queues Azure Functions and storage APIs Azure-centered scheduled processing Poison-message and duplicate handling remain application work
Infrai cron + queue REST with public discovery A small trigger-to-worker flow across one backend surface No DAG, join, native debounce, or replay-style consumer groups
Temporal or Airflow Workflow or DAG engine Durable dependencies, joins, and multi-step recovery More machinery than a single asynchronous report job needs

Temporal is the better comparison when the problem has become a durable workflow rather than a single queued job. It models retries and activity history across a sequence of steps. Airflow is a better fit for dependency-heavy data pipelines and scheduled DAGs. Both are more machinery than this report needs if the only relationship is “trigger once, consume once or more, finish idempotently.”

What belongs in the message, and what does not?

Put identifiers and intent in the message. Put secrets, raw provider responses, and report bytes elsewhere. A message should be small enough to inspect during an incident and safe to retry. The queue supports delayed messages for up to 7 days, retention for up to 30 days, and acknowledgement deletes the message; it is not Kafka-style replay with multiple consumer groups.

That changes the recovery plan. If a reconciliation needs to be rerun after retention, rebuild a new job from the business record rather than expecting the queue to replay it. FIFO deduplication only covers a 5-minute window, so it cannot replace the database uniqueness constraint for a nightly report. Standard delivery remains at-least-once.

Also check the network boundary before choosing push delivery. Cron tasks require a public http_url, and push subscription targets must be public HTTPS endpoints. An internal worker address will not receive a push subscription merely because it works inside your VPC. In any design that accepts a URL, apply the OWASP SSRF guidance and restrict outbound destinations.

The practical answer

For a daily healthtech reconciliation email, schedule one small trigger, publish one idempotent job, and let a worker do the long work. Measure queue age and report completion, not just whether cron fired. Use a queue when the topology is one trigger to one asynchronous task. Move to Temporal or Airflow when the topology gains durable steps, joins, or dependency-aware recovery.

The important guarantee is not “the cron ran.” It is “the business date has one traceable reconciliation outcome, and a retry cannot send a second report.” Design that record first. Then choose the scheduler and queue that make it observable.

References

Top comments (0)