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 duplicate dedupeKey is dropped:
await q.push({ orderId: 'o_123' }, { dedupeKey: 'charge:o_123' })
OptionMeaning
delaySecondsHold the message invisible for this long before any consumer can pull it.
dedupeKeyIdempotent enqueue. A second push with the same dedupeKey is dropped, so a retried producer doesn’t double-enqueue.
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.
  • 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 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.
06Observe

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.


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