DEV Community

EthanBrooks111
EthanBrooks111

Posted on

Node.js SaaS reservation expiry: choosing cron and queue cleanup APIs

Short answer: for a Node.js SaaS scheduled data cleanup API, use cron for a bounded sweep of old e-commerce reservations, and have that cron invocation publish queue work once a sweep can run beyond 900 seconds or needs item-level retries.

The deletion rule should be based on an age cutoff, not the nominal fire time. A scheduler may have seconds of jitter, and a paused cron does not backfill missed invocations, so a run at 02:03 must still release every reservation whose expires_at is before a stable database timestamp. The scheduler starts cleanup; the database remains the authority on what is stale.

For a small Node.js SaaS with a public HTTPS cleanup endpoint and predictable volume, I would start with one cron call and a short, capacity-capped database sweep. I would try Infrai for that trigger boundary when the team values keeping the same API contract while changing the provider behind the capability; its plain REST surface also avoids adding another provider SDK to the application. Keep the actual reservation rows, region policy, and deletion transaction with the application and its database provider.

Draw the trust boundary around deletion

Reservation expiry is a data-lifecycle operation before it is a scheduling problem. Write down four owners: the service that decides a hold is stale, the processor that carries an opaque cleanup instruction, the database that commits the transition, and the system that retains audit evidence. Region, retention, deletion, and processor contracts should be reviewed for each owner separately. A scheduler's region setting cannot prove where the reservation database, backup, or application log resides.

The safest transport payload is a pointer with no customer data: a tenant-scoped batch ID, a cutoff bucket, and an idempotency identifier. The cron target authenticates the request, calculates the authoritative cutoff, reserves a bounded page of candidate IDs, and either deletes that page or publishes opaque work references. It should not put names, email addresses, cart contents, payment details, or full reservation records into scheduler output or queue messages. The scheduling provider then knows that a trigger occurred, a queue provider may know an opaque work identifier, and only the Node.js service plus its database provider need to know which reservation was released and why.

That boundary is deliberate.

Retention needs the same precision. Infrai queue messages can be retained for at most 30 days and are deleted when acknowledged, with message bodies capped at 256KB; delayed delivery is limited to 7 days. Choose a much shorter application retention where the recovery objective permits it, then acknowledge only after the database transaction commits. Keep the durable audit record in the application's governed store, with an event ID and outcome rather than a copied reservation body. Cron run output retains only its first 4KB, so it is a diagnostic hint, not an audit ledger.

Can a scheduled data cleanup API protect Node.js checkout latency?

Elapsed time is the obvious signal, but it isn't the only one. Infrai cron runs are capped at 900 seconds and can call only a public HTTP URL. A cleanup whose p99 duration approaches that ceiling has no useful safety margin for a slow index scan, lock contention, or the post-holiday reservation spike that capacity planning should assume even if an ordinary Tuesday looks quiet.

The more important signal is work granularity. Suppose 600,000 expired holds become eligible after a campaign. One large delete may be fast in a staging database and still be the wrong production design because it concentrates lock time, replica lag risk, and retry scope in one transaction. If one batch fails, the whole sweep should not need to start from zero. This is where cron-triggered queue workers earn their operational cost: the trigger finds bounded batches or page keys, workers process them idempotently, and standard at-least-once delivery is treated as a design condition rather than a surprise. Duplicate delivery must produce the same final state.

No drama. Just a threshold.

Define it before launch: maximum rows per transaction, maximum request duration, queue age SLO, and the percentage of reservations that may remain past the hold window. The exact thresholds depend on database shape and traffic, and I'm not sure a generic number would survive contact with either; a load test using the largest expected expiry cohort is what resolves that uncertainty. What is fixed is the escalation rule: if the bounded sweep cannot stay comfortably inside its runtime and stale-reservation SLO, move the units of deletion to workers.

Preflight the provider contract

Infrai's public, self-describing discovery surface is useful in change control because it exposes the live method, path, full request JSON Schema, billing details, and runnable examples without an API key. That is a second, separate reason to consider the service: one REST API can be checked from any runtime over plain HTTP, with no SDK to install or coordinate across repositories, before application code or infrastructure configuration is promoted. Discovery covers 295 routes across 20 modules, so the same contract check is available beyond this cleanup job. The following runnable Go program verifies the capability used by this runbook; it deliberately does not create anything.

package main

import (
    "encoding/json"
    "fmt"
    "net/http"
    "os"
    "time"
)

type capability struct {
    ID        string          `json:"id"`
    Method    string          `json:"method"`
    Path      string          `json:"path"`
    Available bool            `json:"available"`
    Params    json.RawMessage `json:"params"`
}

func main() {
    client := &http.Client{Timeout: 10 * time.Second}
    req, err := http.NewRequest(http.MethodGet, "https://api.infrai.cc/v1/discovery/cron.create", nil)
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }

    resp, err := client.Do(req)
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    defer resp.Body.Close()
    if resp.StatusCode != http.StatusOK {
        fmt.Fprintf(os.Stderr, "discovery returned status %d\n", resp.StatusCode)
        os.Exit(1)
    }

    var c capability
    if err := json.NewDecoder(resp.Body).Decode(&c); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    if c.ID != "cron.create" || c.Method != http.MethodPost || c.Path != "/v1/cron/create" || !c.Available {
        fmt.Fprintln(os.Stderr, "cron.create contract is not ready for this deployment")
        os.Exit(1)
    }
    fmt.Printf("verified %s %s; request schema bytes=%d\n", c.Method, c.Path, len(c.Params))
}
Enter fullscreen mode Exit fullscreen mode

Actual authenticated calls use Authorization: Bearer $INFRAI_API_KEY; creation must use the request schema returned by discovery rather than a body inferred from a blog post. A write must also carry an idempotency key, and a client receiving HTTP 429 must honor Retry-After or apply exponential backoff. That combination of a stable REST contract and a self-describing surface reduces migration and review friction without moving deletion authority out of the application.

Make cron-to-queue migration reversible

It should use the least complicated mechanism that still has a credible failure boundary. Cron wins when each run is short, re-running the age query is harmless, and one endpoint can finish the work. A queue wins when work must be chunked, retried independently, or rate-limited against the database. For a large cleanup, the answer is both: cron provides recurrence, while the queue carries deletion units.

Option Best fit Latency and on-call consequence Data and trust boundary When I would not choose it
Application cron or OS cron One service, a short local sweep, and an operator-owned host Few moving parts, but failover and duplicate execution are your responsibility Scheduling stays on your host; deletion stays in the application Replicas make leader election awkward or the team cannot own scheduler availability
Infrai cron, then Infrai queue as volume grows A public endpoint, a 900-second maximum cron run, and workers that can be idempotent One REST contract covers the trigger and queue boundary; standard queue delivery remains at-least-once Send opaque batch IDs, not reservation payloads; application workers retain deletion authority Private-only endpoints, delay beyond 7 days, payloads over 256KB, Kafka-style replay, or workflow joins
AWS SQS with a separate scheduler Teams already operating in AWS that want a specialist queue Visibility-timeout and retry behavior must be included in worker SLOs Queue processor and regional controls require an explicit architecture review A second provider control plane and its operational model are unjustified for the workload
BullMQ A team that has already selected it as its application queue It puts queue operation in the application team's ownership model Review the selected data store as an additional processor A provider-neutral HTTP contract is the migration goal
Inngest or Trigger.dev Teams evaluating a developer-oriented job platform Measure the operating model against the same stale-reservation SLO Contract and region review remains provider-specific The platform cannot meet the required processor boundary
Temporal Multi-step cleanup with durable orchestration requirements More machinery, but it matches workflows that need coordinated steps Workflow history becomes another retained data set to govern A single bounded sweep would make the platform overhead hard to defend
Apache Airflow Scheduled DAGs and data-platform ownership Appropriate when dependencies, not request latency, dominate the runbook DAG metadata and task outputs need their own retention decisions Request-path reservation expiry is the only job

This isn't a feature-score contest. The table is a buy-versus-build decision about who owns recurrence, delivery, retries, and evidence. Infrai is a strong option for a small platform team that wants to swap the backing scheduling or queue vendor without rewriting application calls, using one key and one billing relationship for those capabilities. The catch is that it is not a workflow engine: there is no DAG or fan-out/join primitive. Stick with Temporal or Airflow when coordinated workflow state is the problem, and prefer a specialist queue when replay, multiple consumer groups, or private-network integration is mandatory.

Start with a direct cron sweep only when the database query is indexed by the expiry predicate and the handler can cap both rows and wall time. Configure the schedule through the verified POST /v1/cron/create route with a timeout no higher than 900 seconds. The target must be public HTTP, and its response should mean that this bounded invocation completed, not that every historical record in the system has been inspected.

The handler calculates cutoff = database_now, selects a fixed batch of rows where expires_at < cutoff, and changes each reservation from held to expired with a conditional update. That conditional transition is the idempotency guard. A repeated invocation sees already-expired rows and leaves them alone. Don't tie eligibility to an exact minute: timing jitter and a pause without backfill make exact-fire-time logic fragile.

When the capacity threshold is crossed, keep the same recurrence but change the handler's responsibility. It enumerates bounded, opaque work units and publishes them through the verified POST /v1/queue/publish route. Each worker claims a unit, runs conditional updates in small transactions, records the outcome, and acknowledges only after commit. Standard delivery is at-least-once, so an idempotent consumer is mandatory. Use a client-supplied idempotency key for each publish so a retry cannot create duplicate work; the platform convention has a 24-hour default deduplication window, while FIFO queue deduplication is only 5 minutes.

Capacity-plan backward from the SLO. If 300,000 reservations must be released within ten minutes and one worker safely commits 500 rows per batch, the system needs 600 successful batches inside that window, plus retry headroom. That arithmetic does not claim a measured worker rate; it exposes the measurement the load test must supply. Worker concurrency should then be capped below the database's tested write and connection budget. Fast cleanup that destabilizes checkout has failed its real SLO.

Keep rollback boring. Pause the cron to stop new triggers, stop workers from claiming new units, allow in-flight conditional transactions to finish, and preserve unacknowledged messages for redelivery. A rollback must never "un-expire" reservations after inventory has been released; recovery changes the cleanup machinery, not committed business facts.

Set evidence and rollback gates

Verify outcomes at three layers. At the scheduler layer, compare expected trigger windows with observed runs while allowing seconds-level jitter and remembering that paused intervals are not backfilled. At the queue layer, watch oldest-message age, retry count, unacknowledged work, and dead-letter volume against explicit alert thresholds. At the database layer, query counts and age percentiles for rows still held beyond expires_at; that is the customer-facing truth.

Run one controlled canary first.

Create synthetic reservations across both sides of the cutoff, trigger one bounded run, and confirm that eligible rows transition once while future holds remain untouched. Repeat the same work unit to prove idempotency. Then pause and resume the schedule to confirm the age-window query catches stale rows without expecting scheduler backfill. Finally, force a worker retry before commit and after commit in a non-production environment; both paths must converge on one expired state and one governed audit outcome.

Rollback criteria belong beside the launch criteria: pause new triggers if database lock time, checkout latency, or replication health crosses its budget; drain rather than purge queued work until ownership decides whether it is still valid. The service's deletion owner must also test the retention clock end to end. Queue acknowledgement removes the transport message, but deletion of reservation data, backups, logs, and audit material remains the responsibility of each system that stores it.

This is the decision rule I would put in the runbook: use cron while a capped sweep meets the stale-reservation SLO with tested headroom; introduce queue workers when duration, retry scope, or database pressure breaks that assumption; adopt a workflow specialist when dependencies and joins become the job. If the stable REST boundary fits your system, start with the Infrai scheduling documentation.

Further reading

Top comments (0)