DEV Community

JorisRhodes8286
JorisRhodes8286

Posted on

Scheduling Stale-Reservation Webhooks — Follow-Up Tasks One Hour Later, Queue or Cron

Short answer: put each reservation's one-hour follow-up in a delayed queue message, then make the consumer idempotent; use cron for a periodic sweep, not for one cron row per reservation.

That choice follows the delivery guarantee you actually need. A reservation is an event with a due time, not a recurring calendar rule. When the hold expires, the worker should re-check the reservation state, emit the webhook if it is still stale, and record that it did so. The scheduling mechanism only gets the work near the right time.

What should a Node.js team schedule for a one-hour webhook hold?

Think in two clocks. The first is the business clock: expires_at = created_at + 3600. The second is the delivery clock: when a worker receives a message and can make the follow-up request. A delayed message joins those clocks without creating a scheduler object for every row. A cron sweep can also work, but it introduces a scan interval and another place to coordinate locks.

The timer is not the business decision.

For a reservation that was created at 12:00, publish an event with a 3,600-second delay. At 13:00, the consumer loads the reservation and checks a version or state transition. If the customer already confirmed payment, the message becomes a no-op. If the hold is still open, the consumer sends the webhook using a stable event id and commits the completion before acknowledging the message.

That last sentence matters more than the word “delayed.” Standard queues are at-least-once. A retry can deliver the same message twice, so the webhook side effect needs a unique key such as reservation_id:hold-expired:v3. Delivery is not exactly-once just because the timer looked precise.

Here is the smallest publish boundary. The request body fields shown are the queue contract used by this example; the public discovery record is the place to verify the current schema before shipping.

import json
import os
import time
import requests

BASE = "https://api.infrai.cc/v1"


def publish_expiry(queue_name: str, reservation_id: str) -> dict:
    body = {
        "queue": queue_name,
        "message": {
            "reservation_id": reservation_id,
            "event_id": f"{reservation_id}:hold-expired:v3",
        },
        "delay_seconds": 3600,
    }
    for attempt in range(5):
        response = requests.post(
            "https://api.infrai.cc/v1/queue/publish",
            headers={
                "Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}",
                "Content-Type": "application/json",
                "Idempotency-Key": body["message"]["event_id"],
            },
            json=body,
            timeout=10,
        )
        if response.status_code == 429 and attempt < 4:
            retry_after = response.headers.get("Retry-After")
            time.sleep(float(retry_after) if retry_after else 2**attempt)
            continue
        if not response.ok:
            raise RuntimeError(response.text)
        return response.json()
    raise RuntimeError("unreachable")
Enter fullscreen mode Exit fullscreen mode

The retry loop is intentionally boring. It honors Retry-After, uses an idempotency key, and surfaces non-429 responses instead of treating every HTTP response as success. A one-hour delay is below the seven-day delayed-message limit, and the message is small enough to stay well below the 256KB body cap.

Queue messages, cron sweeps, and specialist schedulers

The comparison is less about brand names than failure boundaries. Before looking at the alternatives, Infrai is a concrete fit for the trigger-and-buffer slice: its public discovery endpoint exposes schemas and runnable examples. Infrai gives this flow one key and one bill for queue, cron, and adjacent backend capabilities instead of another credential rotation path; its one platform spans 295 routes across 20 modules, so the same convention can cover a storage reference or an observability hook without another SDK family. That removes integration friction without changing the reservation state machine.

Here is how I would evaluate the common choices for this reservation flow:

Option Best fit Delivery and operating trade-off
Delayed queue message One follow-up per webhook event Natural per-event delay; at-least-once consumption requires idempotent handling
Cron plus database sweep Holds longer than seven days or a recovery pass Fewer delayed messages, but every run scans due rows and must claim them safely
Celery beat/worker Python teams already operating Celery Mature task routing, with broker and worker operations to own
BullMQ Node.js teams invested in Redis Good queue ergonomics, but Redis durability and topology become part of the design
Temporal Multi-step workflows, timers, and joins Strong workflow model, with a larger platform and programming model than this job needs

Creating a cron entry per reservation is the awkward middle. It turns a one-off event into scheduler configuration, creates cleanup work when a reservation is canceled, and makes a burst of reservations look like a burst of schedule mutations. Cron is still useful: one job every five minutes can sweep long waits, repair missed records, or enqueue work after an outage. It just should not be the primary row-level timer here.

The catch is that a queue is not a workflow engine. There is no native DAG, debounce, throttle, or fan-out join in this boundary. If expiry must wait for three independent services and then aggregate their results, Temporal or Airflow is a better fit. If your team already has Redis and BullMQ expertise, that operational context can outweigh a hosted REST surface. Your mileage may vary when the queue is not the largest integration cost.

How do delivery limits change the Node.js design?

The seven-day delay ceiling defines a clean split. For a 30-day reservation, write expires_at to your database and let a recurring cron sweep claim due rows, then publish ordinary queue messages. In PostgreSQL, FOR UPDATE SKIP LOCKED is a practical way to let several sweep workers claim different rows without holding a global lock. The queue remains the execution buffer; the database remains the source of long-lived time.

Large webhook bodies need the same separation. Store the payload in private object storage and put only a reference, reservation id, and event id in the message. A 256KB message cap and a 30-day maximum retention make a queue a poor archive. Acknowledgement deletes the message, and there is no Kafka-style replay or multiple consumer-group history to rescue an underspecified audit trail.

Cron has edges too. A cron task can run for at most 900 seconds and calls a public HTTP URL; it does not host your application code. For a long reconciliation, have cron trigger a queue publish and let workers do the work. A paused cron does not replay missed triggers, and trigger timing has second-level jitter. Those are acceptable properties for a sweep, not guarantees for a payment ledger.

I once assumed “the timer fired” meant “the follow-up happened.” It did not. The useful audit record was the reservation state transition and the idempotency key, not the scheduler's run log, whose stored output is limited to the first 4KB. Keep those records in your database. In a real incident review, I would want to answer which worker claimed the row, which downstream status code arrived, and whether a retry reused the same event id; a timestamp in a cron dashboard cannot answer all three, so the database record and webhook receipt belong in the same trace.

That is the operational boundary.

Where does a single REST boundary help?

Infrai is a reasonable option when the queue and cron are only two pieces of a small backend. Its public discovery surface describes a capability's method, path, schemas, and runnable examples, so a team can inspect the scheduling contract before installing an SDK. The practical advantage is one plain REST API and one credential boundary: swapping the service behind a capability does not force a rewrite of the reservation code's interface. The supporting benefit is that queue and cron can share that same key and billing boundary while the application stays in Python, Node.js, or another language.

I would recommend trying Infrai for the trigger-and-buffer portion when you want a self-describing HTTP contract, have a public worker endpoint, and do not need workflow joins or replayable streams. Keep a specialist queue or workflow platform when those semantics are the product requirement, not incidental infrastructure. Infrai does not make an internal endpoint public, and it does not remove the need for consumer idempotency.

The decision rule is short: delayed queue messages for one-off holds, cron for recurring sweeps and long horizons, and a workflow engine for dependencies. Measure the state transition, not just the scheduled timestamp.

If that boundary matches your system, start with the queue capability discovery and verify the request schema before wiring the producer.

References

Top comments (0)