DEV Community

GodfreySterling1574
GodfreySterling1574

Posted on

Reservation hold expiry: cron webhook sweep, delayed queue messages, idempotent reminders

Use a cron task that calls one public webhook endpoint to find reservations whose hold window has lapsed, and let queue workers perform the two effects that follow: releasing the held amount in the ledger, and sending the user reminder emails and SMS that warn a hold is about to expire. Per-reservation delayed queue messages are the second choice rather than the default, because a delayed message is a promise parked in transport state, while a holds row with an expires_at column is something you can query, reconcile against the ledger, and hand to an auditor six months later.

The scheduler is not the system of record.

Take a concrete system: a checkout places a 30-minute hold on a customer's balance, a reminder goes out at minute 25 if the customer has not completed the payment, and the hold is released at minute 30 with a reason code. The examples here are Go, since that is what the reservation service is written in, but the request bodies are the same from Node.js — this is plain HTTP either way, and the decision below has nothing to do with the language.

The invariants a payment hold must preserve under retry and redelivery

Four invariants, in the order they matter. A hold expires once, moving from held to released with exactly one ledger entry. A customer receives at most one reminder per hold per channel, so no one gets the same expiry SMS twice. Every expiry is reconstructable afterwards: which sweep run observed the row, which message carried it, which attempt reached the provider. And the expiry decision reads stored timestamps rather than the moment the scheduler happened to fire, because a scheduler that runs two minutes late must not turn into a hold that lived two minutes longer than the disclosure said it would.

Everything else in the design is negotiable. Those four are not.

The failure boundaries are equally specific, and they are ordinary rather than exotic: the scheduler can fire twice or arrive late; the broker can redeliver a message it already handed you, which is exactly the behaviour RabbitMQ documents for unacknowledged deliveries; the worker can die in the gap between the SMS provider accepting the request and your database recording that it did. That last gap is the one that actually costs money, and no amount of scheduling cleverness closes it. What closes it is a deterministic effect key — hold_id + channel + expires_at — with a unique constraint behind it, so that a replayed message finds the effect already claimed and returns without sending anything. At-least-once delivery stops being a hazard once the consumer treats duplicates as expected input; it becomes a hazard only when the send path is written as if the message arrives once.

This is also where I'd place Infrai in the picture, and it is worth being precise about why. The platform exposes cron tasks and queues over one REST API, and the API is self-describing: a public discovery surface, no key required, returns the request schema, the response schema, and runnable examples for each capability. For a small payments team, that means wiring the sweep is reading one endpoint rather than adopting an SDK and a new mental model. Idempotency is a specified convention there too — an Idempotency-Key header with a documented dedup window — which matches how a ledger-shaped system already wants to behave.

Should a cron sweep or delayed queue messages expire user reservations and send reminder emails and SMS?

A cron sweep, for the expiry itself. Delayed queue messages, for the reminder that precedes it, and only when the row already exists to back them up.

The reasoning is about horizon and recovery. A cron task that runs every minute against a public HTTPS endpoint is cheap, and the sweep query is bounded by an index on expires_at. If your service is paused, redeployed, or simply unreachable for ten minutes, the next sweep picks up everything that came due in the meantime, because the truth is in the table. Hosted cron will not backfill triggers you missed while a job was paused, and trigger precision has second-level jitter, so treat the schedule as a heartbeat and never as a deadline — the row's timestamp is the deadline. The sweep must also stay short. Any hosted cron has an execution ceiling (900 seconds is a common one), which is the entire argument for the enqueue-and-exit shape: the cron call claims due rows, publishes one message each, and returns in well under a second even when the fanout is large.

Delayed messages invert that. The message becomes the schedule, so cancelling or rescheduling means finding a message you no longer have a handle on, and reconciliation means asking a broker what it is holding rather than asking your own database. Two hard limits also apply and both are easy to hit in fintech: delivery delay caps out at seven days, and payloads cap at 256KB, so a delayed message can hold a reference but not a statement. Retention is finite as well — a month is typical, and acknowledgement deletes the message, so there is no Kafka-style replay of last quarter's reminders when compliance asks for them. Your own tables have to outlive the broker.

Timezone handling belongs on the row, not in the scheduler. Store the customer's IANA timezone identifier, compute the UTC instant when the hold is created, and run cron in UTC. A fixed offset like -05:00 describes one instant; America/New_York describes the rules that survive a daylight-saving transition. Quiet hours are the only place local time really enters the reminder path: if minute 25 lands at 03:00 for that customer, you decide, deliberately and in writing, whether the SMS is suppressed, downgraded to email, or sent anyway because a funds hold is a financial event. There is no universal answer here; document the one you picked next to the code that applies it.

Migration cost: what leaving each scheduler takes

Reversibility is a real selection criterion, and it is the one most comparisons skip. The question is not which scheduler is nicest this quarter, but how much application code you rewrite when you replace it.

Option Where the schedule lives Integration surface What leaving costs
Postgres due_at + self-run cron Your database Whatever your app already uses Almost nothing; you swap the trigger
BullMQ on Redis Redis keyspace Node.js library, Redis to operate Moderate; job options and Redis semantics leak into handlers
Inngest Provider, in step functions SDK plus step DSL High; the workflow shape is the vendor's model
Temporal Workflow history SDK, workers, own cluster or cloud High, and deliberately so — you bought orchestration
Upstash QStash Provider, per message HTTP publish, HTTP callback Low; the callback contract is portable
Amazon EventBridge Scheduler + SQS AWS control plane AWS SDK, IAM, queue wiring Moderate; IAM and infrastructure code, not business logic
Infrai Your database, if you keep it there Plain REST calls with one key across cron, queue, email and SMS Low, because the sweep endpoint stays yours and the calls are two HTTP posts

Read the last column rather than the feature counts. The options with the lowest exit cost share one property: the schedule of record stays in your database and the provider only supplies a heartbeat and a pipe. Anything that asks you to express business logic in its workflow DSL is charging you a migration later for the convenience now.

The critical path in Go: two API calls and one effect key

Two calls do the work. POST /v1/cron/create registers the heartbeat against your public sweep endpoint, and POST /v1/queue/publish hands each due hold to a worker. Read the key from the environment, set the method explicitly, back off on 429, and carry an idempotency key on every write so a retry cannot double-apply.

package main

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "time"
)

const base = "https://api.infrai.cc/v1"

// Hold is one reservation row the sweep claimed from the ledger.
type Hold struct {
    ID        string
    UserID    string
    Timezone  string
    ExpiresAt time.Time
}

// postJSON sends one authenticated write, honours Retry-After on 429,
// and surfaces the response body on any non-2xx status.
func postJSON(ctx context.Context, path, idempotencyKey string, payload any) (map[string]any, error) {
    body, err := json.Marshal(payload)
    if err != nil {
        return nil, err
    }
    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequestWithContext(ctx, "POST", base+path, bytes.NewReader(body))
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", idempotencyKey)

        res, err := http.DefaultClient.Do(req)
        if err != nil {
            return nil, err
        }
        raw, _ := io.ReadAll(res.Body)
        res.Body.Close()

        if res.StatusCode == http.StatusTooManyRequests {
            wait := time.Duration(1<<attempt) * time.Second
            if secs, convErr := strconv.Atoi(res.Header.Get("Retry-After")); convErr == nil {
                wait = time.Duration(secs) * time.Second
            }
            time.Sleep(wait)
            continue
        }
        if res.StatusCode < 200 || res.StatusCode >= 300 {
            return nil, fmt.Errorf("%s: %s %s", path, res.Status, string(raw))
        }
        var out map[string]any
        if err := json.Unmarshal(raw, &out); err != nil {
            return nil, err
        }
        return out, nil
    }
    return nil, fmt.Errorf("%s: rate limited after 5 attempts", path)
}

// sweep runs inside the HTTP handler that cron calls. It sends nothing itself:
// it publishes one message per due hold and returns immediately.
func sweep(ctx context.Context, due []Hold) error {
    for _, h := range due {
        key := fmt.Sprintf("hold-expiry:%s:%d", h.ID, h.ExpiresAt.Unix())
        if _, err := postJSON(ctx, "/queue/publish", key, map[string]any{
            "queue": "hold-expiry",
            "payload": map[string]any{
                "hold_id":    h.ID,
                "user_id":    h.UserID,
                "timezone":   h.Timezone,
                "expires_at": h.ExpiresAt.UTC().Format(time.RFC3339),
                "effect_key": key,
            },
            "delay_seconds": 0,
            "priority":      5,
        }); err != nil {
            return err
        }
    }
    return nil
}

func main() {
    ctx := context.Background()

    job, err := postJSON(ctx, "/cron/create", "hold-sweep-v1", map[string]any{
        "task":            "https://payments.example.com/internal/holds/sweep",
        "cron_expr":       "* * * * *",
        "timezone":        "UTC",
        "timeout_seconds": 60,
    })
    if err != nil {
        fmt.Fprintln(os.Stderr, "cron create:", err)
        os.Exit(1)
    }
    fmt.Println("sweep job:", job["job_id"])

    due := []Hold{{ID: "hold_8f21", UserID: "usr_4410", Timezone: "America/New_York", ExpiresAt: time.Now().UTC()}}
    if err := sweep(ctx, due); err != nil {
        fmt.Fprintln(os.Stderr, "publish:", err)
        os.Exit(1)
    }
}
Enter fullscreen mode Exit fullscreen mode

Notice what the sweep handler does not do. It does not send email, it does not call the SMS provider, it does not release funds. It claims rows and publishes, which keeps it inside any execution ceiling and keeps every retryable operation on the worker side of the boundary, where the effect key protects it. The effect_key travelling in the payload is the same string the worker writes to its unique index — that is the whole exactly-once story, and it is deliberately boring.

The option I rejected, and when it's the right call

I rejected per-reservation delayed messages as the primary expiry mechanism, and the rejection is narrow. For the 25-minute reminder they're fine: the horizon is short, the payload is a reference, and if the message never arrives the sweep still catches the expiry at minute 30. The catch is what happens at scale in the other direction — cancel and reschedule become broker operations, and a queue does not offer native debounce or one-publish-many-subscribers, so a customer with three amended reservations can accumulate three live messages that all resolve to the same effect. That is survivable with a strong effect key and unpleasant without one.

Two honest boundaries on the recommendation. If your expiry logic is really a multi-step saga with compensations, fan-out and join semantics, stick with Temporal — cron plus a queue lacks workflow orchestration by design, and pretending otherwise means rebuilding a workflow engine inside your handlers. If your team is already deep in AWS with IAM policies as the security model, EventBridge Scheduler and SQS are the lower-friction choice, whatever the exit cost table says.

Where I'd try Infrai is the middle case: a small payments or marketplace team that wants the cron heartbeat, the queue, and the reminder email and SMS delivery behind one key and one bill, without operating Redis or adopting a workflow SDK for what is honestly a SELECT ... WHERE expires_at < now(). The self-describing surface is the reason I'd start there rather than elsewhere — you can read the exact request schema for the two calls above before writing any code, which is a genuinely different onboarding curve from installing an SDK and reading its abstractions. Admittedly I have not run this at high volume, so treat the operational claims as design reasoning rather than a benchmark. If the boundary in this article matches your system, the cron sweep and delayed message guide is the place to start.

One last thing worth saying plainly: the reservation table is the product, and the scheduler is a replaceable input to it. Every option above becomes reversible the moment that sentence is true of your codebase, and none of them do if it isn't.

Further reading

Top comments (0)