PgQue · Design Brief

Partition KeysOrdered, parallel consumption — the log-native way

slug partition-keys version v0.7 (draft) Tier A build-now engine untouched

Two tiers of per-key consumption, matched to two real needs. Mutual exclusion (Tier A) — one worker per key at a time — reuses what PgQue already ships: cooperative consumers plus a per-key advisory lock, near-zero new engine code. Ordered per key (Tier B) — Kafka's partition model, order within a key — adds hash-routed slots and pays for strict order with read amplification and a fixed partition count. Pick the tier the workload needs.

01The problem

PgQue is an ordered, immutable log, not a job queue. One in-order consumer can't keep up with millions of events; naive multi-worker consumption breaks per-key order. The motivating workload (a multi-tenant storage service evaluating PgQue to replace pg-boss) has two needs, not one — and they want different guarantees:

Migrations · mutual exclusion

  • Run one job per tenant at a time (schema migrations)
  • Order doesn't matter — idempotent "ensure latest" job
  • Concurrent requests across instances must collapse to one run

Lifecycle events · ordered

  • FileCreated, FileDeleted, FileOverwritten — millions
  • Ordered per tenant; order across tenants irrelevant
  • Throughput-critical

02Two tiers — really two separate features

Both needs are "per-key", but the guarantee differs — so the cost differs, and so do the features. Tier B is the partition-keys feature (ordered slots). Tier A is a plain-queue recipe (mutual exclusion + producer idempotency) that uses no slots — it's the migration case, kept deliberately separate so partition keys don't drift toward a mutable job queue. "Tier A/B" below is just shorthand. Don't pay for ordering you don't need.

TierGuaranteeBuilt onCost
AMutual exclusion — one worker per key (G2)cooperative consumers (exist) + per-key pg_try_advisory_lock≈ none · no new tables · no N
BOrdered per key — FIFO within a key (G1 + G2)N hash-routed slot subscriptions via extra_whereN× read amplification · fixed N · slot assignment

Cooperative consumers cannot provide Tier B: they split by tick window, not key, so a key scatters across workers (round-1 finding A1, confirmed in next_coop_batch). Tier A leans on exactly that throughput-spreading and adds the per-key lock for serialization.

User stories · the acceptance contract

Both features share one user-story layer — the language the specs, this brief, and the acceptance suites all speak. US-12 is partition keys (ordered slots); US-13 is producer idempotency (TTL dedup on a plain queue). Each story maps to an executable check.

US-12 · partition keys

  • 12.1 Keyed send(…, partition_key)ev_extra1
  • 12.2 Per-key order — one key, one slot, ev_id order
  • 12.3 Cross-key parallelism — N slots, disjoint, complete
  • 12.4 One processor per slot — same batch, never divergent
  • 12.5 Claim / release + instant crash recovery
  • 12.6 partition_slot_status — owner pid + lag (R7)
  • 12.7 Enforced N — wrong N rejected, never misrouted

US-13 · producer idempotency

  • 13.1 TTL dedup — one insert, original id, deduped=true
  • 13.2 Effect-scoped keys — :v2 not hidden by :v1
  • 13.3 Window expiry — same key inserts again after TTL
  • 13.4 GC — maint() reaps expired dedup rows
  • 13.5 Mutual-exclusion recipe — advisory lock + idempotent handler

Executable: tests/acceptance/us12_partition_keys.sql and tests/acceptance/us13_producer_idempotency.sql; the two-session facets of 12.4 / 12.5 live in tests/two_session_slot_claim.sh.

03Tier A · mutual exclusion

A recipe over existing primitives on a plain queue, not new engine code — and a separate feature from partition keys. The migration use case lives here: producer idempotency + consumer mutual exclusion. It needs no slots, no fixed N, no ordering — the "key" is a lock key, not a partition. Producer dedup is spec'd on its own in blueprints/idempotency/SPEC.md (ships as Phase 1B); keeping migrations out of the partition-keys feature stops it drifting toward a mutable job queue.

Mechanism

  • send(queue, type, payload, partition_key) → key into ev_extra1
  • Existing cooperative consumers spread batches across workers
  • Per event: pg_try_advisory_lock(hashtextextended(key,0))
  • Lock free → process, then unlock · lock held → ack and drop

Why ack-and-drop is safe

  • Handlers are idempotent (given)
  • Lock held ⇒ that key is already being processed
  • Contended event is redundant — no retry churn, no ordering concern
  • No N · no slot assignment · no rebalancing — workers stateless & elastic

Idempotency: a complementary layer, not the same thing

The idempotency ask is a second layer, not a restatement of partition keys. Consumer-side mutual exclusion (above) prevents duplicate work: residual duplicates ack-drop, the idempotent handler runs once. Producer-side dedup — a TTL window (the SQS/NATS model) — additionally prevents duplicate inserts, keeping the log small when many instances race to enqueue the same key. Use the consumer layer for correctness; add the producer TTL window when insert volume matters. Measured side by side in blueprints/partition-keys/repro/.

Key-scope footgun. The TTL key must encode the desired effect, not just the entity. Key on migrate:tenant, ship migration v1 then v2 inside the window, and v2 is silently suppressed as a "duplicate" — a correctness bug, not a caveat. Use migrate:tenant:version. Reproduced both ways in the repro (--tier hazard).

04Tier B · ordered per key

When order is the point (lifecycle events). Within one queue, events sharing a partition key are consumed in order by a single worker at a time; different keys run in parallel — with no per-event locks and no new mutable state. This is where fixed N, read amplification, and slot assignment live — the inherent price of strict ordering. G2 is also Tier A's guarantee; Tier B adds G1.

G1

Per-key affinity + FIFO. Every event of key K maps to one slot hashtextextended(K,0) % N; within it, non-retried events of K arrive in ev_id order, never to another slot.

G2

Single processor per key. At most one worker holds an unacked event for K at any instant.

G3

Failure boundary. pause: no later K event until the failed one is acked or dead-lettered. skip (v0.1 default): at-least-once, order not held after a failure.

05Tier B · architecture

producers send(queue, type, payload, partition_key => K) → ev_extra1 engine · sacred — UNCHANGED append-only tables · global ev_id / ev_txid order next_batch · get_batch_cursor(extra_where) · rotation — no edits to batch_event_sql full stream full stream full stream slot 0 · sub#0/N own cursor extra_where: h%N==0 keys in ev_id order one worker slot 1 · sub#1/N own cursor extra_where: h%N==1 keys in ev_id order one worker slot N-1 · sub#k/N own cursor extra_where: h%N==N-1 keys in ev_id order one worker each slot = independent subscription · own cursor → no data loss one key → one slot → per-key order · distinct keys → parallel cost: N× read amplification (each slot scans the full stream, server-side hash-filtered)
Slots are independent subscriptions filtering via the existing extra_where hook. The PgQ engine is not modified.

06Scope & phasing

Tier A · build now

  • send() partition key → ev_extra1
  • Per-key advisory-lock consume recipe over cooperative consumers
  • Mutual exclusion (G2); ack-drop on contention; idempotency collapse
  • No new tables, no N — stateless workers

Tier B · ordered, follow-up

  • N hash-routed slot consumers; per-key order (G1 + G2)
  • skip policy; engine untouched; persisted N
  • Slot claim (advisory / static); read-amp benchmark
  • pause strict order — open mechanic, gated on O1/O2

07Key decisions

IDTierDecisionChoice (v0.7)
D1A·BWhere the key livesev_extra1, send()-sourced queues only
D6A·BProducer signaturesend(queue, type, payload, partition_key =>)
A1ATier A serializationcooperative consumers + per-key pg_try_advisory_lock; contended + idempotent → ack-drop. No N, no assignment
A2AIdempotency asktwo complementary layers — consumer mutual-exclusion (dup work) + optional producer TTL window (dup insert); not a substitute for partition keys
D8–D10BAssignment, resize, no member tableclaim not assign; core slot_lock_key + partition_slot_status view; epoch-gated online resize; no heartbeat/lease table (see §09 + SPEC)
D3BSlot count NFixed, persisted & enforced per (queue, consumer)
D4BAssignment function(hashtextextended(key,0) % N + N) % N (stable, sign-safe)
D7BSlot & single-ownerslot = consumer "<c>#k/N"; receive lock; claim via advisory/static
D2BFailure policyskip default; pause = follow-up
D5BState budgethappy & skip: none. pause: compact blocked-key marker

Three review rounds against the real engine (G1 ordering + G2 single-owner lock verified real), then reframed into two tiers after consumer Q&A: the mutual-exclusion need (migrations) is met by Tier A with almost no engine change, so it leads. pause strict failure ordering stays a Tier-B follow-up — withholding a blocked key's later event needs a defer-without-retry-increment primitive that doesn't exist yet.

08Roadmap

1A  partition_key + fixed-N slots + skip 1B  producer TTL dedup (separate spec) 2  read-amp optimization — if bench demands 3  pause — needs defer-without-retry primitive

1A is the partition-keys feature: send(partition_key), fixed-N hash-routed slots, skip policy. 1B is producer TTL dedup — a separate send-layer feature (blueprints/idempotency/SPEC.md), independent of 1A; together with consumer mutual exclusion it covers the migration case on a plain queue. 2 optimizes read amplification only if benchmarks demand it. 3 is pause strict ordering, gated on a defer-without-retry-increment primitive (O1) and bounding hot-blocked-key cost (O2).

09Tier B · worker → slot assignment

An "assignment" question exists only in Tier B (Tier A has no slots — its advisory lock is per key, not per slot). Tier B slots are claimed, not assigned: no consumer-group leader, no PartitionAssignor, no stop-the-world rebalance. Here the database is the coordinator — a worker doesn't get told which slot to take, it claims a free one (or takes a static worker i → slots {i, i+M…} share if workers have stable identity).

Claim loop · client-side

  • Worker tries non-blocking pg_try_advisory_lock per slot 0..N-1
  • First lock wins → owns that slot, calls receive_partitioned
  • Sticky-until-idle; releases on drain or crash
  • 16 slots / 16 workers → one disjoint slot each · 16 / 8 → ≈2 each

Scale 8 → 10 workers

  • New workers run the same loop, grab released/free slots
  • No revocation, no leader recompute
  • Converges to ≈N/M within one claim cycle
  • Crash → Postgres frees the lock → slot reclaimable instantly
lock 1

Log & cursor — G2 receive lock (blocking for update): one active batch per slot; a second worker blocks, then is handed the same batch idempotently — never a divergent batch, never loss or delivery reorder.

lock 2

Processing exclusivity — session claim (non-blocking try, held across process→ack): closes the process→ack gap the receive lock doesn't span (single in-flight processor) and steers workers onto distinct slots. Skipping it risks duplicate/interleaved processing of one batch — not pure liveness.

N is fixed (D3) and is the read-amplification multiplier (§05 — every slot scans the full stream), so N is chosen to match the parallelism you need, not inflated to dodge a future resize. Even spread under key skew (a hot slot stays hot) and online resize of N (which reshuffles keys across slots, breaking per-key order mid-flight) — the latter now has a sanctioned future path (the epoch-gated begin_resize→drain→complete_resize state machine in core, Phase 3), immutable N in Phase 1. R3, R4, D9.

Rotation pinning (R7) — the sharpest cost of N. Rotation waits for the slowest slot's cursor, so one stalled or lagging slot pins rotation for the whole queue and old event tables stop dropping — the bloat pgque exists to avoid, back with nicer names. It worsens as N grows. A per-slot lag alert is mandatory for any slot deployment, and N stays minimal — read-amp and rotation pressure both push the same way: keep N small.

Coordination: mechanism vs policy

  • Core SQL: receive lock (G2), hash filter, enforced N, a shared slot_lock_key namespace, a read-only partition_slot_status view, and the resize guard
  • Client/user: the claim loop, poll cadence, when to resize — a buggy loop loses spread, never order
  • No member/heartbeat/lease table — redundant: session-death frees the lock (no TTL to tune), G2 is exclusivity; a lease only re-adds churn

Two caveats worth stating

  • Pooling: under transaction-mode pooling (Supavisor/PgBouncer) a session advisory lock leaks onto the pooled backend — the slot looks permanently owned (a wedge, not a miss). Only the claim-holding connection needs session/direct mode; receive/ack traffic can stay pooled
  • Grow N breaks per-key order for in-flight keys → drain the old epoch first. Sanctioned path: an epoch-gated begin_resize → drain → complete_resize state machine in core (Phase 3); immutable N in Phase 1
  • Every slot must be polled — an unclaimed slot stalls → pins rotation (R7). M ≥ N or cycle idle slots

Reframe for the Kafka mind: pgque routing is consume-time (the log carries only the key; hash%N is applied at receive), so a resize moves no data and never blocks producers — no "events stamped under the old N" problem.