Skip to content

Storage backends & sizing

rdq introduces no new stateful infrastructure — retry queues and DLQs live in a datastore you already run, reached through the Storage SPI. Each plugin owns its own atomic-claim mechanics behind one correctness bar:

No two workers may ever claim the same task.

Availability is inherited: rdq adds no stateful component of its own, so its HA story is exactly the backend’s — Postgres replicas, Redis Sentinel, Mongo replica sets. Stateless rdq workers coordinate only through the backend’s atomic claims and leases, so scaling is “add another instance” and node death is a non-event.

BackendStatusAtomic-claim mechanism
PostgreSQLv1 — shipped, production-hardened reference pluginSELECT … FOR UPDATE SKIP LOCKED
RedisFast-follow (post-v1)Atomic sorted-set pops (Lua)
MongoDBFast-follow (post-v1)findAndModify

The SPI ships as a public contract with a compliance test-kit so third parties can build and verify their own plugins — including a chaos test that kill -9s a worker mid-processing and asserts the task is reclaimed after lease expiry with zero double-claims. Redis and MongoDB are the first validation targets for that pluggability.

The Postgres plugin claims due tasks with a single FOR UPDATE SKIP LOCKED query. Workers that find all candidate rows locked skip them and return immediately — they never block waiting for a peer’s lock, so aggregate claim throughput scales roughly linearly with worker count until Postgres itself (CPU or connections) becomes the bottleneck, not the claim algorithm.

SELECT id FROM rdq_task
WHERE queue = $1
AND ( (status='PENDING' AND next_attempt_at <= now())
OR (status='IN_FLIGHT' AND lease_expires_at <= now()) )
ORDER BY next_attempt_at
LIMIT $2
FOR UPDATE SKIP LOCKED

Measured on GitHub Actions ubuntu-latest (4 vCPU / 16 GB, Postgres 16-alpine in testcontainers over a loopback bridge). Reproduce with go test -run=^$ -bench=. -benchtime=10s -count=3 ./storage/postgres/....

OperationGoroutinesMeasured (4 vCPU)
Enqueue (pure admit)4 (GOMAXPROCS)≈ 2 500 enqueues/sec
ClaimDue (serial, 1 worker)1≈ 600 claims/sec
ClaimDue (1×GOMAXPROCS)4≈ 1 800 claims/sec
ClaimDue (2×GOMAXPROCS)8≈ 2 800 claims/sec
ClaimDue (4×GOMAXPROCS)16≈ 3 400 claims/sec

These are sustained steady-state numbers with a resident pool of 256 due tasks per queue. One claim round-trip is roughly 3–5 SQL statements in a single transaction: the FOR UPDATE SKIP LOCKED scan, the lease flip, and (on resolution) the attempt record and state transition.

Scaling shape: 1 worker → 600/s; 4 → 1 800/s (3.0×, near-linear); 8 → 2 800/s (4.7×); 16 → 3 400/s (5.7×, Postgres CPU saturating). Add workers to raise throughput; the plateau starts when Postgres CPU hits 100% or connections are exhausted. A loopback Docker bridge adds ≈ 0.2–0.5 ms round-trip; production Postgres on the same host or a fast LAN typically shows higher numbers.

Every ClaimDue opens a short transaction (one connection). Rule of thumb:

db_connections_needed ≈ N_workers + N_producers + 5 (headroom/ops)
ScaleWorkersProducersRecommended max_connections
Small (dev / staging)4420
Medium16840
Large6416100
Very large128+32+Use a pooler (PgBouncer)

At N_workers ≥ ~80, put PgBouncer in transaction mode in front of Postgres — rdq’s claim transaction is milliseconds long, an ideal fit. For long-running handlers (with ExtendLease heartbeats), the DB connection is returned after ClaimDue returns and only re-acquired for the resolution call; N_workers does not equal connections held, so you can safely run more workers than connections.

  1. Many workers, few due tasks — SKIP LOCKED handles the race gracefully but most ClaimDue calls return empty; raise the resident pool (enqueue ahead) or cut worker count to match queue depth.
  2. Very short handlers (< 5 ms) — claim overhead dominates; increase the ClaimDue limit (batch size) so each claim hands out a small batch.
  3. Single queue, many workers — the scan touches the same index rows every time; at 16+ workers on one queue with a small pool you see diminishing returns. Distribute load across multiple queues.

10 000 tasks/hour (≈ 2.8/sec). A single goroutine (≈ 600 claims/sec) has huge headroom — start with 2 workers for redundancy and 1 producer (2 500 enqueues/sec capacity). max_connections = 2 + 1 + 5 = 8; the Postgres default of 100 is fine, no pooler.

1 000 000 tasks/hour (≈ 278/sec). 4 workers on one 4-vCPU node (1 800 claims/sec) carries this with 6× headroom; 4 producers cover 278 enqueues/sec. max_connections ≈ 4 + 4 + 5 = 13 (set 25 for margin). A 4-vCPU / 8 GB Postgres handles the load; size shared_buffers to hold the hot (queue, next_attempt_at) WHERE status IN ('PENDING','IN_FLIGHT') index — typically a few hundred MB for < 1M in-flight tasks, which stays small when turnover is fast.

See the full sizing guide and BENCHMARKS.md for hardware context and how to calibrate for your own environment. Benchmark output includes a claims/sec / enqueues/sec metric read straight off the -bench output.

Redis and MongoDB plugins are on the roadmap. Each will implement the same SPI operations and pass the compliance kit — the same double-claim and lease-recovery guarantees the Postgres plugin meets — so application and server code is unchanged when swapping backends. Their atomic-claim mechanics (Lua sorted-set pops for Redis, findAndModify for MongoDB) differ, but the correctness bar and lease semantics are identical.