TILLCACHE · QUEUES

Durable work, at least once.

GA

A 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.

01Get a queue

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.

02Produce

push()

ts
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
OptionMeaning
delaySecondsHold the message invisible for this long (up to 24 hours) before any consumer can pull it.
dedupeKeyIdempotent 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.

03Consume

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.

ts
// 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:

FieldWhat it is
idStable message id.
bodyThe payload you pushed, deserialized.
receiptThe lease handle — pass it to ack() or nack(). It changes each time the message is redelivered.
attemptsHow many times this message has been delivered. Drives the dead-letter threshold.
enqueuedAtWhen the message was first pushed.
04Loop

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.

ts
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
})
OptionMeaning
maxHow many messages to pull per batch.
visibilitySecondsLease length per message while your handler runs.
idleMsHow long to wait before polling again when the queue is empty.
signalAn AbortSignal — abort it for a clean shutdown between batches.
05Semantics

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 and attempts increments. 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 dedupeKey on push and both ends are covered — no duplicate enqueue, safe redelivery.
Make handlers idempotent
Because delivery is at-least-once, design handlers so that processing the same msg.id twice is a no-op — check a “done” marker in KV, or write with a unique key downstream.
06Recover

Dead letters

ts
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 good

reason 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.

07Observe

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.


Next: Pub/Sub for fan-out that isn’t a work queue, or KV for the state your handlers read and write.