Skip to content

Partition keys (1A) + producer idempotency (1B): spec, brief, draft implementation#295

Draft
NikolayS wants to merge 26 commits into
mainfrom
claude/pgque-idempotent-partitions-d7emjj
Draft

Partition keys (1A) + producer idempotency (1B): spec, brief, draft implementation#295
NikolayS wants to merge 26 commits into
mainfrom
claude/pgque-idempotent-partitions-d7emjj

Conversation

@NikolayS

@NikolayS NikolayS commented Jun 19, 2026

Copy link
Copy Markdown
Owner

Status: DRAFT — do not merge

Awaiting external review by Fabrizio (Supabase Storage) — this implements the two features he needs to move off pg-boss. Merge only after his review + the standard loop (CI → REV → functional test).

What's here

Design layer (converged through 3 internal review rounds + Max + Fabrizio + two stronger-model refinement passes):

  • blueprints/partition-keys/SPEC.md v0.7 — ordered per-key consumption via N slot subscriptions; G1/G2 corrected (session claim is load-bearing, not "pure liveness"); mechanism/policy seam; member/heartbeat table rejected (D10); tick-gated online-resize protocol as sanctioned future path (D9, draft); connection-pooler caveat (only the claim connection needs session mode).
  • blueprints/idempotency/SPEC.md — producer TTL dedup as a separate send-layer feature.
  • Brief deployed at https://pgque.dev/briefs/partition-keys.html with full version history.

User stories — US-12.1–12.7 (lifecycle events: keyed send, per-key order, cross-key parallelism, single processor, claim/crash-recovery, observability, enforced N) and US-13.1–13.5 (migrations: TTL dedup, effect-scoped keys, expiry, GC, mutual-exclusion recipe) in both SPECs, the brief, and executable acceptance tests.

Draft implementation (red/green TDD, engine untouched):

  • sql/pgque-api/partition_keys.sql — 4-arg send, subscribe_slot/unsubscribe_slot (persisted enforced N), receive_partitioned (SECURITY DEFINER co-ownership over the admin-only get_batch_cursor extra_where hook), ack_partitioned, slot_lock_key/claim_slot/release_slot, partition_slot_status view.
  • sql/pgque-api/send_idem.sql — atomic single-statement TTL claim, dedup returns the original event_id, maint_idem() GC self-registered via queue_extra_maint (zero maint.sql edits).
  • Wired into build/transform.sh; sql/pgque.sql + pgque-tle.sql rebuilt.

Reproduction & benchmarks (earlier commits): TS/bun repro of both workloads incl. the version-suppression hazard guardrail; bloat-under-backlog + throughput bench vs a pg-boss-style mutable job table.

Verification (fresh PostgreSQL 18.3)

1. install (\i sql/pgque.sql)                      OK
2. tests/run_all.sql                               ALL TESTS PASSED (173 PASS)
3. tests/acceptance/run_acceptance.sql (US-1..13)  ALL ACCEPTANCE TESTS PASSED
4. tests/two_session_slot_claim.sh                 PASS (claim exclusivity across
   real sessions; dead session's slot instantly reclaimable; owner_pid visible)

Repro commands:

psql -d pgque_test -f sql/pgque.sql
psql -d pgque_test -f tests/run_all.sql
psql -d pgque_test -f tests/acceptance/run_acceptance.sql
PGQUE_TEST_DSN=postgresql://...:5499/pgque_test tests/two_session_slot_claim.sh

Known follow-ups before leaving draft

  • T-security non-superuser-installer variant (co-ownership currently exercised as superuser only)
  • CI across PG 14–17 (locally verified on 18 only)
  • Online resize (D9) is spec-only, marked draft-pending-its-own-review — deliberately NOT implemented
  • pause policy (Phase 2) blocked on O1 — deliberately NOT implemented

🤖 Generated with Claude Code

https://claude.ai/code/session_01HYTw9GXVXJvectNqZ2fqsq

claude added 7 commits June 18, 2026 00:20
Design guidance for external PRs adding pg-boss-style idempotency
keys (refs #293) and per-partition serialization. Maps both features
onto PgQue's existing layering: sidecar tables (rotation-safe), send
wrappers that reduce to insert_event(), consumer-side gating instead
of engine changes, maint-cycle expiry to bound bloat.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WuaYcu1XXsVEpsnLhF1FFu
Records why pgque's producer idempotency is a TTL window (log model, not
job queue) and why free-once-processed belongs on the consumer side.
Includes prior-art survey (SQS/NATS/Rabbit vs pg-boss/Oban/River/
Graphile/Hatchet, pgmq gap) and the producer GC fork. Internal blueprint;
basis for the reply to #293. Not yet pushed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WuaYcu1XXsVEpsnLhF1FFu
SamoSpec-format spec (blueprints/partition-keys/SPEC.md) for consumer-side
ordered, parallel consumption by partition key (Kafka-partition model:
order within a key, parallelism across keys, no per-event state). Adds a
self-contained on-brand HTML brief at web/public/briefs/partition-keys.html
(served by Pages at /briefs/partition-keys.html on merge to main).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WuaYcu1XXsVEpsnLhF1FFu
Re-ground the consumer mechanism after ops/security + QA/testability
review: drop the (impossible) cooperative-consumer overlay for N
independent slot subscriptions filtering via get_batch_cursor
extra_where; restate the guarantee as testable G1/G2/G3; correct the
retry rationale (ev_id preserved, ev_txid changes); derive pause from
existing retry_queue (no new table); fix send-signature collision,
ev_extra1/trigger collision, unstable hashtext, fixed-N invariant,
slot/owner definition. Add decisions.md and refresh the HTML brief.
Remove superseded IDEMPOTENCY_AND_PARTITIONS.md contributor guide.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WuaYcu1XXsVEpsnLhF1FFu
Relocate IDEMPOTENCY_DESIGN.md -> blueprints/idempotency/DESIGN.md to
match the partition-keys/ slug layout; update the cross-reference in the
partition spec and refresh the note's footer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WuaYcu1XXsVEpsnLhF1FFu
Round 2 verified the model against the engine: G1 ev_id ordering is
real (order by 1, preserved through get_batch_cursor) and the G2
single-owner lock is the tested #97 guard. Fixes folded in:
- security: receive_partitioned/subscribe_slot are SECURITY DEFINER
  over the admin-only get_batch_cursor; validated integer-only filter
  (corrects the "injection-safe" framing).
- correctness: pause blocked-set moved off the transient retry_queue
  to a durable compact partition_block marker (per failing key, not
  per event); DLQ-unblock predicate made explicit.
- bug: modulo sign-normalized to (h%N+N)%N.
- R7 rotation wedge (+ pause must not hold the batch open); N
  persistence + teardown + DLQ-cascade caveat.
- tests: retry-affinity, security, N-invariant; split G2 block/parallel;
  get_batch_cursor in engine-untouched guard.
Update decisions.md (round-2 scorecard) and refresh the brief.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WuaYcu1XXsVEpsnLhF1FFu
Round 3 convergence: Phase 1 (skip-default partition consumption)
declared implementation-ready; pause split into Phase 2 with explicit
open items (O1 defer-without-retry-increment primitive; O2 hot-blocked-
key cost). Corrected the SECURITY DEFINER model to the co-ownership
invariant (not pgque_admin) + non-superuser-owner security test. Fixed
DLQ-unblock sub_id<->co_id join; partition_block FK-cascade/index/
revoked-from-roles, created empty in Phase 1; tightened tests
(engine-untouched /4 overload, in-order-after-unblock, marker-clear-via-
DLQ, marker durability, hot-blocked-key). Update decisions.md and brief.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WuaYcu1XXsVEpsnLhF1FFu
Resolve two follow-up design questions on the partition-keys brief:
fixed-N rebalancing and how partitions map to workers.

- Add SPEC §15 + brief §07: claim-based assignment via per-slot
  pg_try_advisory_lock — no leader, no PartitionAssignor, no rebalance
  protocol. The DB arbitrates; scale-up/down is lock acquire/release.
- Separate the two locks: G2 blocking receive lock = correctness backstop;
  advisory slot lock = distribution/liveness only.
- D8 decision row; T-claim assignment-liveness test.
- Correct the over-provisioning framing: fixed N is also the read-amp
  multiplier, so inflating N is not free; online resize breaks G1
  mid-flight. Expand R4.
- Log the Q&A in decisions.md; bump to v0.5 (draft).
Lead with Tier A (mutual exclusion via cooperative consumers + per-key
advisory lock, near-zero engine code) — it covers the migration workload
and folds in the idempotency ask. Reposition the hash-slot design as
Tier B (ordered per key), the inherent price of strict ordering, where
fixed N / read-amp / slot assignment live. Correct advisory-lock framing:
per-key in Tier A, per-slot distribution in Tier B.
claude and others added 4 commits June 25, 2026 16:20
Self-contained spike under blueprints/partition-keys/repro/ that installs
pgque on a fresh VM, drives both workloads concurrently, measures
throughput, and checks the guarantees empirically. No engine changes —
both tiers are thin recipes over existing primitives.

- Tier A (mutual exclusion): cooperative consumers + per-key advisory
  lock; verifies G2 (no overlapping runs per key) and idempotency
  collapse (1 run per tenant despite duplicates).
- Tier B (ordered): N hash-routed slot subscriptions via get_batch_cursor
  extra_where; verifies G1 affinity + FIFO + exactly-once, and measures
  read amplification.

Measured (this VM, PG16): Tier A 8k events -> exactly 2000 runs, all
invariants PASS at 4/8/16 workers. Tier B read amplification = N exactly
(4.00/8.00/16.00x) with aggregate throughput ~inverse to N (87k/54k/30k
ev/s) — strict ordering's cost, quantified.
Updated README.md to reflect changes in the partition-keys reproduction spike, including installation instructions, workload details, and caveats.
Port the reproduction driver from Python to TypeScript on bun + pg, to
match clients/typescript/ and Fabrizio's stack.

Align the repro to the two cases from the thread:
- Case 1 (migrations) now models BOTH guarantees that were conflated:
  L1 producer-side idempotency (TTL dedup window, demo.send_idem) that
  prevents duplicate INSERTs, and L2 consumer-side mutual exclusion
  (advisory lock) that prevents duplicate WORK. Concurrent producers +
  background ticker exercise the real race.
- Case 2 (lifecycle) unchanged: ordered slots, read-amp measured.

Correct the brief's overreach: idempotency is a complementary layer, not
the same requirement as partition keys.

Measured (PG16): dedup OFF -> 12000 attempts/12000 inserted/1000 runs;
dedup ON -> 12000 attempts/1000 inserted/1000 runs. Both: all invariants
PASS. Case 2 read-amp = N exactly.
…ardrail

Act on review (Max): partition-keys is the ordered-slot feature only;
producer TTL dedup becomes a separate send-layer feature.

- New blueprints/idempotency/SPEC.md: producer TTL dedup feature spec with
  the key-scope rule in hard language (key must encode the desired EFFECT,
  not just the entity), atomic claim+append, (queue, idem_key) scoping,
  rotation/maintenance GC, orthogonality to partition keys.
- Repro: add --tier hazard guardrail proving the version-suppression bug —
  entity-only key drops the v2 migration (0 inserted); effect-scoped key
  delivers both waves. Reframe Case 1 as 'producer idempotency + consumer
  mutual exclusion on a plain queue', not partition keys.
- partition-keys SPEC: promote rotation pinning to a first-class risk (R7);
  point §12 at the new feature spec; roadmap 1A/1B/2/3; v0.6.
- Brief: scope to ordered slots, separate-feature framing, key-scope footgun
  callout, rotation-pin note, roadmap pills; v0.7.

Guardrail measured (PG16): tenant key -> v2 0 inserted; tenant:version ->
v2 N inserted. PASS.
claude and others added 3 commits June 25, 2026 20:51
…s-style

bench.ts compares PgQue (append + rotation) against a pg-boss-style
mutable job table (insert->update->delete) in the same DB.

Measured (PG16): consume throughput pgque ~208k ev/s vs jobq ~40k ev/s
(~5x — the per-message UPDATE+DELETE churn is the tax). Under backlog,
pgque holds ZERO dead tuples and needs ZERO vacuum; the mutable table
accumulates ~2 dead tuples per processed job and, after draining a ~150k
backlog, sits at ~300k dead tuples / ~34 MiB (grew while draining) — the
pg-boss bloat, reproduced.

Adds demo.jobq baseline table. Honest gaps documented: produce is
round-trip-bound, and the full rotation reclaim-to-zero curve is a
follow-up (PgQ rotation is a multi-period state machine).
…resize

Applied after Fabrizio tested the repro, plus an advisor + 4-agent
workflow that converged independently.

- Correct the receive-lock claim: core DOES coordinate (for update of s
  returns the same active batch idempotently, two_session_receive_lock.sh);
  the real hazard is the process->ack gap, not a coordination vacuum (§12).
- State the mechanism/policy seam (D8): core SQL owns corruption-capable
  transitions; clients/users own guarded policy loops.
- Reject a member/heartbeat/lease table (D10) — redundant with session-death
  advisory-lock release + G2 — replaced by core slot_lock_key (D7) + a
  read-only partition_slot_status view.
- Add the connection-pooler caveat (session locks need session-mode/direct
  connections — the Supabase ICP).
- Upgrade R4 to a sanctioned epoch-gated drain-then-cutover online-resize
  protocol (D9, §15) with retry-flush + DLQ-preservation guards (Kinesis
  parent-shard-drain shape); immutable N in Phase 1.
- Promote "every slot must be polled" to a hard R7-adjacent requirement.
- Brief + decisions.md updated; changelog v0.7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Materialize every historical draft of the partition-keys brief (v0.1–v0.7)
as its own self-contained page under web/public/briefs/partition-keys/,
extracted from git history. Inject a version-switcher nav into each page
and the live brief, add an index landing page. All self-contained (inline
CSS, no external assets) so they render under the Pages CSP.

- /briefs/partition-keys.html      -> live (latest, v0.7) + switcher
- /briefs/partition-keys/          -> version index
- /briefs/partition-keys/vX.Y.html -> each archived draft

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Final review pass caught the self-contradiction v0.7 introduced and a
resize data-loss hole; applied:

- F1: the receive-lock correction (§12) makes the session claim
  LOAD-BEARING for G2, not "pure liveness" — fixed G2 (§2), the two-lock
  framing (§15), D7, the mechanism/policy prose, and the brief lock cards.
  Claim releases only at a batch boundary (releasing mid-batch hands the
  successor the same open batch).
- F2: reworked online resize from ev_id-gating to tick-window gating —
  fixes the abort_resize data-loss hole and the ev_seal type conflation;
  abort is now loss-free by construction; complete_resize takes the
  subscription row lock; DLQ re-home + retry-flush specified. Marked the
  protocol "draft — needs its own review round before Phase 3."
- F3/F4: D10/pooling sharpened — session lock leaks onto the pooled
  backend under transaction pooling (wedge, not miss); only the claim
  connection needs session mode; tcp_keepalives_* for silent partitions.
- F6: brief drift (lock cards, resize note, D8/D9 ID collision -> A1/A2).
- F7: epoch column gets a job — receive_partitioned returns it as a
  user-space fencing token.
- Regenerated the v0.7 archive snapshot to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fresh-eyes review after the v0.7 refinement:

- §8 grants list now includes slot_lock_key/claim_slot/release_slot and
  select on partition_slot_status (they were core per D7 but ungran­ted).
- Removed review-finding labels (F7/F2) that leaked into spec prose;
  replaced with real cross-references (D9, §8, §15).
- Brief pooling bullet updated to the corrected semantics: the session
  lock leaks onto the pooled backend (wedge, not miss), and only the
  claim-holding connection needs session/direct mode.
- v0.7 archive snapshot regenerated to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HYTw9GXVXJvectNqZ2fqsq
NikolayS and others added 2 commits July 5, 2026 19:07
N independent slot consumers over the append-only log (SPEC v0.7 §6/§8/§15):
send(queue, type, payload, partition_key) -> ev_extra1; subscribe_slot/
unsubscribe_slot with persisted enforced N; receive_partitioned via the
admin-only get_batch_cursor extra_where hash filter (SECURITY DEFINER
co-ownership, validated ints only); ack_partitioned; slot_lock_key/
claim_slot/release_slot shared advisory namespace; partition_slot_status
view (owner pid via pg_locks, cursor lag). skip policy only — no pause,
no resize.

Red/green TDD: tests/test_partition_keys.sql (US-12.1..12.7, dblink
second backend) failed against plain pgque.sql, green after the feature;
tests/two_session_slot_claim.sh proves claim exclusivity + crash
reclaim + owner visibility across two real sessions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HYTw9GXVXJvectNqZ2fqsq
pgque.send_idem(queue, type, payload, idem_key, ttl, partition_key)
returns (event_id, deduped): atomic single-statement claim on
pgque.idem (insert .. on conflict do update .. where expired), append
+ claim commit atomically, dedup returns the ORIGINAL event_id.
maint_idem() GC self-registers via the existing queue_extra_maint hook
— pgque.maint() reaps expired claims with no maint.sql edit. Exact-match
(queue, idem_key) scoping; key-must-encode-effect hazard documented.

Red/green TDD: tests/test_send_idem.sql (US-13.1..13.4 + atomicity +
dblink race: two concurrent send_idem same key -> exactly one insert).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HYTw9GXVXJvectNqZ2fqsq
- SPEC §17 (partition-keys) + idempotency SPEC user-story sections:
  US-12.1..12.7 (Fabrizio case 2: lifecycle events) and US-13.1..13.5
  (case 1: migration dedup), each with acceptance criterion + test
  pointer; brief gets a matching User Stories panel.
- tests/acceptance/us12_partition_keys.sql, us13_producer_idempotency.sql
  wired into run_acceptance.sql; regression tests wired into run_all.sql.
- build/transform.sh includes partition_keys.sql + send_idem.sql in the
  default API surface; sql/pgque.sql + pgque-tle.sql rebuilt.

Verified end-to-end on a fresh PG 18.3: install -> full regression
(173 PASS) -> full acceptance (US-1..US-13 ALL PASSED) -> two-session
manual test (claim exclusivity, crash reclaim, owner visibility).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HYTw9GXVXJvectNqZ2fqsq
@NikolayS NikolayS changed the title docs: partition-keys spec + brief, idempotency design rationale Partition keys (1A) + producer idempotency (1B): spec, brief, draft implementation Jul 6, 2026
NikolayS and others added 3 commits July 6, 2026 07:38
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Slot ownership moves from session-scoped advisory locks to a
batch-granularity lease (worker id + TTL + epoch) in
pgque.partition_slot: plain transactional DML, so it works on one
PgBouncer transaction-mode pool with no session state and no
connection-per-worker floor (Fabrizio review). receive/ack now
require and renew the lease (server-enforced G2); takeover of an
expired lease bumps the epoch and fences the zombie. slot_lock_key
is removed. Spec v0.8 (new D11); adds T-fencing and the previously
missing T-retry-affinity test.

Verified: tests/run_all.sql, tests/acceptance/run_acceptance.sql,
tests/two_session_slot_claim.sh all green on a fresh install (PG
local); install idempotent over the old draft via guarded drops.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review pass on v0.8 (adversarial SQL + consistency + brief), applied
in place without a version bump:

- plain receive/ack/nack reject partition slot consumers (#-names):
  without the guard a reader could drive the raw slot subscription
  lease-free and unfiltered, and a fenced zombie could double-ack
  via plain ack(batch_id)
- add nack_partitioned: lease-fenced slot retry/DLQ path (plain nack
  no longer serves slot batches); shared _nack_batch_event core
  preserves the #98 canonical re-query and #104 idempotent DLQ
- spec fixes: epoch is returned by claim_slot (not receive), the D9
  resize epoch and D11 lease epoch are distinct counters,
  partition_block is Phase-2-only, null-key routes to slot 0 (G1),
  grace-rule wording, G2 enforcement scoped honestly to the
  pgque-api surface
- brief updated to v0.8 (+archived copy, version switcher)
- new tests: raw-slot guards, claim validation, view epoch after
  takeover, lease renewal heartbeat

Verified: run_all, acceptance, two_session_slot_claim.sh green on a
fresh install of the rebuilt sql/pgque.sql.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The brief named session-scoped pg_try_advisory_lock in the Tier A
per-key recipe while the idempotency spec and US-13.5 test use
pg_try_advisory_xact_lock -- the xact-scoped variant is pooler-safe
and consistent with D11's rejection of session locks. Align the
brief (live + v0.8 archive) and spec section 12 wording.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@NikolayS

NikolayS commented Jul 6, 2026

Copy link
Copy Markdown
Owner Author

Partition-keys benchmark — v0.8 lease model at the target scale (SPEC R2/R7, S4)

Question this answers: does the N-slot + batch-lease design sustain a Supabase-Storage-like profile — >400M events/day, ordered per tenant — and what do read amplification (R2) and rotation pinning (R7) cost in practice?

Setup

  • Hardware: Hetzner CCX43 — 16 dedicated vCPU (AMD), 64 GiB RAM, local NVMe, Ubuntu 24.04.
  • PostgreSQL 18 (PGDG): shared_buffers=16GB, synchronous_commit=off, wal_level=minimal, max_wal_size=16GB, checkpoint_timeout=15min, wal_compression=on, jit=off, pg_stat_statements preloaded.
  • pgque: this PR's sql/pgque.sql at v0.8 (batch-lease slot ownership).
  • Workload: pgbench producer at rate-controlled 5,000 ev/s (= 432M events/day), ~200-byte JSON payloads, partition key tenant-<n> drawn random_zipfian(1, 2000, 1.1) — hot-head tenant skew. Consumers: one bun worker per slot driving the real API — claim_slot (30 s TTL) → sticky receive_partitioned (batch 500) → ack_partitioned — i.e. the documented lease loop, no shortcuts. Ticker every 250 ms, maint() every 60 s, queue rotation period 30 s.
  • Harness: benchmark/partition-keys/ in this PR (samplers: per-slot lag 5 s, sys 10 s, pg_stat_statements 10 s, per-table bloat 30 s).

Headline

Phase N Producer ev/s Consume ev/s Pending p99 (events) Buffers/event CPU peak Peak dead tup (meta) Event tbl GiB end
steady-16 (30 min) 16 5,000 4,999 1,320 4.39 26% 7,586 3.57
steady-32 (30 min) 32 5,001 5,000 1,318 7.02 31% 9,502 7.19
stalled-16 (15 min, slot 7 SIGSTOP 8 min) 16 4,997 4,996 1,665,413 2.97 30% 8,912 5.38

Steady state: the design holds the target with a wide margin. ~9.0M events produced and consumed per 30-minute phase; per-slot pending p50 0, p99 ~1,320 events (≈ 0.26 s of production); zero buffer reads (fully cache-resident window); CPU peak 26–31% on 16 cores. Doubling N from 16→32 at the same rate changed steady-state lag not at all.

Read amplification (R2)

N buffers/event 32/16 ratio
16 4.39
32 7.02 1.60× (ideal-linear would be 2×)

Every slot scans the full stream and filters server-side, so buffers touched per produced event grow with N — observed sublinear (1.60× for 2× slots), all from shared_buffers at this scale. Practical read: at 5k ev/s, going from 16 to 32 slots costs ~5 CPU points. R2 is real, measurable, and affordable at this profile; it argues for keeping N at the parallelism you need, as the SPEC says.

Rotation pinning (R7) — demonstrated, quantified

Slot 7's worker SIGSTOPped for 8 minutes mid-phase:

  • Its lag grew at 2,663 events/s (its zipfian share of the stream), peaking at 2.39M pending.
  • Rotation floor pinned: event tables stopped dropping; event-table bytes grew 3.62 → 5.38 GiB during the stall and resumed dropping after.
  • Other 15 slots: completely unaffected (p50 pending stayed 0).
  • Catch-up did not complete within the remaining 5 minutes after resume — a stalled slot's backlog drains at (slot consume rate − slot arrival rate), so recovery is not instant at this volume.

Two operational findings worth pinning in the docs:

  1. The freed slot was not taken over. The stopped worker's lease expired after its 30 s TTL, but with M=N sticky workers each bound to its own slot, nobody polled slot 7 — it sat ownerless for ~15 min. This is exactly SPEC §15's "every slot must be polled" requirement showing its teeth: client claim loops should cycle idle/free slots (or run M>N candidates), not only stick to their own.
  2. Stale subscriptions are not inert (found by accident): a 4-slot consumer left over from a smoke run silently pinned rotation for the whole queue while every live metric looked healthy. The per-slot lag alert on partition_slot_status is mandatory, and unsubscribe_slot on teardown is not optional. (The harness now force-drops the bench queue at setup for this reason.)

Dead tuples & bloat

  • Event tables: 0 dead tuples throughout all phases. Append-only + rotation-by-drop holds at 432M/day equivalent — the core pgque thesis.
  • partition_slot (the v0.8 lease, 2 UPDATEs/batch): peak 557–704 dead tuples, steady ~96 KiB across all phases. HOT keeps lease churn negligible — the "no per-interval heartbeat churn" claim (D10/D11) is confirmed empirically.
  • subscription / tick metadata churn peaked at ~9.5k / ~7.2k dead tuples and returned to ~0 — bounded rotation-metadata behavior.
  • Total DB footprint stayed = live event window (3.6–7.2 GiB, proportional to rotation retention), no growth beyond it except during the deliberate R7 stall.

Caveats

  • Handler is trivial (count + ack): this measures the queue's capacity, not handler capacity; real deployments add their own per-event work.
  • Producer and consumers ran on the DB host over localhost — no network latency in the loop.
  • 30-minute steady phases; no multi-day soak here (the 2-hour held-xmin soak methodology exists separately in benchmark/).
  • Catch-up-after-stall was measured but not to completion; drain-time modeling for a lagged slot is worth a follow-up note.

Bottom line for the >400M/day question: steady state sustains it at ~26–31% peak CPU on 16 dedicated cores with sub-second per-slot lag and zero event-table bloat, at both N=16 and N=32. The two things that need operational discipline are exactly the two the SPEC already names: keep every slot polled (R7 + §15) and alert on per-slot lag.

Raw data: /tmp/bench/pk/ CSVs (per-slot lag 5 s, pgss 10 s, bloat 30 s, sys 10 s) — archived before VM teardown; harness committed in benchmark/partition-keys/.

Keyed zipfian producer (pgbench), N slot workers on the real v0.8
lease API (bun), per-slot lag / pgss / bloat / sys samplers, three
phases (steady-16, steady-32, stalled-slot for R7), summarizer with
read-amp and dead-tuple sections. Results from R1 on Hetzner CCX43:
benchmark/partition-keys/results/r1-ccx43-summary.md and PR comment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants