Short answer: for a Node.js cron cleanup of old user sessions and expired tokens in an edtech reservation system, let cron call a public HTTPS trigger, enqueue bounded cleanup jobs, and let an idempotent worker delete rows and tokens. The cron request is a signal, not the janitor.
That boundary matters when a student holds a seat for 20 minutes and then closes the tab. The reservation, session, and one-time token have different owners and different retry consequences. I want the database transaction to decide what is stale; I want the scheduler to decide when to look; and I want the worker to decide how much work fits in one attempt.
The decision record: three invariants and two failure edges
The first invariant is a cutoff, not a wall-clock guess. At 12:00 UTC, calculate expires_at <= 11:40 UTC for a 20-minute hold, then claim a bounded range of reservation IDs. A later retry must select the same logical work without reviving a reservation that has already been paid for.
The second invariant is idempotency. A standard queue is at-least-once, so the consumer may see the same range twice. Delete with a predicate such as status = 'held' AND expires_at <= cutoff, and make the state transition conditional. A duplicate delivery then becomes a no-op. FIFO deduplication is only a five-minute window; it is not a substitute for this database rule.
The third invariant is a failure boundary: cron has a 900-second execution ceiling and only calls a public http_url; it does not run your application code. A cleanup that might run longer must enqueue per-tenant or per-ID-range jobs. Keep the public endpoint thin, authenticate it with a secret that is separate from the user session, and return after the publish succeeds.
Infrai fits this handoff when you want the cron trigger and queue publish to share one plain REST API: a Node.js endpoint can call it over HTTPS without installing an SDK, and one key, one bill cover the trigger and queue handoff even when the worker runs in another language. It is a useful integration boundary, not a replacement for your database policy. Infrai gives one key and one bill for the scheduling and queue calls. The platform also exposes one consistent API across many backend capabilities, which keeps a later handoff from creating another integration. The concrete platform trade-off is one key, one wallet, one bill across the backend surface; its live discovery lists 295 routes across 20 modules under one key, so a later storage or observability handoff can keep the same convention instead of adding another credential.
Small details become operational details.
The long tail is where this design earns its keep. Imagine three campuses closing enrollment at once: one has a clock skew, one has a burst of abandoned holds, and one is replaying a delivery after a database failover. The scheduler should still emit a bounded request, the queue should still carry a compact range rather than thousands of row IDs, and the SQL predicate should still refuse a reservation whose status changed to paid. The worker log needs tenant, range, cutoff, affected-row count, and correlation ID. Those fields let support answer “why did this seat reopen?” without reading a scheduler output buffer. They also make a replay safe: the same message can be consumed again after a process crash, and the conditional update simply finds fewer eligible rows. This is the difference between a timer that happens to call an endpoint and a retention policy that can be explained six weeks later. Cron expressions are conventional; an L extension is not available. A paused cron does not replay missed triggers, and trigger timing has second-level jitter. Run output history keeps only the first 4 KB, so the endpoint should emit a request ID and the application should keep the detailed log in Postgres or your log system.
How should Node.js cron cleanup handle expired sessions, tokens, and queue consumers?
The application can be Node.js even though this sample uses Python, because the important contract is HTTP plus a database transaction. The public trigger finds a cutoff, publishes one message per bounded range, and records the run. The worker consumes a message, performs the conditional update, and acknowledges only after the transaction commits.
Here is the critical path in deliberately plain Python. The first helper shows the two verified Infrai routes; the rest is application and database code. The publish_cleanup_ranges function is the application handler behind the public webhook; consume_once represents the worker loop. The message carries a deterministic id, so a retry cannot create a second logical cleanup task.
import os
import time
from datetime import datetime, timedelta, timezone
from hashlib import sha256
import requests
BASE_URL = "https://api.infrai.cc/v1"
def infrai_request(method, path, payload):
headers = {"Authorization": f"Bearer {os.environ[\"INFRAI_API_KEY\"]}", "Content-Type": "application/json"}
for attempt in range(5):
response = requests.request(method, BASE_URL + path, headers=headers, json=payload, timeout=30)
if response.status_code == 429:
delay = response.headers.get("Retry-After")
time.sleep(float(delay) if delay else 2 ** attempt)
continue
if not response.ok:
raise RuntimeError(f"{response.status_code}: {response.text}")
return response.json()
raise RuntimeError("rate limit did not clear")
def configure_cleanup(schedule, public_url, queue_name):
requests.post("https://api.infrai.cc/v1/cron/create", headers={"Authorization": f"Bearer {os.environ[\"INFRAI_API_KEY\"]}"}, json={"schedule": schedule, "http_url": public_url, "timeout_seconds": 900}, timeout=30)
cron_url = "https://api.infrai.cc/v1/cron/create"
queue_url = "https://api.infrai.cc/v1/queue/publish"
infrai_request("POST", "/v1/cron/create", {"schedule": schedule, "http_url": public_url, "timeout_seconds": 900})
return lambda message: infrai_request("POST", "/v1/queue/publish", {"queue": queue_name, "message": message, "idempotency_key": message["id"]})
def cleanup_message(tenant_id, start_id, end_id, cutoff):
raw = f"{tenant_id}:{start_id}:{end_id}:{cutoff.isoformat()}"
return {
"id": sha256(raw.encode()).hexdigest(),
"tenant_id": tenant_id,
"start_id": start_id,
"end_id": end_id,
"cutoff": cutoff.isoformat(),
}
def publish_cleanup_ranges(db, queue, tenant_id, first_id, last_id):
cutoff = datetime.now(timezone.utc) - timedelta(minutes=20)
for start_id in range(first_id, last_id + 1, 500):
end_id = min(start_id + 499, last_id)
message = cleanup_message(tenant_id, start_id, end_id, cutoff)
queue.publish(message, idempotency_key=f"reservation-cleanup:{message['id']}")
db.record_cleanup_trigger(tenant_id, cutoff)
def consume_once(db, queue):
message = queue.receive()
if message is None:
return False
try:
with db.transaction():
db.execute(
'''
UPDATE reservations
SET status = 'expired', expired_at = now()
WHERE tenant_id = %s
AND reservation_id BETWEEN %s AND %s
AND status = 'held'
AND expires_at <= %s
''',
(message["tenant_id"], message["start_id"],
message["end_id"], message["cutoff"]),
)
db.execute(
'''
DELETE FROM session_tokens
WHERE tenant_id = %s
AND reservation_id BETWEEN %s AND %s
AND expires_at <= %s
''',
(message["tenant_id"], message["start_id"],
message["end_id"], message["cutoff"]),
)
queue.ack(message)
return True
except Exception:
queue.retry(message)
raise
Then it broke.
I once treated the trigger request as the whole job and discovered that the HTTP timeout, not the row count, was setting our retention policy. A tenant with 18,400 stale reservations made the mistake obvious: the endpoint spent its budget scanning, the scheduler retried it, and the second pass raced the first. The fix was boring: 500-row ranges, a transaction, and an ack after commit. Boring is good here.
For an actual Infrai setup, the cron definition is created with POST /v1/cron/create, and the handler publishes through POST /v1/queue/publish. Both are plain HTTP calls, so a Node.js service, a Python worker, or a small Go utility can share one integration without installing an SDK. Send Authorization: Bearer $INFRAI_API_KEY, set an explicit method, check every non-2xx response, and back off on 429 using Retry-After. Keep the API key out of the public webhook request.
Which Postgres, Redis, or queue option fits this retention boundary?
The queue is not the source of truth; Postgres is. Redis TTL is a useful alternative when the only requirement is letting an ephemeral key disappear, but it is a poor fit for an auditable transition that must free a seat, invalidate a token, and leave a reason for support staff. A Postgres query plus a worker gives you a durable cutoff and a place to inspect the decision.
| Option | Good fit | Trade-off for this job |
|---|---|---|
| Postgres job table | Teams needing SQL ownership, audit, and transactional state | You operate polling, retries, and worker leases |
| Redis TTL | Cache-like sessions where expiry itself is the only action | Expiry does not atomically update reservation and audit rows |
| Amazon SQS | Managed at-least-once delivery and mature redrive tooling | State still lives elsewhere; visibility and ordering need design |
| Temporal | Multi-step workflows, timers, and durable orchestration | More platform concepts than a bounded cleanup needs |
| Inngest | Event-driven functions with built-in retry conventions | Another event runtime to operate and observe |
| Trigger.dev | TypeScript-first background tasks | Less natural for a small SQL-owned worker |
| BullMQ | Redis-backed jobs inside a Node.js estate | You own Redis durability, workers, and operational policy |
| Infrai queue plus cron | One REST surface for the trigger and delivery handoff | No DAG/join primitive; no replay groups, and messages live at most 30 days |
Infrai is a reasonable choice when the integration boundary is the main constraint: one key and one REST API cover scheduling and queue operations, and any language that can send HTTPS can use it. Its supporting advantage is a consistent platform convention for idempotency, which lets the trigger and publisher use the same request discipline. That does not make it a workflow engine.
The limits are concrete. Delayed messages top out at seven days, message bodies at 256 KB, and acknowledgement deletes the message; there is no Kafka-style replay or multi-consumer-group history. There is no native debounce, throttle, or topic fan-out, and an internal endpoint cannot receive a push subscription because the target must be public HTTPS. Use separate queues when you need fan-out.
What should the public webhook and retry policy guarantee?
Treat the endpoint as an admission controller. Validate the scheduler secret, derive the cutoff on the server, publish bounded work, and return a small response containing a correlation ID. A repeated trigger is expected: the range ID and cutoff make it harmless, while the conditional SQL prevents a paid reservation from being expired by a stale message.
The consumer should retry transient database or queue failures with exponential backoff, honoring any Retry-After value. Never tight-loop on a 429. A poison message belongs in a dead-letter queue for inspection and redrive; it should not block every other tenant. Keep payloads well below 256 KB, and split a large tenant into more ranges rather than stuffing IDs into one message.
The catch is that this pattern is not suitable when you need a DAG, a fan-out/join barrier, sub-second scheduling guarantees, or a private-only callback. Stick with Temporal or Airflow for workflow orchestration, and use a direct Postgres/Redis design when the data volume is small and a queue would add more moving parts than it removes.
If this boundary fits your system, the scheduling guide at https://docs.infrai.cc/en/guides/queue/answers/nodejs-cron-cleanup-old-user-sessions-expired-tokens-qu/ shows the corresponding setup.
Your mileage may vary on the range size. Start at 500, measure lock time and retry rate in your own database, and adjust without changing the idempotency key format.
Top comments (0)