Durable work, at least once.
GAA queue is durable, at-least-once delivery with delays, idempotency keys, a per-message visibility timeout, and an automatic dead-letter queue after too many retries. Get one by name off any client — no separate setup.
cache.queue(name)
cache.queue('name') returns a handle. The queue exists as soon as you push to it; the name scopes producers and consumers to the same work stream.
push()
import { cache } from './cache'
const q = cache.queue('emails')
await q.push({ to: 'ada@example.com', template: 'welcome' })
// Run it 30 seconds from now:
await q.push({ to: 'ada@example.com', template: 'nudge' }, { delaySeconds: 30 })
// Idempotent enqueue — a duplicate dedupeKey is dropped:
await q.push({ orderId: 'o_123' }, { dedupeKey: 'charge:o_123' })| Option | Meaning |
|---|---|
delaySeconds | Hold the message invisible for this long before any consumer can pull it. |
dedupeKey | Idempotent enqueue. A second push with the same dedupeKey is dropped, so a retried producer doesn’t double-enqueue. |
pull / ack / nack
pull() leases messages: each becomes invisible to other consumers for visibilitySeconds. You ack(receipt) to finish one, or nack(receipt) to release it early for another attempt.
// Pull up to 10, lease them for 30s:
const messages = await q.pull({ max: 10, visibilitySeconds: 30 })
for (const msg of messages) {
try {
await handle(msg.body)
await q.ack(msg.receipt) // done — remove it
} catch {
await q.nack(msg.receipt) // failed — make it visible again to retry
}
}Each pulled message carries:
| Field | What it is |
|---|---|
id | Stable message id. |
body | The payload you pushed, deserialized. |
receipt | The lease handle — pass it to ack() or nack(). It changes each time the message is redelivered. |
attempts | How many times this message has been delivered. Drives the dead-letter threshold. |
enqueuedAt | When the message was first pushed. |
The consume() convenience loop
consume() is the batteries-included worker: it pulls, hands each message to your handler, and auto-acks on return / auto-nacks on throw. It loops until the signal aborts.
const ac = new AbortController()
await q.consume(async (msg) => {
await handle(msg.body) // return normally → auto-ack
// throw → auto-nack (retry)
}, {
max: 10, // batch size per pull
visibilitySeconds: 30, // lease per message
idleMs: 1000, // wait when the queue is empty before polling again
signal: ac.signal, // ac.abort() to stop the loop cleanly
})| Option | Meaning |
|---|---|
max | How many messages to pull per batch. |
visibilitySeconds | Lease length per message while your handler runs. |
idleMs | How long to wait before polling again when the queue is empty. |
signal | An AbortSignal — abort it for a clean shutdown between batches. |
Delivery, retries & the DLQ
- At-least-once. A message is delivered at least once. If your handler crashes after doing the work but before acking, it will be redelivered — so make handlers idempotent.
- Visibility timeout. A pulled message is hidden for
visibilitySeconds(default 30s). If it isn’t acked in that window, it becomes visible again andattemptsincrements. Give slow work a longer lease. - Dead-letter queue. After N failed attempts (default 5) a message is moved to the dead-letter queue instead of retrying forever, where you can inspect and replay it.
- Idempotent enqueue. Pair the above with a
dedupeKeyon push and both ends are covered — no duplicate enqueue, safe redelivery.
msg.id twice is a no-op — check a “done” marker in KV, or write with a unique key downstream.Depth & lag in TillPulse
Queue depth and consumer lag surface in TillPulse automatically — no extra wiring. When a queue backs up, you see it (and can alert on it) in the workspace you already use.