Sizing a Queen deployment means sizing PostgreSQL, because the broker holds no data. And sizing PostgreSQL for this workload means counting transactions, not messages. Every number below follows from that one substitution.
The unit of cost is a commit
A push becomes one queen.log_segments row per (queue, partition) touched: many
messages packed as length-prefixed frames, zstd-compressed, stored with
STORAGE EXTERNAL so PostgreSQL does not waste CPU trying to re-compress bytes
that are already compressed. Writing that row is one transaction, and a
transaction is one WAL flush.
So the question that decides your hardware is not “how many messages per second” but “how many messages ride each commit”.
Three mechanisms move that ratio, and only the first is yours to control:
Client batch size. A push call carrying N messages for one partition becomes one segment row inside one commit. N pushes of one message each cannot be cheaper than that, and are usually much more expensive. This is the dominant lever in the whole system, ahead of every server-side knob, and it is free.
Push fusion. Concurrent pushes are coalesced by the broker before they reach
PostgreSQL. The batch size is emergent rather than configured: when the broker is
idle a push fires immediately on its own, and when it is busy the next segment
accumulates for exactly one in-flight flush round trip. Disjoint partitions are
additionally bundled into a single multi-segment transaction, so commit rate
decouples from partition count under load. QUEEN_V2_FUSION_FRAMES is retained
for compatibility and is no longer a flush trigger. There is no frame threshold
to tune.
Ack fusion. Cursor advances are coalesced the same way, one commit per flush for many cursors, with a 3 ms hold by default. It is on by default.
The consequence for the failure mode you will actually hit: a saturated Queen is
almost always commit-bound, not CPU-bound. queen_db_pool_idle pinned at zero and
pool_waiting non-zero in the rates log block mean requests are queueing for a
connection while PostgreSQL flushes.
The four numbers that tell you where you are
| Signal | Where | Reading |
|---|---|---|
queen_fusion_items_per_batch{op="push"} |
Prometheus | messages per commit. Low under load means your clients are not batching |
queen_batch_rtt_milliseconds{op="push"} |
Prometheus | how long a commit takes. Rising with flat throughput means the disk is the wall |
segments_per_commit |
the fusion log summary |
how many partitions share a transaction |
rows_per_commit |
the ack-fusion log summary |
cursors per ack commit |
The PostgreSQL-side lever behind all of them is the commit-durability setting, which trades data loss on power failure against commit rate. That is a PostgreSQL decision with PostgreSQL consequences, and the broker neither sets nor depends on it.
Partition count drives background work, message count does not
Three background loops iterate partitions on a fixed cadence, regardless of traffic:
- Retention, every
RETENTION_INTERVAL(5000 ms). It builds a work list of every partition of every queue, and its hash-sidecar purge phase runs for every partition on every cycle: the sidecar is written by every push whether deduplication is on or off, and expires on its own clock. - The stats reconciler, every
STATS_INTERVAL_MS(10000 ms), recomputingqueen.statswith arithmetic that is O(partitions). - The metrics collector, every
METRICS_FLUSH_MS(60000 ms), writing one bucket row per active (tenant, queue).
An idle deployment with 100 partitions and one with 100,000 differ in steady-state PostgreSQL CPU by three orders of magnitude while both move zero messages. If a cluster is burning CPU with no traffic, this is why, and the levers are the three intervals above. Lengthening them costs freshness in the dashboard and slower retention, nothing else.
Two aggravating factors:
- Partitions are reclaimed only after 30 days of being empty and idle
(
PARTITION_CLEANUP_DAYS). A partition is created by the first push that names it, and a design that mints one per user or per order carries its background cost for the whole retention window plus that month, and forever if the lane keeps receiving the occasional message, or holds a dead-letter row. - Every message costs 16 bytes in the hash sidecar for the length of the purge
window (
GREATEST(dedupWindowSeconds, completedRetentionSeconds, 900s)), on top of its compressed payload. That is small per message and very much not small at a billion of them.
Partition count is also what buys you consumer parallelism: exactly one in-flight leased batch exists per (partition, consumer group), so a single-partition queue cannot be consumed in parallel by one group. Choose the count for the parallelism you need, and then know what it costs to keep.
Connections and consumers
Each instance opens up to DB_POOL_SIZE connections, default 160. Multiply by
the number of instances and compare against the database’s max_connections
before you scale out; a connection pooler in front of PostgreSQL is a reasonable
answer, but the arithmetic is yours to do.
Parked consumers are cheap. A long-poll pop releases its pooled connection
before parking, so waiting consumers cost memory and a wake-up, not a
connection. Concurrent in-flight statements are the scarce resource, and the
adaptive concurrency limiters (visible as vegas_push and vegas_pop in the
rates block) are what keep them bounded.
Broker memory is dominated by two explicitly budgeted caches:
QUEEN_DEDUP_CACHE_MB (default 512) and QUEEN_ACK_REGISTRY_MB (default 64).
Both report their occupancy in the sizes log block, and neither affects
correctness: the deduplication probe in SQL is authoritative, and an ack-registry
miss falls back to the SQL path.
What the published runs mean
These are the only performance figures in this documentation, and each one is inseparable from the conditions it was measured under. Do not quote a number without its row.
| Result | Conditions it was measured under |
|---|---|
| 24-hour soak: 51,820,403,100 messages, roughly 600k msg/s per side, error rate 0.00012%, zero restarts, broker RSS flat at about 6.3 GB | explicit acks (manualAck=true, ackAsync=true), a 32 vCPU / 62 GiB VM, commit 615efdc |
| 1M msg/s per side, sustained for 600 s | autoAck (manualAck=false) and deduplication off; 99.914 % delivered: about 500,000 messages were never accepted (pushErr=3032) |
| A 4-stage pipeline over 1000 partitions at 25k events/s for 600 s: 88,503,408 messages verified, 0 duplicates, 0 gaps, 0 order violations | dedupWindow=300s, echoed by the loader. This is the ordering result, not a throughput result |
| A 2-core cell holding more than 2400 msg/s through the proxy, 12 tenants for 1 hour, 0 cross-tenant deliveries | deduplication off (dedupWindowSeconds: 0) and 429 retry disabled (retry429Attempts: 1); the raw sampler also recorded 818 HTTP 502 and 4,392 connection-refused errors against the proxy, and the roughly 1 % gap between pushes and deliveries is redelivery volume |
What they legitimately imply:
- A single PostgreSQL instance on commodity hardware is not the bottleneck people assume it is, and the broker’s memory is stable over a day at high rate.
- Total order per partition survives at scale: the pipeline run verified 88.5 million messages with zero ordering violations at a thousand partitions.
- Throughput is a function of what you switch on. The highest number in the table is also the one with deduplication off and at-most-once delivery, and it dropped half a million messages. The lowest is a two-core shared cell with a rate limiter in front.
What they do not imply, and you should not extrapolate:
- Nothing here is a latency SLO. These are throughput and correctness runs. Percentile latency depends on your commit durability setting, your disk and your batch size.
- Nothing here was measured on your disk. Commit rate is a storage property first. A run on NVMe tells you very little about a network volume.
- The high-throughput rows are not the safe configuration. Deduplication off
removes exactly-once storage on retry;
autoAckremoves redelivery. If your deployment needs either guarantee, size against the explicit-ack row. - The 2-core cell figure includes a rate limiter and its errors. It is a multi-tenant isolation result that happens to carry a throughput number, and the proxy-side errors in its raw output are part of the result, not noise to discard.
Full artifacts, methodology and the raw output for each run are in Benchmarks.
A sizing procedure that works
-
Measure your own message. Payload size and compressibility decide storage, and zstd level 3 on your actual JSON is the only honest estimate.
-
Fix the batch size first. Push in batches and consume in batches; then read
queen_fusion_items_per_batchunder your real load. Everything else is a smaller lever. -
Choose partition count from the consumer parallelism you need, then price the background cost of that count against the three intervals above.
-
Load the deployment until
pool_waitinggoes non-zero, and note the rate. That is your commit ceiling on that hardware, and it moves with the disk, not with the broker. -
Size retention before you go live. An unbounded queue is a full disk on a schedule you did not choose, and dead-letter rows are never reclaimed at all. See Operating retention.