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 repeat dedupeKey within an hour enqueues nothing
const first = await q.push({ orderId: 'o_123' }, { dedupeKey: 'charge:o_123' })
const again = await q.push({ orderId: 'o_123' }, { dedupeKey: 'charge:o_123' })
// again.id === first.id, again.deduped === true| Option | Meaning |
|---|---|
delaySeconds | Hold the message invisible for this long (up to 24 hours) before any consumer can pull it. |
dedupeKey | Idempotent enqueue. A second push with the same dedupeKey within an hour enqueues nothing and returns the first message’s id with deduped: true, so a retried producer doesn’t double-enqueue. |
A message body is any JSON value up to 256 KB. On the free tier a namespace accepts 1,000 queue messages a day; the push after that answers 429 until midnight UTC.
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. - One receipt per delivery. Every delivery carries its own
receipt. Once a lease runs out and the message goes to another consumer, the old receipt no longer acks or nacks it, so a slow worker can’t delete a copy someone else is still processing. - Dead-letter queue. After 5 deliveries that end in a nack or an expired lease, a message moves to the dead-letter queue instead of retrying forever. It stays there for 14 days, where you can inspect, replay or purge 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.Dead letters
const { messages, total } = await q.deadLetters({ limit: 20 })
for (const m of messages) console.log(m.id, m.reason, m.attempts, m.deadAt)
await q.replay({ ids: [messages[0].id] }) // back on the queue, retries reset
await q.replay() // the oldest 100
await q.purgeDead({ ids: [messages[1].id] }) // delete for goodreason is timeout when the last lease expired and nack when your handler gave it back. A replayed message starts again at attempts: 1 on its next delivery. Replay and purge need a read-write token.
Owners and admins can do the same from the dashboard. On the Queues page, a queue’s dead-lettered count opens its dead letters: each message with its body, attempts and when it failed. Replay or purge the ones you select, or all of them. Each replay and purge is recorded in the audit log with who did it and how many messages it moved. From a terminal, tilldev cache dead jobs --ns <namespace> lists them, and tilldev cache replay and tilldev cache purge-dead act on them.
Depth & lag in TillPulse
Queue depth, in-flight and dead-lettered counts surface in TillPulse automatically, whichever backend the namespace uses. They refresh every time the queue is pushed to or pulled from. When a queue backs up, you see it in the workspace you already use.
The Queues page in the dashboard also charts each queue’s last 24 hours in half-hour steps: the peak depth and the longest wait of the oldest message. Hover a step for its numbers. The same history is returned as lag_history by the API.