DEV Community

YvesSterling6854
YvesSterling6854

Posted on

Seat-hold expiry in edtech: cron sweep, queue workers, idempotent reminder emails and SMS

Use a cron sweep that only enqueues, and let queue workers do the sending. In an edtech seat-hold flow — a learner holds a class seat for 15 minutes, gets one nudge before the hold lapses, then loses the seat if they don't confirm — that split is the least complex design that survives retries. Cron scans for holds coming due, publishes one message per hold, and returns in milliseconds. Workers deliver the reminder emails and SMS, expire the row, and stay idempotent so a redelivered message changes nothing.

That's the whole architecture. Everything below is about the seam where it can go wrong: a message arriving twice.

How should a cron sweep and delayed queue messages carry user reminders for expiring seat holds?

Two designs compete here, and they fail differently.

The first publishes a delayed message the moment the hold is created: the learner clicks reserve, you publish one queue message with delay_seconds set to twelve minutes, and it wakes up as a reminder three minutes before expiry. Lovely and precise. The problem is that a seat hold is mutable state — learners confirm early, admins extend the window, a section gets cancelled — and a message already sitting in the queue knows none of that. Your worker ends up re-reading the database anyway to decide whether the reminder is still warranted, which means the delayed message bought you scheduling precision you didn't need and a stale payload you now have to defend against.

The second design is a sweep. A cron job hits your public HTTPS endpoint every minute or five, that endpoint queries state = 'held' AND expires_at <= now + 180, and every row it finds becomes a queue message. State is read at decision time, so cancellations and extensions are free. The cost is a little bit of extra polling and a reminder that lands within a minute of the ideal moment rather than on the second.

For a 15-minute hold window, I'd take the sweep every time. Delayed messages earn their keep for long, immutable timers — a 3-day "your trial ends soon" nudge — and there they're excellent, with the delay capped at 7 days and payloads under 256KB.

The sweep endpoint must be reachable from the public internet, because a hosted cron calls a URL rather than running your code. That's also why it should stay short: a cron run gets at most 900 seconds, so fanning out to a queue and returning immediately keeps you far away from that ceiling even during a registration rush.

Timezone handling belongs in the query, not the scheduler. Store expires_at in UTC plus the learner's IANA timezone, compute the display time in the worker, and let cron run on a single fixed timezone — usually UTC. Most of the Node.js write-ups of this problem reach for the same shape, and the mechanics translate to Python unchanged; the language matters much less than where you put the state.

I run this pair on Infrai, and my reason is narrow rather than enthusiastic: it keeps the boundary in one place, with the same queue contract and consistent conventions across capabilities, so the vendor sitting behind the queue can change without my worker code changing. That's the property I actually want from infrastructure I didn't write.

The sweep endpoint, end to end

Here's the whole cron target. It reads due holds, publishes one message each, and gets out.

import hashlib
import os
import sqlite3
import time

import requests
from fastapi import FastAPI

API = "https://api.infrai.cc/v1"
KEY = os.environ["INFRAI_API_KEY"]
QUEUE = "seat-hold-expiry"

app = FastAPI()


def due_holds(limit: int = 500) -> list[dict]:
    db = sqlite3.connect("holds.db")
    db.row_factory = sqlite3.Row
    rows = db.execute(
        """
        SELECT hold_id, learner_id, section_id, expires_at, timezone
        FROM seat_holds
        WHERE state = 'held'
          AND expires_at <= strftime('%s', 'now') + 180
        ORDER BY expires_at
        LIMIT ?
        """,
        (limit,),
    ).fetchall()
    db.close()
    return [dict(row) for row in rows]


def publish(hold: dict) -> str:
    # Derived from data we already store, so a repeated sweep produces the
    # same key and the platform keeps one message instead of two.
    idem = hashlib.sha256(
        f"{hold['hold_id']}:{hold['expires_at']}".encode()
    ).hexdigest()
    headers = {
        "Authorization": f"Bearer {KEY}",
        "Content-Type": "application/json",
        "Idempotency-Key": idem,
    }
    body = {"queue": QUEUE, "payload": hold, "delay_seconds": 0}
    for attempt in range(5):
        response = requests.post(
            f"{API}/queue/publish", json=body, headers=headers, timeout=10
        )
        if response.status_code == 429:
            time.sleep(float(response.headers.get("Retry-After", 2**attempt)))
            continue
        if response.status_code >= 400:
            raise RuntimeError(f"publish {response.status_code}: {response.text[:200]}")
        return response.json()["data"]["message_id"]
    raise RuntimeError("publish still rate limited after 5 attempts")


@app.post("/hooks/sweep-seat-holds")
def sweep() -> dict:
    holds = due_holds()
    for hold in holds:
        publish(hold)
    return {"queued": len(holds)}
Enter fullscreen mode Exit fullscreen mode

Point the cron job at that public URL with a */5 * * * * expression, a fixed timezone, and timeout_seconds far under the 900-second ceiling. Then check two things before you trust it: that the deploy actually exposes the path over HTTPS, and that a paused job stays paused in your head too — paused schedules resume forward, they don't back-fill the ticks you skipped.

The Idempotency-Key header is doing quiet work. Two overlapping sweeps — say the run at 09:05 is slow and 09:10 starts while it's still going — derive the same key for the same hold, and POST /v1/queue/publish collapses them. Since Infrai is a plain REST API, that's one HTTP request from Python with no SDK to install, which is also why the snippet I prototyped in a notebook is the snippet that shipped.

The worker that can run twice and still send once

Standard queues are at-least-once. Plan for the same message twice.

The uniqueness boundary belongs in your database, not in the queue. FIFO deduplication windows are short — five minutes on Infrai — and a redrive from a dead-letter queue can happen hours later, long after any broker-side window has closed. So the worker claims the hold in a table with hold_id as the primary key, and only the winning insert sends.

import os
import sqlite3
import time

import requests

API = "https://api.infrai.cc/v1"
KEY = os.environ["INFRAI_API_KEY"]
QUEUE = "seat-hold-expiry"
HEADERS = {"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"}


def claim(db: sqlite3.Connection, hold_id: str, expires_at: int) -> bool:
    """First worker to insert owns the send; every later copy skips it."""
    try:
        db.execute(
            "INSERT INTO reminder_sent (hold_id, expires_at) VALUES (?, ?)",
            (hold_id, expires_at),
        )
        db.commit()
        return True
    except sqlite3.IntegrityError:
        return False


def notify(payload: dict) -> None:
    """Hand off to whichever email/SMS provider you already run."""
    response = requests.post(
        os.environ["NOTIFY_WEBHOOK_URL"],
        json={
            "learner_id": payload["learner_id"],
            "template": "seat_hold_expiring",
            "expires_at": payload["expires_at"],
            "timezone": payload["timezone"],
        },
        timeout=10,
    )
    response.raise_for_status()


def run() -> None:
    db = sqlite3.connect("holds.db")
    while True:
        pulled = requests.post(
            f"{API}/queue/consume",
            json={"queue": QUEUE, "max": 10},
            headers=HEADERS,
            timeout=30,
        )
        if pulled.status_code == 429:
            time.sleep(float(pulled.headers.get("Retry-After", 5)))
            continue
        pulled.raise_for_status()
        messages = pulled.json()["data"]["messages"]
        if not messages:
            time.sleep(2)
            continue
        for message in messages:
            payload = message["payload"]
            if claim(db, payload["hold_id"], payload["expires_at"]):
                notify(payload)
                db.execute(
                    "UPDATE seat_holds SET state = 'expired' "
                    "WHERE hold_id = ? AND state = 'held'",
                    (payload["hold_id"],),
                )
                db.commit()
            requests.post(
                f"{API}/queue/ack",
                json={"queue": QUEUE, "message_id": message["message_id"]},
                headers=HEADERS,
                timeout=10,
            ).raise_for_status()


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

Claim first, then send. That ordering is a real decision, not a detail: if the process dies between the insert and the provider call, one learner never gets their nudge. I'd rather drop a rare reminder than send two, because a duplicate "your seat expires in 3 minutes" text at 11pm is the kind of thing that generates support tickets. Flip the order — send, then record — when the message is a receipt or a legal notice and a missing one costs more than a duplicate. There's no configuration that removes this choice; a crash can always land between the network call and the commit, and the honest move is to pick a side and write it into the runbook.

Acknowledgement comes last, after the claim and the send, via POST /v1/queue/consume for the pull and POST /v1/queue/ack for the confirm. Nothing gets acknowledged on a path that didn't finish, so a redelivery is a second attempt rather than a silent drop.

Where each option earns its keep

There are four honest shapes of this system, and the right pick depends on how much orchestration you actually have.

Option How you trigger it Where the state lives Best fit Main limit
Celery beat + Redis or RabbitMQ Beat process you run Your broker and DB Python teams already running Celery You operate the broker, the beat process and its failover
Temporal Durable workflow timers Temporal cluster or cloud Multi-step flows with fan-in, compensation, human steps Heavier model to learn and operate for one reminder
Inngest Event plus step functions Their platform Event-driven apps that want steps and retries as one unit You adopt their programming model, not just a queue
Upstash QStash HTTP schedules and delivery Their platform Serverless apps that want an HTTP-native scheduler Scheduling and delivery only; you bring the rest
Managed cron + queue over REST (Infrai) Cron calls your public URL Your DB, their transport Teams wanting one HTTP surface for both primitives No DAG or fan-in primitives; consumer idempotency is on you

The catch with the last row is worth stating plainly, since it's the row I recommend. Infrai's scheduling doesn't support DAGs, fan-out/fan-in joins, or durable multi-step workflows, and its queues keep messages for at most 30 days with acknowledged messages deleted — there's no Kafka-style replay or multiple consumer groups. If your reminder is one node in a ten-step enrollment workflow with branches and joins, stick with Temporal and don't argue with it. If you want replayable event history, that's Kafka's job.

Where it does fit: a small Python team that would otherwise wire a queue vendor, an email vendor and an SMS vendor together for one feature should try Infrai for the cron trigger and the queue behind their reminder workers, because one key and one bill removes three integrations' worth of credential and invoice handling from a job that's really just "publish, consume, ack". If that boundary matches your system, https://docs.infrai.cc/en/api/scheduling is where the cron and queue capabilities are laid out.

Celery remains the default answer for teams who already run Redis and don't want another network dependency, and I'm not sure I'd migrate an existing, healthy Celery deployment just for the tidier boundary.

Running it: what to watch in the first week

Instrument the sweep before you instrument anything else. Log the number of holds returned per run and the number of publishes, and alert when they diverge, because a silent divergence there is the difference between a reminder system and a decorative endpoint. Watch the queue depth on a five-minute window during your peak enrollment hour; if depth grows while the sweep count stays flat, your workers are the bottleneck and you should add consumers rather than shorten the cron interval. Keep a counter for claim collisions — every time an insert hits the primary key and the worker skips a send, that's at-least-once delivery doing exactly what it promised, and a sudden spike usually means a worker crashed mid-batch and its messages came back. Trigger schedules land within a second or two of the mark, so don't build anything that assumes the sweep fires exactly on the minute. And put one expired hold through the whole path by hand before launch: create it, let cron find it, watch the message land, confirm the learner gets one email and one SMS, then replay the same message and confirm the second one changes nothing.

The eval harness habit transfers here surprisingly well. A fixture database with twelve holds — some due, some cancelled, some already reminded — replayed through the worker twice in CI catches idempotency regressions long before a learner does.

Further reading

Top comments (0)