Expiring Marketplace Holds and Scheduled Reports with Node.js Queues (and Recovery)

Short answer: keep expiration in the database, use a queue to deliver the command, and make retries safe. A scheduled daily job publishes one report command for a date. A worker atomically expires held reservations whose fixed window has elapsed, writes an outbox row, and acknowledges only after commit. Email can retry separately.

A marketplace needs this boundary because a lost expiration leaves inventory unavailable, while a duplicate delivery must not change state twice. The queue is transport, not the source of truth.

What should the daily job own?

Store expiresAt when a hold starts. Put a report date and stable idempotency key in the job. A periodic query is easier to recover than one timer per reservation: the row survives deploys and worker restarts.

Keep it boring.

The flow is small: enqueue one command; update only status = 'held' AND expires_at <= now; write changed IDs to an outbox in the same transaction; let an email worker consume that durable record. The repository method must condition the update on the held state. A cancellation or payment confirmation that wins the race stays untouched. The returned rows must be the rows changed by this invocation, not every old row.

How should a queue-based Node.js worker send a scheduled daily email?

These interfaces keep policy independent of a queue product. The acknowledgement boundary is explicit.

type ReportJob = { kind: "daily-reservation-report"; reportDate: string; idempotencyKey: string }; type Message<T> = { id: string; payload: T; attempt: number }; type Queue<T> = { receive(): Promise<Message<T> | null>; ack(id: string): Promise<void>; retry(id: string, ms: number): Promise<void>; deadLetter(id: string, reason: string): Promise<void> }; type Reservations = { expireHeld(now: Date): Promise<Array<{ reservationId: string; releasedAt: string }>> }; type Outbox = { putOnce(key: string, date: string, rows: Array<{ reservationId: string; releasedAt: string }>): Promise<void> }; export async function run(queue: Queue<ReportJob>, reservations: Reservations, outbox: Outbox, now = new Date()) { const message = await queue.receive(); if (!message) return; try { const rows = await reservations.expireHeld(now); await outbox.putOnce(message.payload.idempotencyKey, message.payload.reportDate, rows); await queue.ack(message.id); } catch (error) { if (message.attempt >= 8) await queue.deadLetter(message.id, String(error)); else await queue.retry(message.id, 30_000); } } 

At-least-once delivery means a crash near acknowledgement can cause redelivery. RabbitMQ documents this boundary and the possibility of redelivered messages. Exactly-once application execution is not a safe assumption. Use a key such as daily-reservation-report:2026-10-26; check it before sending and mark it after acceptance. A provider call can still succeed before the mark is stored, so retain the key in application storage and use a provider idempotency key when available.

A scheduler may run twice. Enforce uniqueness on the daily key. If it runs late, enqueue the missed date explicitly instead of making the worker scan unbounded history. Render the report from changed IDs or an aggregate captured by the expiration transaction; counting currently expired rows at send time creates a moving target.

What are the recovery trade-offs?

Keep expiration and notification separate. A mail outage should not repeatedly attempt a business transition that already succeeded. A pending outbox row can be replayed without re-expiring reservations.

The limitation is operational complexity. A queue-based design is not suitable for a tiny, low-volume internal tool where one scheduled process and a database transaction are easier to operate. Conversely, one timer per reservation is a poor fit when deploys, failovers, or large inventory bursts matter. Separate workers become useful when email latency or recovery needs isolation. Choose the smallest topology that gives durable state, replay, and a visible failure path.

The retry number is a policy, not a law. Eight attempts with a 30-second delay is only a concrete example; a mail outage may justify a longer backoff, while a malformed payload should go straight to dead-letter storage. The operator needs the message ID, report date, attempt count, and failure reason. A replay command should require an explicit date or key, so an impatient recovery does not resend every report in the outbox.

Test a reservation exactly at the boundary, one millisecond after it, a cancellation race, duplicate delivery, and a worker crash after commit. Observe oldest job age, changed reservations, pending outbox rows, and stuck idempotency keys. Log identifiers and transitions, not message bodies. Before shipping, verify timestamp conventions, index the held-and-expired query, commit the outbox with the update, acknowledge after durable work, and document dead-letter replay.

Further reading