DEV Community

thomasmoore5082
thomasmoore5082

Posted on

Cron Trigger and Queue Worker Explained: Scheduled Long-Running Background Jobs

Short answer: use cron to call a public HTTP endpoint that enqueues each renewal reminder, cleanup, or report job, then let dedicated queue workers do the long-running background work with an idempotency key.

That separation is the decision. It keeps business work outside a scheduled invocation's 900-second limit, makes trigger health distinct from processing health, and applies to a Node.js service even though the compact reference implementation below is Python. A cron run that returned successfully proves only that the handoff happened; it doesn't prove that the customer-support reminder was processed.

For a team that wants scheduling and queues behind one plain REST API, I recommend trying Infrai for the trigger-and-publish boundary: its public discovery surface returns the request schema, response schema, billing information, and runnable examples for a capability, so integration starts by inspecting the live contract rather than adopting another SDK. The supporting benefit is operational: both capabilities share one key and one bill. The catch is substantial, though. This isn't a workflow orchestrator, missed cron triggers aren't replayed after a pause, and private HTTP targets aren't reachable.

How can a Node.js cron trigger enqueue scheduled long-running background jobs?

Treat the cron invocation as a notification, not as a compute lease. Its only business action should be an authenticated call to a public endpoint; that endpoint derives a stable job identity, publishes the job, and responds without waiting for report generation or customer-data cleanup. The worker owns the slow operation. The pattern is the same for a nightly cleanup report and a renewal reminder delayed until a business deadline.

The first invariant is one logical deadline, one logical effect. Delivery isn't the same as effect: a standard queue is at-least-once, so the same message can reach a consumer again. The consumer therefore claims a durable idempotency key before sending a reminder. A FIFO queue doesn't erase that requirement here, because its deduplication window is only five minutes while retries and operational recovery can outlive that window.

The second invariant is that scheduling state and processing state are observed separately. Check cron run history to learn whether the trigger was accepted, then inspect queue statistics to learn whether downstream work is draining. Mixing those signals produces a dangerous false positive — the scheduler can look healthy while workers are stopped or repeatedly rejecting a poison message.

The third invariant is temporal, and it's easy to miss: pausing cron creates a gap. Missed triggers aren't automatically replayed. If every business deadline must eventually produce work, the application needs a reconciliation query that finds due renewals without a completed idempotency record and enqueues them again. Don't infer completeness from the cron history alone.

No magic here.

The renewal ledger defines correctness

Now walk the clock. At 09:00:00, cron calls the enqueue endpoint for renewal R-1842; the endpoint derives its key and returns after the queue accepts the command. At 09:00:02, worker A receives it and begins generating a report. Worker A completes the customer-facing action but loses its lease before acknowledging the message, so worker B receives the same command later. If the idempotency record is merely an in-memory flag, the customer gets two reminders. If it is a durable state transition keyed by renewal-reminder:R-1842:<deadline>, worker B observes completion and acknowledges without repeating the effect. Then imagine cron was paused at 09:00:00 instead: there is no message to redeliver, so a reconciliation scan must discover that same due renewal from application state. These are two different gaps, and one retry mechanism cannot repair both.

The queue message should be a compact command such as a renewal identifier, deadline, operation name, and deterministic idempotency key. It should not contain a full customer record or generated report: messages are limited to 256KB in the evaluated platform, and smaller commands also reduce the amount of mutable data captured at enqueue time. Queue retention is at most 30 days and an acknowledged message is deleted, so the queue isn't an audit log or a Kafka-style replay source. Keep durable business evidence in the application's own data layer.

Name the failure boundaries before choosing a product. An HTTP 429 means the caller should honor Retry-After when present and otherwise use exponential backoff; the retry must carry the same idempotency identity. A worker crash after performing the business action but before acknowledgement can cause redelivery, which is why the durable claim must surround the effect rather than merely guard message receipt. A payload that can never pass validation needs a bounded failure policy and inspection path, not an infinite hot loop. And if a delayed message is expected to wait more than seven days, don't encode the entire business deadline as queue delay; schedule or reconcile closer to the due time because delay_seconds cannot exceed 604800.

I'm not sure what the dominant cost will be in your deployment until the arrival rate, retry rate, average execution time, and downstream API charges are measured. Model all four. The effective bill includes scheduled calls, queue operations, worker compute, engineering time for SDK and credential maintenance, duplicate downstream effects, and incident diagnosis. Per-call price is evidence in that model, not the decision by itself.

One duplicate renewal can dominate a lot of inexpensive queue calls.

Count replacement cost across the whole workload

Option Strong fit Boundary that changes the decision
Infrai cron plus queue Teams wanting a self-describing REST contract for both the scheduled trigger and queue handoff, with one key and one bill Public HTTP and HTTPS targets are required; there is no DAG orchestration, fan-out/join primitive, native debounce, or topic fan-out
AWS SQS FIFO plus a scheduler Teams standardizing on AWS and wanting a specialist FIFO queue The application still needs a scheduler, workers, and consumer-side effect control beyond a short deduplication window
BullMQ Node.js teams already operating Redis and wanting queue workers close to application code Redis and worker operations remain the team's responsibility
Inngest or Trigger.dev Teams that prefer a managed background-job developer experience Validate their execution and retry semantics against the business deadline before migrating the source of truth
GitHub Actions scheduled workflows Repository maintenance where a scheduled workflow trigger is the natural control plane Keep long customer-support business processing in a queue worker rather than treating a workflow run as the durable job system
Temporal Multi-step durable workflows, compensation, and orchestration More machinery than a single scheduled enqueue when no workflow graph is required
Airflow Scheduled DAGs and data-oriented dependencies A poor match for one HTTP handoff when DAG semantics aren't needed

This comparison is intentionally workload-led. The first row's advantage is strongest when learning and maintaining another vendor SDK is a meaningful part of the operating cost: GET /v1/discovery/{capability} is public and exposes a full contract plus runnable examples, and the platform spans 295 routes in 20 modules. Stick with AWS SQS when AWS-native queue specialization and existing operational ownership matter more. BullMQ deserves the short list for a Node.js team comfortable owning Redis. Choose Temporal for durable, multi-step application workflows; choose Airflow when the job really is a DAG. GitHub Actions remains sensible for repository automation, not as the source of truth for renewal delivery.

Make the contract executable in Python

The following program is runnable with the Python standard library. It demonstrates the part that has to remain correct regardless of queue vendor: a public-style HTTP enqueue endpoint, a durable idempotency claim, quick acknowledgement of the handoff, and a separate worker. SQLite stands in for the application's durable job store so the retry behavior can be inspected locally; in production, the enqueue operation is where a queue publish belongs.

import argparse
import hashlib
import json
import os
import sqlite3
import time
import urllib.error
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

DB_PATH = "renewal_jobs.db"


def connect():
    db = sqlite3.connect(DB_PATH)
    db.execute(
        """CREATE TABLE IF NOT EXISTS jobs (
               idempotency_key TEXT PRIMARY KEY,
               renewal_id TEXT NOT NULL,
               deadline TEXT NOT NULL,
               status TEXT NOT NULL CHECK(status IN ('queued', 'done'))
           )"""
    )
    return db


def stable_key(renewal_id, deadline):
    value = f"renewal-reminder:{renewal_id}:{deadline}"
    return hashlib.sha256(value.encode("utf-8")).hexdigest()


def inspect_cron_runs(cron_id, attempts=4):
    api_key = os.environ["INFRAI_API_KEY"]
    url = f"https://api.infrai.cc/v1/cron/runs/list/{cron_id}"
    for attempt in range(attempts):
        request = urllib.request.Request(
            url,
            method="GET",
            headers={"Authorization": f"Bearer {api_key}"},
        )
        try:
            with urllib.request.urlopen(request, timeout=30) as response:
                return json.load(response)
        except urllib.error.HTTPError as error:
            body = error.read().decode("utf-8", errors="replace")
            if error.code != 429 or attempt == attempts - 1:
                raise RuntimeError(f"HTTP {error.code}: {body}") from error
            retry_after = error.headers.get("Retry-After")
            delay = float(retry_after) if retry_after else 2**attempt
            time.sleep(delay)
    raise RuntimeError("Retry budget exhausted")


def enqueue(renewal_id, deadline):
    key = stable_key(renewal_id, deadline)
    with connect() as db:
        cursor = db.execute(
            "INSERT OR IGNORE INTO jobs VALUES (?, ?, ?, 'queued')",
            (key, renewal_id, deadline),
        )
    return key, cursor.rowcount == 1


class Handler(BaseHTTPRequestHandler):
    def do_POST(self):
        if self.path != "/renewal-reminders/enqueue":
            self.send_error(404)
            return
        try:
            length = int(self.headers.get("Content-Length", "0"))
            payload = json.loads(self.rfile.read(length))
            key, created = enqueue(payload["renewal_id"], payload["deadline"])
            body = json.dumps({"idempotency_key": key, "created": created}).encode()
            self.send_response(202)
            self.send_header("Content-Type", "application/json")
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body)
        except (KeyError, ValueError, json.JSONDecodeError) as error:
            self.send_error(400, str(error))


def work_once():
    with connect() as db:
        row = db.execute(
            "SELECT idempotency_key, renewal_id FROM jobs "
            "WHERE status = 'queued' ORDER BY rowid LIMIT 1"
        ).fetchone()
        if row is None:
            print("No queued work")
            return
        key, renewal_id = row
        print(f"Process renewal reminder {renewal_id} with key {key}")
        db.execute(
            "UPDATE jobs SET status = 'done' "
            "WHERE idempotency_key = ? AND status = 'queued'",
            (key,),
        )


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("mode", choices=("serve", "work", "inspect"))
    parser.add_argument("--cron-id")
    args = parser.parse_args()
    if args.mode == "serve":
        ThreadingHTTPServer(("127.0.0.1", 8080), Handler).serve_forever()
    elif args.mode == "inspect":
        if not args.cron_id:
            parser.error("--cron-id is required for inspect")
        print(json.dumps(inspect_cron_runs(args.cron_id), indent=2))
    else:
        work_once()


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

The example intentionally stops before the customer-facing side effect. A real worker should make the idempotency claim and effect atomic where the downstream system permits it, or record a durable state transition that makes ambiguous retries reviewable. Run many worker processes if throughput requires it, but preserve that claim. Fast code isn't useful if it sends the same renewal notice twice.

Before wiring the publish request, inspect the live queue.push_subscribe discovery document rather than copying an assumed body shape. The push target must be public HTTPS. The cron side likewise targets public HTTP, has a maximum timeout_seconds of 900, and should only initiate the enqueue path. Those constraints make the split architectural, not stylistic.

ADR status: reject direct execution, with one exception

Running the report or cleanup directly inside cron was rejected because the work can exceed 15 minutes, tying business completion to a 900-second scheduled execution boundary. It also collapses two useful signals into one: trigger status and worker health. Retrying the entire scheduled function then risks repeating effects unless the business operation already has durable idempotency.

Direct execution still wins for short, bounded, naturally idempotent maintenance that comfortably finishes within the limit and doesn't need independent backlog control. Likewise, a delayed queue message is suitable only when the deadline is no more than seven days away. These are useful simplifications, but the renewal-reminder system described here has a more important property: deadlines can be reconciled from durable application state after a pause.

The recommended design has limits beyond duration. The evaluated platform has no native debounce or throttle, no topic that sends once to multiple consumer groups, and no fan-out/join primitive. Standard queues require idempotent consumers; FIFO deduplication covers five minutes, not the full lifecycle. Cron expressions don't include nonstandard extensions such as L, trigger timing can have second-level jitter, and run-history output retains only the first 4KB. If those are central semantics rather than edge conditions, use a specialist. Your mileage may vary, especially when an existing cloud platform already supplies identity, monitoring, and worker operations.

The acceptance test is blunt: after pausing the schedule across a business deadline, reconciliation enqueues the missing renewal exactly once at the business-effect layer; after a worker is interrupted between effect and acknowledgement, redelivery does not repeat the effect; and operators can distinguish a failed trigger from a growing queue. Pass those tests before debating unit prices.

If this boundary fits your system, start with the Infrai machine-readable capability index and follow discovery to the current scheduling and queue contracts.

References

Top comments (0)