Queen MQ
Concepts

The shape of Queen.

Queen has six concepts. Once you have them in your head you can predict what every API call will do without looking it up.

Queues

A queue is a logical container of messages. Each queue has its own configuration:

Queues are created on first push by default. Call configure first if you want non-default settings:

configure a queue
await queen.queue('orders')
  .config({
    leaseTime: 300,        // 5 minutes
    retryLimit: 3,
    retentionSeconds: 86400,
    encryptionEnabled: false,
  })
  .create()

Optionally, queues can also belong to a namespace and a task. These are tags that let consumers pop from many queues at once (?namespace=billing&task=process-invoice) without naming them individually.

Partitions

A partition is an ordered lane inside a queue. Messages pushed to the same partition are guaranteed to be processed in order; messages on different partitions can be processed in parallel.

Unlike Kafka, partitions in Queen are not physical commit-logs. They're logical, there is no quota, no rebalance, no broker pinning. Make one per user, tenant, chat, video, or device. Tens of thousands per queue is normal.

Four producers (chat ui, scheduler, webhook, replay) push into one queue (agent-tasks). The queue is split into five ordered partitions per agent session. Two consumer groups (agent runner, tracer) each read every message independently. One slow session running a 28-second tool call stalls only its own partition; the other lanes keep flowing.
One slow session stalls only its own lane. Producers push to agent-tasks; each session is its own FIFO partition; agent runner and tracer each consume the same stream with their own offset. A 28-second tool call on sess:9c1d never touches the other 9,999 sessions.
Why partitions matter

No head-of-line blocking

If user A's message takes 30 seconds to process, user B's message on a different partition is unaffected. The lane is the unit of ordering, not the bottleneck.

Default partition

You can ignore partitions

Pushing without a partition uses the special Default partition. Plenty of workloads (broadcast events, fire-and-forget jobs) don't need ordering at all.

per-user ordering
// All events for user-123 are processed in push-order.
await queen.queue('user-events')
  .partition('user-123')
  .push([
    { data: { event: 'login' } },
    { data: { event: 'view-page' } },
    { data: { event: 'logout' } },
  ])

Consumer groups

A consumer group is a named offset over a queue. The semantics:

fan-out: same events, two pipelines
// Group 1, sends emails. Two workers in this group share the load.
await queen.queue('events').group('emailer').consume(handler1)
await queen.queue('events').group('emailer').consume(handler2)

// Group 2, analytics. Sees the same events independently.
await queen.queue('events').group('analytics').consume(analyticsHandler)

Subscription modes

When a brand-new consumer group connects, it can choose where to start:

ModeBehavior
(default, all) Process every message ever pushed, including the entire backlog.
new Skip everything older than the moment the group joins; only process messages pushed after.
subscriptionFrom: '2025-12-01T00:00:00Z' Replay from a specific timestamp.
A long timestamped tape from September 2024 to NOW, with two consumer-group cursors. The agent-runner cursor is at the head (live, lag 0). A new report-fall-2024 group joins with subscriptionFrom Sep 1, 2024 and sweeps from that past timestamp toward the head, replaying the historical window without disturbing the live group.
Per-group offset, replay any timestamp. The live agent-runner group keeps consuming at the head while a new report-fall-2024 group materialises at subscriptionFrom: 2024-09-01 and replays the historical window. PostgreSQL is the source of truth, there is no separate log retention to manage.

Leases & retries

A popped message is leased to one consumer for leaseTime seconds. If the consumer doesn't ack within that window the lease expires and the message is re-queued. If the consumer acks with failed, the message goes back to the queue (or the DLQ if it has exceeded retryLimit).

Long-running jobs

For tasks that may exceed leaseTime (LLM calls, video transcodes), have the client renew the lease in the background:

await queen.queue('jobs')
  .renewLease(true, 2000)   // renew every 2s
  .autoAck(false)
  .each()
  .consume(async (m) => { /* long work */ })
State diagram showing the lease lifecycle. A message is popped from the queue and leased to consumer-1 with a 30-second timer. On success it moves to a completed state; on failure or lease expiry the retry counter increments and the message returns to the head of the partition. After the configured retryLimit (3) the message routes to the dead-letter queue with the original payload, error, and retry trace.
Lease → retry → DLQ. Same diagram covers the failure path described in Dead-letter queue: after retryLimit the message is moved with the last error and the original payload, available to browse and replay from the dashboard.

Transactions

A transaction is an atomic bundle of ack + push operations. Either the whole bundle commits, or none of it does. This is what makes multi-stage pipelines exactly-once.

two-stage pipeline
await queen.queue('raw')
  .autoAck(false)
  .each()
  .consume(async (m) => {
    const out = transform(m.data)

    await queen
      .transaction()
      .ack(m)                                  // mark input completed
      .queue('processed').push([{ data: out }]) // emit derived
      .commit()                                 // single PG transaction
  })

Under the hood this is a single BEGIN…COMMIT against PostgreSQL. The ack and the insert are in the same WAL record, there's no in-between state where the input is acked but the output never landed.

A worker pops a message from queue A (raw-events), transforms it inside a glowing BEGIN/COMMIT envelope, and pushes the derived message into queue B (processed-events). The ack of the input and the insert of the output are wrapped in one PostgreSQL transaction with a single WAL record. The SQL inset shows the actual UPDATE messages_consumed plus INSERT INTO messages, both inside one BEGIN..COMMIT block.
Atomic across queues. The ack of the input and the push of the output are written in the same WAL record. Either both commit, or neither does, no orphan output, no duplicate ack, no separate outbox table.

Long polling

Pop with wait=true and the server holds the connection open until a message arrives, the timeout expires, or you cancel the request. No busy-loop polling on the client.

blocking pop
curl 'http://localhost:6632/api/v1/pop/queue/jobs?wait=true&timeout=30000&autoAck=true'

In a multi-instance cluster, pushes broadcast a UDP "wake-up" packet to peer servers so a long-poll on instance B can return immediately when instance A pushes, no database polling involved.

Dead-letter queue

Each queue can opt into a DLQ. When a message exceeds retryLimit (or the per-queue maxWaitTimeSeconds) Queen moves it to the DLQ along with the last error message, retry count, and original timestamps. You can browse, filter, and replay DLQ messages from the dashboard or the HTTP API.

list DLQ messages
curl 'http://localhost:6632/api/v1/dlq?queue=orders&limit=50'

Disk failover

If PostgreSQL becomes unreachable, pushes do not fail. The server writes them to a local file buffer (FILE_BUFFER_DIR) and acknowledges to the client. When PostgreSQL recovers, a background scanner streams the buffered events back into the database in order. Result: zero message loss even through failovers, restarts, and short network partitions.

Push-only path

The disk buffer covers the push path. Pop and ack require the database to be live. For full HA, run the server behind multiple replicas and configure PostgreSQL with a streaming replica + automatic failover.

Three-phase loop showing disk failover. Phase 1: producers push to the broker, broker commits to PostgreSQL, all healthy. Phase 2: PostgreSQL becomes unreachable; new pushes spill to a local FILE_BUFFER on disk, the buffered counter grows, and clients still receive HTTP 201. Phase 3: PostgreSQL recovers; a background scanner drains the file buffer back into the database in order. The messages-lost counter stays at 0 throughout.
Push path always succeeds. When PG is unreachable, pushes spill to FILE_BUFFER on local disk and clients still get an HTTP 201. When PG recovers, a background scanner drains the buffer back in order. Verified across 10.4B+ messages with zero loss.

Tracing

Every message can carry a traceId. Push the same trace ID across multiple queue stages and Queen stitches them together into a timeline you can visualize in the dashboard. Useful for debugging multi-stage pipelines (translate → agent → notify), showing per-stage latencies, error correlations, and consumer-group ownership.


Where next?

Code

Client patterns

Side-by-side recipes in JavaScript, Python, Go, PHP, and C++.

Reference

HTTP API

Every endpoint, parameter, and response shape.

Operate

Server setup

Env vars, JWT auth, K8s, multi-instance cluster.