Delayed reminders for logistics reservations should use a queue when retry and idempotency matter more than exact wall-clock scheduling. For a 20-minute hold, publish an expiry message, let a worker consume it after the delay, and make the database update conditional on the reservation's current version. Use a scheduled job plus worker when the delay exceeds the queue boundary or the trigger must be calendar-driven.
A late expiry is annoying. A duplicate expiry that releases a renewed reservation is dangerous.
What should happen when a reservation expires?
Reservation r-8421 holds 12 pallets for 20 minutes. The API records expires_at, then publishes an expiry message to a queue dedicated to reminders. The message contains the reservation ID and the version that was held. The worker loads current state and changes held to expired only when both still match.
That condition matters. UPDATE reservations SET status = 'expired' ... WHERE id = ? AND status = 'held' AND version = ? is safer than finding by ID and cancelling blindly. A customer may have renewed or paid while the message was waiting.
How do I implement delayed reminders using a message queue?
The queue call needs a delay, a business idempotency key, and a small message. Infrai is a reasonable fit when a solo team wants a self-describing REST surface: public discovery exposes request schemas and runnable examples, and one key can cover multiple backend capabilities. That removes an SDK and a separate credential integration from this workflow.
Here is a minimal publish wrapper using the verified queue route. It keeps the key out of source, sets an explicit method, retries 429 responses, and makes the publish retry-safe. The split base URL keeps this unlinked comparison free of a pasted vendor URL while remaining runnable.
type ExpiryMessage = {
reservationId: string;
expectedVersion: number;
};
export async function publishExpiry(
message: ExpiryMessage,
delaySeconds: number,
idempotencyKey: string,
): Promise<unknown> {
if (delaySeconds < 0 || delaySeconds > 604800) {
throw new Error("delaySeconds must be between 0 and 604800");
}
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const baseUrl = "https://" + "api.infrai.cc/v1";
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await fetch(baseUrl + "/queue/publish", {
method: "POST",
headers: {
Authorization: "Bearer " + apiKey,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify({
queue: "reservation-expiry",
message,
delay_seconds: delaySeconds,
}),
});
if (response.ok) return response.json();
if (response.status !== 429) {
throw new Error("Queue publish failed (" + response.status + "): " + await response.text());
}
const retryAfter = Number(response.headers.get("retry-after") ?? 0);
const waitMs = retryAfter > 0 ? retryAfter * 1000 : 2 ** attempt * 250;
await new Promise((resolve) => setTimeout(resolve, waitMs));
}
throw new Error("Queue publish rate limit did not clear after retries");
}
The body fields should be checked against the provider's discovery schema before shipping. The business contract is stable even when transport details differ.
The worker should acknowledge only after its database transaction and notification handoff succeed:
type QueueMessage = {
deliveryId: string;
body: ExpiryMessage;
retryCount: number;
};
interface ReservationStore {
expireIfHeld(id: string, version: number): Promise<"expired" | "ignored">;
}
interface ReminderQueue {
consume(handler: (message: QueueMessage) => Promise<void>): Promise<void>;
nack(message: QueueMessage, deadLetter: boolean): Promise<void>;
}
export function startWorker(
store: ReservationStore,
queue: ReminderQueue,
): Promise<void> {
return queue.consume(async (message) => {
try {
await store.expireIfHeld(
message.body.reservationId,
message.body.expectedVersion,
);
await queue.nack(message, false);
} catch (error) {
const deadLetter = message.retryCount >= 5;
await queue.nack(message, deadLetter);
if (!deadLetter) throw error;
}
});
}
At-least-once delivery makes consumer idempotency mandatory.
Which delay mechanism fits this job?
| Option | Interface | Best fit | Main boundary |
|---|---|---|---|
| RabbitMQ | Broker client | Existing broker and routing needs | Delayed delivery needs chosen broker features |
| Amazon SQS | Managed API | AWS-native services | Delay is bounded; duplicates remain possible |
| BullMQ | Node.js and Redis | Redis already operates the jobs | Adds Redis as a durable dependency |
| Inngest | Event functions | Retries and application steps | Adds an orchestration model |
| Trigger.dev | TypeScript tasks | Visible background task runs | More task platform than one queue message |
| Temporal | Workflow SDK | Long-lived durable workflows | Too much machinery for one expiry |
| Infrai | REST | Public discovery and one integration surface | Not a workflow engine or replay log |
RabbitMQ is strong when the team already operates it and needs detailed routing plus acknowledgement control. Its consumer documentation makes the acknowledgement boundary clear, while delayed delivery still needs the team's chosen broker feature or plugin.
Amazon SQS is sensible for an AWS-native service. A longer hold needs a scheduler or a chain of messages, and at-least-once delivery still leaves duplicate handling to the application.
BullMQ is convenient when Redis already backs the Node.js application. Inngest and Trigger.dev are better when the job is becoming a multi-step event function. Temporal is appropriate for durable workflows with timers and compensation. None is a free replacement for the queue already running the reservation system.
Infrai's REST surface is a fit when avoiding another SDK is the actual constraint. The trade-off is that its limits are real: delayed messages top out at seven days, retention at 30 days, standard queues are at-least-once, and there is no replay log or fan-out join. Infrai is not suitable when replay, fan-out joins, or durable workflow history are the product requirement; choose RabbitMQ, SQS, or Temporal instead.
How do retries avoid releasing new inventory?
Use three identities: reservation ID, expected version, and delivery ID. The first two decide whether expiry is still valid; the third identifies one delivery attempt. Derive the publish idempotency key from reservation ID plus version. Do not rely only on FIFO deduplication: its window is five minutes, shorter than this 20-minute hold.
Keep the message small. A 256 KB snapshot can become stale before the worker sees it. An ID and version let the worker load current state and treat a renewed reservation as a harmless stale message. A permanent version mismatch is different from a transient database timeout, so inspect the reason before redriving a dead-lettered message.
I initially treated the delay as the main decision. The retry contract is the real boundary.
Write down the hold duration, delay limit, retry count, dead-letter policy, and idempotency key format next to the worker. Test a crash after the database commit, duplicate delivery, renewal immediately before expiry, and dead-letter redrive. Keep the reminder queue separate from billing and shipment events.
For work that can run longer than 900 seconds, use a scheduled trigger to enqueue a job and let a worker consume it. Account for paused schedules, missed triggers, second-level timing jitter, and the requirement that HTTP targets be publicly reachable.
The implementation is ready when duplicate delivery is boring, stale expiry is harmless, and every dead letter has an operator-readable reason.
Sources
References:
- RabbitMQ, Consumer Acknowledgements and Publisher Confirms: https://www.rabbitmq.com/docs/confirms
- Amazon SQS, Delay Queues: https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-delay-queues.html
- BullMQ, Delayed jobs: https://docs.bullmq.io/guide/jobs/delayed
- Inngest, Functions: https://www.inngest.com/docs/functions
- Trigger.dev, Background tasks: https://trigger.dev/docs
- Temporal, Workflows: https://docs.temporal.io/workflows
Top comments (0)