Skip to content

Delivery guarantees

A delivery guarantee says how many times a task can run for one delay() call:

Broker Guarantee A crash mid-task means
RedisBroker at-least-once the task runs again after the visibility timeout
RabbitBroker at-least-once the task runs again once RabbitMQ sees the connection drop
InMemoryBroker at-most-once the task is lost (it was in this process anyway)

At-least-once means a task is never silently lost once enqueued, but it can run more than once. gisila_queue does not offer exactly-once; you get that effect by making handlers idempotent.

Rely on at-least-once for anything that must happen: charges, emails, webhooks, data syncs. Pair it with idempotent handlers.

If running twice is worse than not running (and you can’t make it idempotent), you need a different design: record the intent in your database in the same transaction as the business change, and have the task check that record before acting.

Redis. reserve pops a message and writes it to an “unacked” ledger in one Lua script, keyed by a fresh delivery tag. While the handler runs, the worker touches the entry every third of visibilityTimeout (5 minutes by default). After the outcome is recorded the worker acks, which removes the entry. If a worker dies, its entries stop being touched; once older than visibilityTimeout, the next reserve on any worker puts them back on their queue with redeliveries + 1. The visibility timeout bounds how long recovery takes, not how long a task may run.

RabbitMQ. Deliveries are manually acked. Unacked deliveries go back to the queue when the channel closes or the process dies.

Redelivery limit. A message redelivered more than maxRedeliveries times (5) is not run again: it is recorded as failed and dead-lettered. This stops a “poison” task that crashes the process from taking down worker after worker.

Where duplicates come from:

  • A worker crashes after the handler succeeded but before the ack.
  • A worker is stopped with stop(timeout:) and a task outlives the timeout; it stays unacked and is redelivered.
  • A retry copy is enqueued and the worker dies before acking the original.
  • A handler runs longer than the visibility timeout while the worker can’t reach Redis to touch it.

Idempotency tools:

  • Use natural keys: “set status to shipped” instead of “increment shipped count”.
  • Check before acting: look up whether the email for orderId was already sent.
  • Use send(dedupeKey:) to stop the producer from enqueuing the same thing twice (double clicks, retried HTTP requests). It doesn’t stop a redelivered message from running twice.
  • Pass ids, not objects, so a rerun reads current state.
import 'package:gisila_queue/gisila_queue.dart';
app.task<void>('orders.send_receipt', (ctx) async {
final orderId = ctx.arg(0) as int;
final order = await orders.find(orderId);
if (order.receiptSentAt != null) return null; // already done on a previous delivery
await mailer.sendReceipt(order);
await orders.markReceiptSent(orderId);
return null;
}, maxRetries: 5, autoRetry: true);