Partition keys (1A) + producer idempotency (1B): spec, brief, draft implementation#295
Partition keys (1A) + producer idempotency (1B): spec, brief, draft implementation#295NikolayS wants to merge 26 commits into
Conversation
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.
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.
…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 ungranted). - 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
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
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>
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
Headline
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)
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, quantifiedSlot 7's worker SIGSTOPped for 8 minutes mid-phase:
Two operational findings worth pinning in the docs:
Dead tuples & bloat
Caveats
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: |
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>
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.mdv0.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.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-argsend,subscribe_slot/unsubscribe_slot(persisted enforced N),receive_partitioned(SECURITY DEFINER co-ownership over the admin-onlyget_batch_cursorextra_where hook),ack_partitioned,slot_lock_key/claim_slot/release_slot,partition_slot_statusview.sql/pgque-api/send_idem.sql— atomic single-statement TTL claim, dedup returns the original event_id,maint_idem()GC self-registered viaqueue_extra_maint(zero maint.sql edits).build/transform.sh;sql/pgque.sql+pgque-tle.sqlrebuilt.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)
Repro commands:
Known follow-ups before leaving draft
pausepolicy (Phase 2) blocked on O1 — deliberately NOT implemented🤖 Generated with Claude Code
https://claude.ai/code/session_01HYTw9GXVXJvectNqZ2fqsq