Delivery guarantees
What it is
Section titled “What it is”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.
When to use it
Section titled “When to use it”Rely on at-least-once for anything that must happen: charges, emails, webhooks, data syncs. Pair it with idempotent handlers.
When not to use it
Section titled “When not to use it”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.
How it works
Section titled “How it works”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
orderIdwas 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.
Example
Section titled “Example”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);