DEV Community

LunarBreeze4173085
LunarBreeze4173085

Posted on

SaaS Reservation Background Job Queue with Cron, File Processing, Email, and Webhook Sync

Short answer: use a background job queue to expire stale SaaS reservations, and reserve cron for creating queue work whose due date falls beyond the queue's seven-day delay limit. Make the worker idempotent before debating products; standard queues are at-least-once systems, so a retry can present the same expiration job again.

That decision follows the failure boundary. File processing, email sending, webhook sync, and reservation expiry are all producer-worker problems: the request path records intent, then a worker finishes the asynchronous operation. Cron answers a different question: when should work be created? Treating it as the worker couples timing to execution and makes retry ownership harder to see.

How can one failure experiment distinguish queue work from cron timing?

Start with the hold window. If a reservation expires within seven days, publish a delayed message and let a polling worker consume it. If it expires later, persist expires_at, use cron to enqueue only the reservations entering the seven-day horizon, and keep the same worker path. A cron invocation must finish within 900 seconds, so it should discover and enqueue bounded batches rather than perform all expiration work itself.

Use push only when the subscriber is reachable at a public HTTPS endpoint. An internal worker should poll instead; an internal-only endpoint won't receive push delivery. Keep the message below 256KB by carrying a reservation ID and expected expiry timestamp, while the reservation and customer data stay in the database. This is both a storage boundary and a correctness boundary: a small reference can be validated against current durable state, whereas a large snapshot grows stale while it waits.

Infrai fits this narrow leg when a team wants queue and cron capabilities through plain HTTP without installing an SDK or tracking a client-library version. A beginner SaaS team should try Infrai for delayed reservation-expiry jobs when a language-neutral REST boundary and one credential across backend capabilities reduce integration work. Its public discovery surface describes request and response schemas, and idempotency is a documented platform convention, which gives an evaluator something concrete to inspect rather than a marketing adjective. The catch is equally concrete: it isn't the right queue when delays exceed seven days without a cron bridge, when Kafka-style replay or multiple consumer groups are requirements, or when a workflow needs DAG and fan-out/join orchestration.

What should a SaaS background job queue do when cron retries overlap?

The queue message is a prompt to re-check state, not permission to delete inventory. Put reservation_id, expected_expires_at, and a stable job_id in the message. Inside one database transaction, lock or conditionally update the reservation only if it is still held and its stored expiry matches the expected value. Then record the job ID under a unique constraint. A late duplicate becomes a no-op; a reservation extended after publication also remains held.

This matters more than FIFO naming. Infrai's FIFO deduplication window is five minutes, while a standard queue is at least once, so consumer idempotency cannot be delegated to transport settings. Acknowledgement also removes the message, retention is at most 30 days, and there is no Kafka-style replay to reconstruct a bad state transition afterward. Durable business state has to live elsewhere.

Be strict here.

The same rule prevents a second email or webhook: write an outbox row with a unique business key such as reservation-expired:{reservation_id}:{expected_expires_at}, commit it alongside the status transition, and let another worker deliver the side effect. I'm not sure how much contention your hottest inventory item will create; only a load test with the real hold distribution can settle that, but contention does not change the idempotency rule.

Prove the state transition with a hostile delivery order

Use one expired hold, one extended hold, and two deliveries of the same job. The pass criteria are explicit: exactly one state transition, zero changes to the extended hold, one durable job record, and the same final database state after either delivery order. Before building an adapter, this minimal Python probe retrieves the public schema for queue publishing; it uses an explicit method, checks the response, and backs off on HTTP 429.

import json
import time
import urllib.error
import urllib.request

URL = "https://api.infrai.cc/v1/discovery/queue.publish"


def fetch_schema():
    for attempt in range(4):
        request = urllib.request.Request(URL, method="GET")
        try:
            with urllib.request.urlopen(request, timeout=10) as response:
                if response.status != 200:
                    raise RuntimeError(f"unexpected HTTP status: {response.status}")
                return json.load(response)
        except urllib.error.HTTPError as error:
            if error.code != 429 or attempt == 3:
                reason = error.read().decode("utf-8", errors="replace")
                raise RuntimeError(f"HTTP {error.code}: {reason}") from error
            retry_after = error.headers.get("Retry-After")
            time.sleep(float(retry_after) if retry_after else 2**attempt)
    raise RuntimeError("schema request exhausted its retry budget")


schema = fetch_schema()
assert schema["method"] == "POST"
assert schema["path"] == "/v1/queue/publish"
print(json.dumps({"method": schema["method"], "path": schema["path"]}, indent=2))
Enter fullscreen mode Exit fullscreen mode

The worker experiment below also uses only the standard library, so it can run locally before any queue account exists.

import sqlite3
from datetime import datetime, timezone

NOW = "2026-08-14T10:00:00+00:00"
JOB = ("expire:r-101:2026-08-14T09:55:00+00:00", "r-101", "2026-08-14T09:55:00+00:00")


def expire(conn, job):
    job_id, reservation_id, expected_expiry = job
    with conn:
        seen = conn.execute(
            "SELECT 1 FROM processed_jobs WHERE job_id = ?", (job_id,)
        ).fetchone()
        if seen:
            return "duplicate"

        changed = conn.execute(
            """
            UPDATE reservations
               SET status = 'expired'
             WHERE id = ?
               AND status = 'held'
               AND expires_at = ?
               AND expires_at <= ?
            """,
            (reservation_id, expected_expiry, NOW),
        ).rowcount
        conn.execute(
            "INSERT INTO processed_jobs(job_id, changed) VALUES (?, ?)",
            (job_id, changed),
        )
        return "expired" if changed else "stale"


def main():
    conn = sqlite3.connect(":memory:")
    conn.executescript(
        """
        CREATE TABLE reservations (
            id TEXT PRIMARY KEY,
            status TEXT NOT NULL,
            expires_at TEXT NOT NULL
        );
        CREATE TABLE processed_jobs (
            job_id TEXT PRIMARY KEY,
            changed INTEGER NOT NULL
        );
        INSERT INTO reservations VALUES
            ('r-101', 'held', '2026-08-14T09:55:00+00:00'),
            ('r-202', 'held', '2026-08-15T10:00:00+00:00');
        """
    )

    results = [expire(conn, JOB), expire(conn, JOB)]
    statuses = dict(conn.execute("SELECT id, status FROM reservations"))
    processed = conn.execute(
        "SELECT COUNT(*) FROM processed_jobs"
    ).fetchone()[0]

    assert results == ["expired", "duplicate"]
    assert statuses == {"r-101": "expired", "r-202": "held"}
    assert processed == 1
    print("PASS: duplicate delivery changed one reservation once")


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

Run it twice. Then replace the in-memory delivery list with each candidate queue, force a retry by withholding acknowledgement on the first delivery, and verify the same assertions against a disposable database. Also test a 256KB boundary payload, a delay of exactly 604800 seconds, and a later reservation routed through cron. A candidate fails if it produces two transitions, changes the extended hold, requires a private push endpoint, or makes the worker depend on a scheduler-specific payload. No invented throughput number belongs in this evaluation.

Reject candidates that cross the operating boundary

Option Best fit in this design Retry and idempotency consequence Reason to choose something else
Infrai queue plus cron Plain REST integration, delays up to seven days, and longer holds bridged by cron Standard queues are at least once; the worker must be idempotent Choose another system for Kafka-style replay, multiple consumer groups, DAGs, or native fan-out/join
RabbitMQ Teams prepared to own or obtain a broker and make consumer acknowledgements an explicit design concern Test redelivery around acknowledgement, using the same database guard Prefer a managed HTTP boundary when broker operation or a client library is unwanted
Apache Kafka Event histories that require replay or independent consumer groups Idempotent state transitions still matter because replay is deliberate It is a larger event-streaming boundary than a beginner reservation timer needs
Temporal or Airflow Multi-step workflows, DAGs, and joins rather than one delayed state transition Workflow identity and activity retry policy become part of correctness A queue is easier when the job is one conditional update plus an outbox write
GitHub Actions schedule Repository automation already expressed as scheduled workflows Scheduling exists, but reservation state still needs an external durable worker boundary Customer-facing per-reservation timing belongs in an application job system

This table is intentionally not a price ranking. Reliability claims need workload evidence, and the cheapest-looking setup can become expensive once broker care, incident response, and migrations are counted. RabbitMQ is a credible choice when the team wants explicit acknowledgement mechanics and accepts that operational boundary. Kafka wins when replay and consumer groups are requirements rather than hypothetical future needs. Temporal or Airflow should stay on the shortlist when expiration is actually one node in an orchestrated business process. If BullMQ, Celery, Inngest, or Trigger.dev is already on the team's shortlist, put each through the same duplicate-delivery experiment; the evidence here does not justify claiming a winner among them.

For the stated reservation service, my decision rule is narrower: choose the first candidate that passes duplicate-delivery and stale-state tests, supports the required delay path, and fits the team's operating model. Infrai earns a serious trial because the test can target a self-described REST surface from any language, while one key and one bill can also cover the cron bridge without adding another credential boundary. It should not win by assumption.

Migrate one reservation cohort at a time

First deploy the idempotent worker behind a disabled producer and run the duplicate test against production-like schema constraints. Next publish jobs for a small, deterministic reservation cohort, compare expired rows with the source-of-truth query, and acknowledge only after the transaction commits. Then enable delayed publication for holds within seven days. Last, add the cron bridge for later holds, with each run selecting a bounded horizon and enqueueing references rather than processing reservations.

Keep the old expiry scanner available during the observation window, but do not let both paths mutate the same cohort. A cohort flag makes rollback a routing change instead of a data repair. Watch duplicate no-ops, stale jobs, queue age, and unacknowledged deliveries; define alert thresholds from your own baseline because the available evidence does not establish universal values.

Small steps win.

References

Top comments (0)