diff --git a/.gitignore b/.gitignore index 82c0ba33..8e52b11c 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,9 @@ clients/ruby/pkg/ clients/ruby/.bundle/ clients/ruby/*.gem +# Benchmark harness deps (bun.lock is committed; deps are not) +benchmark/partition-keys/node_modules/ + # Claude Code agent worktrees (ephemeral isolation per agent run) .claude/worktrees/ diff --git a/benchmark/partition-keys/README.md b/benchmark/partition-keys/README.md new file mode 100644 index 00000000..73d25ef2 --- /dev/null +++ b/benchmark/partition-keys/README.md @@ -0,0 +1,103 @@ +# Partition-keys read-amplification benchmark + +Exercises the pgque v0.8 **partition keys** feature (`sql/pgque-api/partition_keys.sql`) +at a high-volume multi-tenant scale — the read-amplification scenario in +`blueprints/partition-keys/SPEC.md` §14 (S4). It uses **only the real installed +pgque API** (`send` keyed / `subscribe_slot` / `claim_slot` / +`receive_partitioned` / `ack_partitioned` / `release_slot` and the +`pgque.partition_slot_status` view) — no demo schema. + +## What it measures + +The slot mechanism is N independent slot consumers, each scanning the full +event stream and filtering server-side to its hash class. That gives two +properties this bench quantifies: + +- **R2 — read amplification ~N×.** Each produced event is scanned by all N + slots, so buffers touched per produced event scale ~linearly with N. Measured + from `pg_stat_statements` (under `track = top`, the event-table scan buffers + roll up into the top-level `receive_partitioned` call), at **N=16 vs N=32**. +- **R7 — a stalled slot pins rotation for the whole queue.** One slot's worker + is SIGSTOPped mid-run; its subscription cursor freezes, so the engine cannot + drop old `event_N_M` tables. The bench tracks per-slot lag growth, the + rotation floor (queue table count), and the catch-up slope after resume. + +## Target profile (Fabrizio: >400M events/day) + +- **Producer:** 5,000 ev/s sustained keyed `send`, Zipfian tenant skew (s=1.1) + over 2,000 tenants, ~200-byte JSON payload — see `producer.sql`. Rate-limited + pgbench (`-R 5000 -c 16 -j 8`). +- **Consumers:** N slot workers (one per slot) on the lease loop — `slot_worker.ts` + (bun + node-postgres). Each worker claims its slot, sticky-drains it in + batches of 500, acks, releases at drain, re-polls after 200 ms idle. One ack + log line per batch: `ts,worker,slot,events,max_ev_id`. +- **Ticker:** `pk_ticker.py` — `pgque.ticker()` every 250 ms, `pgque.maint()` + every 60 s. + +## Phases + +`run_bench.sh` runs, all output under `/tmp/bench/pk//`: + +1. **steady-16** (30 min): `bench_q`, consumer `w16`, 16 slots, 5k ev/s. +2. **steady-32** (30 min): fresh `bench_q32`, consumer `w32`, 32 slots, 5k ev/s. +3. **stalled-16** (15 min): reuse `bench_q`; slot 7's worker is SIGSTOPped at + minute 2 and resumed at minute 10. + +Each phase runs, at 5 s / 10 s / 30 s cadences: `slot_status_sampler.sh` +(per-slot lease + lag from `partition_slot_status`, and queue-level throughput ++ table count from `get_queue_info`), `sys_metrics_sampler.py`, +`pg_stat_statements_snapshot.py`, and `bloat_sampler.py`. `pg_stat_statements` +is reset at each phase start and snapshotted at the boundary for the read-amp +measurement. + +Then `summarize.py` parses the CSVs into `summary.md` — producer/consume +throughput, per-slot pending percentiles, CPU/mem, the N-scaling read-amp +table, the stalled-slot timeline, and a headline table to paste into a PR. + +## Running + +On a fresh Hetzner CCX43 (16 dedicated cores, 64 GiB, local NVMe, Ubuntu 24.04), +Postgres 18 from PGDG: + +```bash +# from the operator machine: ship the repo, then bootstrap +rsync -a --exclude .git ./ root@VM:/root/pgque/ +ssh root@VM 'bash /root/pgque/benchmark/partition-keys/setup_vm.sh' + +# on the VM: install driver deps and run the full ~90 min of measured phases +ssh root@VM 'cd /root/pgque/benchmark/partition-keys && bun install && \ + PGUSER=postgres DEVICE=sda bash run_bench.sh' +``` + +`DEVICE` is the block device name in `/proc/diskstats` (Hetzner cloud volumes +usually show as `sda`; adjust if the NVMe is named differently). + +### Knobs (env) + +`RATE` (5000), `N_SLOTS` (16; steady-32 uses 2×), `DURATION_MIN` (30), +`STALL_MIN` (15), `STALL_ON_MIN`/`STALL_OFF_MIN`/`STALL_SLOT`, `ROTATION` +(`30 seconds`), `BATCH` (500), `TTL_S` (30), `PGB_C`/`PGB_J`, `PHASES` +(`1,2,3`), `OUT` (`/tmp/bench/pk`), and libpq `PGHOST`/`PGUSER`/`PGDATABASE`. + +### Smoke run + +A 60-second micro-run of phase 1 at 200 ev/s with 4 slots — just env vars: + +```bash +PGHOST=/tmp PGUSER="$(id -un)" PGDATABASE=bench \ + RATE=200 N_SLOTS=4 DURATION_MIN=1 PHASES=1 DEVICE=none \ + bash run_bench.sh +``` + +## Files + +| File | Role | +|---|---| +| `setup_vm.sh` | VM bootstrap: PG18 + tuning + bun + pgque install | +| `producer.sql` | pgbench keyed-send script (`@QUEUE@` rendered at runtime) | +| `slot_worker.ts` | bun slot-worker lease-loop driver | +| `package.json` | driver deps (`pg`) | +| `pk_ticker.py` | ticker + maint loop | +| `slot_status_sampler.sh` | per-slot lease/lag + queue-rate sampler | +| `run_bench.sh` | phase orchestrator | +| `summarize.py` | CSV → markdown report | diff --git a/benchmark/partition-keys/bun.lock b/benchmark/partition-keys/bun.lock new file mode 100644 index 00000000..bd2700f0 --- /dev/null +++ b/benchmark/partition-keys/bun.lock @@ -0,0 +1,50 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "pgque-partition-keys-bench", + "dependencies": { + "pg": "^8.16.0", + }, + "devDependencies": { + "@types/pg": "^8.15.2", + }, + }, + }, + "packages": { + "@types/node": ["@types/node@26.1.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw=="], + + "@types/pg": ["@types/pg@8.20.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow=="], + + "pg": ["pg@8.22.0", "", { "dependencies": { "pg-connection-string": "^2.14.0", "pg-pool": "^3.14.0", "pg-protocol": "^1.15.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.4.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA=="], + + "pg-cloudflare": ["pg-cloudflare@1.4.0", "", {}, "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A=="], + + "pg-connection-string": ["pg-connection-string@2.14.0", "", {}, "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg=="], + + "pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="], + + "pg-pool": ["pg-pool@3.14.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw=="], + + "pg-protocol": ["pg-protocol@1.15.0", "", {}, "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ=="], + + "pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], + + "pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="], + + "postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], + + "postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="], + + "postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="], + + "postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], + + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], + } +} diff --git a/benchmark/partition-keys/package.json b/benchmark/partition-keys/package.json new file mode 100644 index 00000000..b0ea7a93 --- /dev/null +++ b/benchmark/partition-keys/package.json @@ -0,0 +1,15 @@ +{ + "name": "pgque-partition-keys-bench", + "private": true, + "type": "module", + "description": "Slot-worker driver for the partition-keys read-amplification benchmark (bun + node-postgres)", + "scripts": { + "worker": "bun slot_worker.ts" + }, + "dependencies": { + "pg": "^8.16.0" + }, + "devDependencies": { + "@types/pg": "^8.15.2" + } +} diff --git a/benchmark/partition-keys/pk_ticker.py b/benchmark/partition-keys/pk_ticker.py new file mode 100644 index 00000000..5636a090 --- /dev/null +++ b/benchmark/partition-keys/pk_ticker.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""pk_ticker.py -- tight ticker + maintenance loop for the partition-keys bench. + +Calls pgque.ticker() every TICK_MS (default 250 ms) so slot cursors advance +promptly, and pgque.maint() every MAINT_S (default 60 s) so event tables rotate +and vacuum on the pgque cadence. Persistent autocommit connection, mirroring +tooling/pgq_ticker_daemon.py. + +Env: PGHOST/PGDATABASE/PGUSER (libpq), TICK_MS, MAINT_S, RUN_S (0 = forever). +""" +import os +import signal +import sys +import time + +import psycopg2 + +DSN = ( + f"host={os.environ.get('PGHOST', '127.0.0.1')} " + f"dbname={os.environ.get('PGDATABASE', 'bench')} " + f"user={os.environ.get('PGUSER', 'postgres')} " + "application_name=pk_ticker" +) +TICK_S = float(os.environ.get("TICK_MS", "250")) / 1000.0 +MAINT_S = float(os.environ.get("MAINT_S", "60")) +RUN_S = float(os.environ.get("RUN_S", "0")) + +conn = psycopg2.connect(DSN) +conn.autocommit = True +cur = conn.cursor() + + +def shutdown(signum, frame): + try: + conn.close() + except Exception: + pass + sys.exit(0) + + +signal.signal(signal.SIGTERM, shutdown) +signal.signal(signal.SIGINT, shutdown) + +print(f"pk_ticker: tick={TICK_S}s maint={MAINT_S}s run={RUN_S or 'forever'}", flush=True) +t_start = time.monotonic() +last_maint = 0.0 +while True: + now = time.monotonic() + if RUN_S and now - t_start >= RUN_S: + break + try: + cur.execute("select pgque.ticker()") + if now - last_maint >= MAINT_S: + cur.execute("select pgque.maint()") + last_maint = now + except Exception as e: # noqa: BLE001 + print(f"pk_ticker err: {e}", file=sys.stderr, flush=True) + time.sleep(1) + time.sleep(TICK_S) + +conn.close() diff --git a/benchmark/partition-keys/producer.sql b/benchmark/partition-keys/producer.sql new file mode 100644 index 00000000..d78a85a4 --- /dev/null +++ b/benchmark/partition-keys/producer.sql @@ -0,0 +1,24 @@ +-- producer.sql -- keyed pgbench producer for the partition-keys read-amp bench. +-- +-- Zipfian tenant skew (s=1.1) over 2000 tenants models a high-volume multi-tenant +-- profile: a heavy head of hot buckets plus a long tail. Each send carries a +-- ~200-byte JSON payload and a partition key of 'tenant-'. The key rides +-- ev_extra1 (SPEC D1) so every slot's server-side hash filter routes it. +-- +-- @QUEUE@ is rendered to the live queue name by run_bench.sh (pgbench has no +-- string-literal variable substitution, so the runner seds this placeholder). +\set tenant random_zipfian(1, 2000, 1.1) +select pgque.send( + '@QUEUE@', + 'StorageObjectCreated', + json_build_object( + 'tenant', 'tenant-' || :tenant, + 'bucket', 'bkt-' || (:tenant % 32), + 'object', md5(random()::text) || '/' || md5(random()::text) || '.bin', + 'size_bytes', (random() * 10485760)::bigint, + 'content_type', 'application/octet-stream', + 'etag', md5(random()::text), + 'created_at', clock_timestamp() + )::text, + 'tenant-' || :tenant +); diff --git a/benchmark/partition-keys/results/r1-ccx43-summary.md b/benchmark/partition-keys/results/r1-ccx43-summary.md new file mode 100644 index 00000000..c18d4f96 --- /dev/null +++ b/benchmark/partition-keys/results/r1-ccx43-summary.md @@ -0,0 +1,108 @@ +# Partition-keys read-amplification benchmark + +High-volume multi-tenant profile: keyed producer + N slot workers on the real +pgque v0.8 lease API. Maps to SPEC R2 (read amplification ~N x) and R7 +(a stalled slot pins rotation for the whole queue). + +## Phase `steady-16` (N=16, target 5000 ev/s, 30 min) + +- Producer: **5,000 ev/s** achieved, 8,996,791 events sent +- Consumers: **4,999 ev/s** end-to-end, 8,998,023 events acked +- CPU: 25% avg / 26% peak | RAM used: 3.5 / 5.4 GiB +- Pending events across slots: p50 0, p99 1,320, max 1,332 +- Read-amp (receive_partitioned): 39,458,647 buffers (39,458,647 hit / 0 read) over 388,732 calls = **4.39 buffers/event** +- Per-slot consumed: { w0:288,446, w1:303,241, w2:267,539, w3:896,810, w4:499,753, w5:257,939, w6:530,210, w7:327,033, w8:787,695, w9:1,843,421, w10:845,659, w11:862,379, w12:283,429, w13:353,890, w14:298,668, w15:351,911 } + +**Bloat & dead tuples** + +- Metadata tables by peak dead tuples (end size): + - `pgque.subscription`: peak 7,586 dead (end 7,572 dead, 704.00 KiB) + - `pgque.tick`: peak 1,608 dead (end 0 dead, 888.00 KiB) + - `pgque.partition_slot`: peak 557 dead (end 363 dead, 96.00 KiB) + - `pgque.partition_consumer`: peak 35 dead (end 35 dead, 32.00 KiB) + - `pgque.consumer`: peak 20 dead (end 20 dead, 48.00 KiB) +- `pgque.partition_slot` (v0.8 lease, 2 UPDATEs/batch): peak **557** dead, end 363 dead, 96.00 KiB — HOT updates should keep this tiny +- `pgque.tick` peak dead tuples: 1,608 (rotation-metadata churn) +- `pgque.subscription` peak dead tuples: 7,586 (rotation-metadata churn) +- Event tables: 4 present at phase start -> 4 at end (rotation dropping old `event_N_M`) +- Event-table bytes: start 56.00 KiB, end 3.57 GiB, peak 3.57 GiB +- Event-table peak dead tuples: 0 (append-only — expect ~0; nonzero means retries) +- Total DB footprint: end 3.57 GiB, peak 3.57 GiB + +## Phase `steady-32` (N=32, target 5000 ev/s, 30 min) + +- Producer: **5,001 ev/s** achieved, 8,997,460 events sent +- Consumers: **5,000 ev/s** end-to-end, 9,000,840 events acked +- CPU: 30% avg / 31% peak | RAM used: 7.3 / 9.3 GiB +- Pending events across slots: p50 0, p99 1,318, max 1,337 +- Read-amp (receive_partitioned): 63,166,134 buffers (63,166,134 hit / 0 read) over 775,996 calls = **7.02 buffers/event** +- Per-slot consumed: { w0:145,553, w1:142,039, w2:185,695, w3:451,545, w4:95,106, w5:116,060, w6:196,430, w7:166,868, w8:331,859, w9:1,707,562, w10:118,269, w11:782,539, w12:121,190, w13:219,230, w14:122,795, w15:211,793, w16:143,608, w17:160,798, w18:82,634, w19:447,463, w20:403,448, w21:141,426, w22:335,823, w23:159,336, w24:453,227, w25:136,178, w26:728,446, w27:80,067, w28:161,406, w29:135,008, w30:176,196, w31:141,243 } + +**Bloat & dead tuples** + +- Metadata tables by peak dead tuples (end size): + - `pgque.subscription`: peak 9,502 dead (end 1,878 dead, 1.26 MiB) + - `pgque.partition_slot`: peak 704 dead (end 578 dead, 96.00 KiB) + - `pgque.partition_consumer`: peak 66 dead (end 0 dead, 64.00 KiB) + - `pgque.consumer`: peak 20 dead (end 20 dead, 48.00 KiB) + - `pgque.queue`: peak 8 dead (end 8 dead, 48.00 KiB) +- `pgque.partition_slot` (v0.8 lease, 2 UPDATEs/batch): peak **704** dead, end 578 dead, 96.00 KiB — HOT updates should keep this tiny +- `pgque.tick` peak dead tuples: 0 (rotation-metadata churn) +- `pgque.subscription` peak dead tuples: 9,502 (rotation-metadata churn) +- Event tables: 8 present at phase start -> 8 at end (rotation dropping old `event_N_M`) +- Event-table bytes: start 3.62 GiB, end 7.19 GiB, peak 7.19 GiB +- Event-table peak dead tuples: 0 (append-only — expect ~0; nonzero means retries) +- Total DB footprint: end 7.19 GiB, peak 7.19 GiB + +## Phase `stalled-16` (N=16, target 5000 ev/s, 15 min) + +- Producer: **4,997 ev/s** achieved, 4,495,477 events sent +- Consumers: **4,996 ev/s** end-to-end, 4,496,137 events acked +- CPU: 25% avg / 30% peak | RAM used: 8.9 / 9.0 GiB +- Pending events across slots: p50 0, p99 1,665,413, max 2,386,485 +- Read-amp (receive_partitioned): 13,361,585 buffers (13,361,585 hit / 0 read) over 189,979 calls = **2.97 buffers/event** +- Per-slot consumed: { w0:143,742, w1:150,971, w2:133,571, w3:449,575, w4:249,337, w5:128,238, w6:265,385, w7:163,156, w8:392,808, w9:921,401, w10:423,363, w11:431,755, w12:141,882, w13:176,922, w14:148,272, w15:175,759 } + +**Bloat & dead tuples** + +- Metadata tables by peak dead tuples (end size): + - `pgque.subscription`: peak 8,912 dead (end 1,672 dead, 1.05 MiB) + - `pgque.tick`: peak 7,205 dead (end 0 dead, 1.70 MiB) + - `pgque.partition_slot`: peak 640 dead (end 329 dead, 96.00 KiB) + - `pgque.consumer`: peak 36 dead (end 36 dead, 48.00 KiB) + - `pgque.partition_consumer`: peak 16 dead (end 16 dead, 64.00 KiB) +- `pgque.partition_slot` (v0.8 lease, 2 UPDATEs/batch): peak **640** dead, end 329 dead, 96.00 KiB — HOT updates should keep this tiny +- `pgque.tick` peak dead tuples: 7,205 (rotation-metadata churn) +- `pgque.subscription` peak dead tuples: 8,912 (rotation-metadata churn) +- Event tables: 8 present at phase start -> 8 at end (rotation dropping old `event_N_M`) +- Event-table bytes: start 3.62 GiB, end 5.38 GiB, peak 5.38 GiB +- Event-table peak dead tuples: 0 (append-only — expect ~0; nonzero means retries) +- Total DB footprint: end 5.38 GiB, peak 5.38 GiB +- R7 pin/release — event-table bytes: stall start 3.62 GiB -> stall end 5.38 GiB -> phase end 5.38 GiB (grows while the stalled cursor pins rotation, drops after resume) + +## Read amplification: N-scaling (SPEC R2) + +| N | buffers/event | read/event | ratio vs smallest N | +|--:|--:|--:|--:| +| 16 | 4.39 | 0.00 | 1.00x | +| 32 | 7.02 | 0.00 | 1.60x | + +Every slot scans the full stream and filters server-side, so buffers touched per produced event scale ~linearly with N. Observed 32/16 = 1.60x (ideal 2x). + +## Stalled-slot: rotation pinning (SPEC R7) + +Stalled slot (by peak lag): **slot 7**. +- Lease-expiry stall window: 896s (no live owner) +- Pending events: baseline 0 -> peak 2,386,485 +- Lag growth during stall: **2,663 events/s** +- Catch-up after resume: did not return to baseline within the phase +- Rotation floor: queue held 3..3 event tables (the stalled slot pins the drop floor) + +## Headline + +| Phase | N | Producer ev/s | Consume ev/s | Pending p99 | Buffers/event | CPU peak | Peak dead tup (meta) | Event tbl GiB end | +|---|--:|--:|--:|--:|--:|--:|--:|--:| +| steady-16 | 16 | 5,000 | 4,999 | 1,320 | 4.39 | 26% | 7,586 | 3.57 | +| steady-32 | 32 | 5,001 | 5,000 | 1,318 | 7.02 | 31% | 9,502 | 7.19 | +| stalled-16 | 16 | 4,997 | 4,996 | 1,665,413 | 2.97 | 30% | 8,912 | 5.38 | + diff --git a/benchmark/partition-keys/run_bench.sh b/benchmark/partition-keys/run_bench.sh new file mode 100644 index 00000000..04ac3d3d --- /dev/null +++ b/benchmark/partition-keys/run_bench.sh @@ -0,0 +1,229 @@ +#!/usr/bin/env bash +# run_bench.sh -- partition-keys read-amplification benchmark (SPEC section 14 +# S4) at a high-volume multi-tenant profile: sustained keyed producer + N slot +# workers driving the real pgque v0.8 lease API, measured at N=16 and N=32, +# plus a stalled-slot phase that exercises the R7 rotation-pinning failure mode. +# +# Assumes pgque is already installed into $PGDATABASE (setup_vm.sh does this). +# Everything lands under $OUT (default /tmp/bench/pk); a phases.csv manifest +# ties each phase directory to its metadata for summarize.py. +# +# Knobs (env): RATE, N_SLOTS, DURATION_MIN drive the whole run; the smoke run +# is just those three plus PHASES=1. See README.md. +set -Eeuo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TOOLING="${TOOLING:-$SCRIPT_DIR/../tooling}" + +# --- connection ------------------------------------------------------------ +export PGHOST="${PGHOST:-127.0.0.1}" +export PGUSER="${PGUSER:-postgres}" +export PGDATABASE="${PGDATABASE:-bench}" + +# --- profile --------------------------------------------------------------- +RATE="${RATE:-5000}" # producer sends/s +N_SLOTS="${N_SLOTS:-16}" # base slot count (steady-16); steady-32 = 2x +DURATION_MIN="${DURATION_MIN:-30}" # steady-phase minutes +STALL_MIN="${STALL_MIN:-15}" # stalled-slot phase minutes +STALL_ON_MIN="${STALL_ON_MIN:-2}" # minute the target slot is SIGSTOPped +STALL_OFF_MIN="${STALL_OFF_MIN:-10}" # minute the target slot is resumed +STALL_SLOT="${STALL_SLOT:-7}" # which slot to stall +ROTATION="${ROTATION:-30 seconds}" # queue rotation period (fast => visible floor) +BATCH="${BATCH:-500}" # receive_partitioned max +TTL_S="${TTL_S:-30}" # lease ttl +PGB_C="${PGB_C:-16}" # pgbench clients +PGB_J="${PGB_J:-8}" # pgbench threads +DEVICE="${DEVICE:-sda}" # block device for sys_metrics_sampler +PHASES="${PHASES:-1,2,3}" # which measured phases to run +OUT="${OUT:-/tmp/bench/pk}" + +BUN="${BUN:-bun}" +PYTHON="${PYTHON:-python3}" + +N1="$N_SLOTS" +N2=$(( N_SLOTS * 2 )) +DUR_S=$(( DURATION_MIN * 60 )) +STALL_DUR_S=$(( STALL_MIN * 60 )) + +mkdir -p "$OUT" +MANIFEST="$OUT/phases.csv" +echo "label,queue,consumer,n,rate,dur_s,start_iso,end_iso,dir" > "$MANIFEST" + +log() { echo "[$(date -u +%FT%TZ)] $*"; } + +psql_q() { psql -X -q -v ON_ERROR_STOP=1 -c "$1"; } + +# Kill background jobs of this run on exit; -CONT any stopped worker first. +CLEAN_PIDS=() +cleanup() { + for p in "${CLEAN_PIDS[@]:-}"; do + [[ -n "$p" ]] && kill -CONT "$p" 2>/dev/null || true + [[ -n "$p" ]] && kill "$p" 2>/dev/null || true + done +} +trap cleanup EXIT + +ensure_partitioned() { + # ensure_partitioned + local queue="$1" consumer="$2" n="$3" k + # Force-drop any pre-existing queue: a leftover subscription (e.g. from a + # smoke run) has a frozen cursor and pins rotation for the WHOLE queue + # (SPEC R7), silently distorting every measured phase. + psql -X -q -c "select pgque.drop_queue('$queue', true)" >/dev/null 2>&1 || true + psql -X -q -c "select pgque.create_queue('$queue')" >/dev/null 2>&1 || true + psql_q "update pgque.queue set queue_rotation_period = '$ROTATION'::interval where queue_name = '$queue'" + for (( k = 0; k < n; k++ )); do + psql_q "select pgque.subscribe_slot('$queue', '$consumer', $k, $n)" >/dev/null + done + log "queue '$queue' ready: consumer '$consumer', $n slots, rotation '$ROTATION'" +} + +rest_slots_spec() { + # rest_slots_spec -> prints slot spec excluding stall_slot + local s="$1" n="$2" + if (( s == 0 )); then + echo "1-$(( n - 1 ))" + elif (( s == n - 1 )); then + echo "0-$(( n - 2 ))" + else + echo "0-$(( s - 1 )),$(( s + 1 ))-$(( n - 1 ))" + fi +} + +run_phase() { + # run_phase