|
| 1 | +# benchmark/tick-rate |
| 2 | + |
| 3 | +Producer→consumer end-to-end latency under different `pgque.set_tick_period_ms()` |
| 4 | +settings, plus a held-xmin variant that demonstrates how `pgque.tick` and |
| 5 | +`pgque.subscription` UPDATEs degrade under blocked vacuum. |
| 6 | + |
| 7 | +Backs PR #204 / issue #69. |
| 8 | + |
| 9 | +## What it measures |
| 10 | + |
| 11 | +**Latency** = `consumer_recv_ts - producer_send_ts`, both server-side |
| 12 | +`clock_timestamp()` (no client clock skew). The producer stamps the timestamp |
| 13 | +into the JSON payload at `pgque.send()`. The consumer reads the payload, takes |
| 14 | +its own `clock_timestamp()` at `pgque.receive()` time, and subtracts. |
| 15 | + |
| 16 | +This is **end-to-end delivery latency** (latency #3 in |
| 17 | +[docs/three-latencies.md](../../docs/three-latencies.md)) — the gap that the |
| 18 | +tick rate actually controls. |
| 19 | + |
| 20 | +## Run |
| 21 | + |
| 22 | +Requires: |
| 23 | + |
| 24 | +- Postgres 14+ with `pg_cron` available, `cron.use_background_workers = on` |
| 25 | + (TCP-auth-free), `cron.database_name` pointing at the bench database. |
| 26 | +- `python3` with `psycopg` (`pip install 'psycopg[binary]'`). |
| 27 | +- The schema installed via `\i sql/pgque.sql` and `pgque.start()` already |
| 28 | + invoked. |
| 29 | + |
| 30 | +```sh |
| 31 | +# Idle sweep (a few minutes total). |
| 32 | +python3 benchmark/tick-rate/bench.py \ |
| 33 | + --dsn "host=/var/run/postgresql dbname=pgque_bench user=postgres" \ |
| 34 | + --scenario idle \ |
| 35 | + --periods 1000,100,10,1 \ |
| 36 | + --duration-s 30 \ |
| 37 | + --rate-eps 100 |
| 38 | + |
| 39 | +# Held-xmin orchestration (1 min baseline + 5 min held-xmin run). |
| 40 | +bash benchmark/tick-rate/run_held_xmin.sh |
| 41 | +``` |
| 42 | + |
| 43 | +## Results — local sandbox |
| 44 | + |
| 45 | +Single-laptop, Postgres 16, default settings, `pg_cron` 1.6.2 with |
| 46 | +`use_background_workers = on`. |
| 47 | + |
| 48 | +### Idle sweep (30 s per cell, 100 ev/s producer) |
| 49 | + |
| 50 | +| `tick_period_ms` | rate_eps | sent | recvd | p50 | p95 | p99 | max | mean | |
| 51 | +|---:|---:|---:|---:|---:|---:|---:|---:|---:| |
| 52 | +| 1000 | 100 | 3000 | 3000 | 503.32 | 953.87 | 993.89 | 1004.16 | 503.32 | |
| 53 | +| 100 | 100 | 3000 | 3000 | 52.62 | 98.86 | 103.48 | 105.28 | 52.74 | |
| 54 | +| 10 | 100 | 3000 | 3000 | 8.05 | 263.53 | 863.50 | 1013.39 | 41.86 | |
| 55 | +| 1 | 100 | 3000 | 3000 | 3.26 | 161.67 | 460.47 | 547.91 | 22.07 | |
| 56 | + |
| 57 | +Reads: |
| 58 | + |
| 59 | +- `tick_period_ms = 100` (the default) — clean: median ~52 ms, max ~105 ms, |
| 60 | + exactly tracks "wait for next tick, mean ≈ period/2". |
| 61 | +- `tick_period_ms = 10` and `tick_period_ms = 1` — median improves to single |
| 62 | + digits, but the **tail blows up** to ~1 s. Suspected cause: at sub-10 ms |
| 63 | + periods the procedure can't actually complete its inner iterations within |
| 64 | + one pg_cron 1-second slot, so the next slot can land on a still-running |
| 65 | + worker and effectively skip a tick window. Drives both the p99 and the max |
| 66 | + up to ~1 slot length. |
| 67 | +- The "0.1 ticks/sec" / `tick_period_ms = 10000` cell originally requested in |
| 68 | + the PR comment — **not measurable**. `ticker_loop` clamps the internal |
| 69 | + period at 1 s (the pg_cron slot length), so any value above 1000 collapses |
| 70 | + to "one tick per slot" = `tick_period_ms = 1000`. The setter now rejects |
| 71 | + values > 1000. |
| 72 | + |
| 73 | +### Held-xmin (default `tick_period_ms = 100`, 1000 ev/s) |
| 74 | + |
| 75 | +A separate session runs `BEGIN ISOLATION LEVEL REPEATABLE READ; ... |
| 76 | +pg_sleep(...);` for the duration of the bench, holding the cluster xmin floor |
| 77 | +and preventing autovacuum from reclaiming dead tuples on `pgque.tick` and |
| 78 | +`pgque.subscription`. |
| 79 | + |
| 80 | +| condition | duration | sent | recvd | p50 | p95 | p99 | max | mean | |
| 81 | +|---|---|---|---|---|---|---|---|---| |
| 82 | +| baseline (no held-xmin) | 60 s | 60 000 | 60 000 | 52.60 | 98.96 | 103.40 | 143.76 | 52.75 | |
| 83 | +| held-xmin (RR tx open) | 300 s | 300 000 | 300 000 | 53.83 | 100.18 | 104.73 | 235.56 | 54.03 | |
| 84 | + |
| 85 | +Reads: |
| 86 | + |
| 87 | +- p50 / p95 / p99 are essentially unchanged. Median e2e stays at ~53 ms; p99 |
| 88 | + at ~104 ms. |
| 89 | +- Worst-case (max) roughly **doubles**: 144 ms → 236 ms. |
| 90 | +- This is much milder than the upstream-PgQ baseline reported in the R7 90-min |
| 91 | + bench (peak dead tuples > 21k upstream vs. ≤ 1000 with PR #62's metadata |
| 92 | + rotation). Two reasons the picture looks calm here: |
| 93 | + - Test duration is 5 min, not 30–90 min. Metadata bloat scales with held |
| 94 | + duration; longer runs would visibly degrade the tail further. |
| 95 | + - Tick rate is 10/sec, not 100/sec or 1000/sec. The metadata-table churn at |
| 96 | + higher rates would also amplify the effect. |
| 97 | + |
| 98 | +The takeaway is **not** "held-xmin is fine, ignore #62". It's that under a |
| 99 | +moderate (5-min, 10-tick/sec) blocked-vacuum window, the tail degrades but the |
| 100 | +median is robust — consistent with PgQue's design goal of stable latency |
| 101 | +under load. The ceiling on this stability is what PR #62's rotation fix |
| 102 | +extends. |
| 103 | + |
| 104 | +## Methodology notes |
| 105 | + |
| 106 | +- The producer sends one event per `pgque.send()` call (no batching). Single |
| 107 | + Python thread sustains 1000 ev/s on a local socket. |
| 108 | +- The consumer polls `pgque.receive(queue, consumer, 1000)` in a tight loop |
| 109 | + with a 1 ms sleep between empty receives. No LISTEN/NOTIFY wakeup — the |
| 110 | + point is to measure the tick-driven path. |
| 111 | +- The drain budget at run end is `max(2 s, 3 × tick_period_ms)` so slow ticks |
| 112 | + don't truncate trailing events. |
| 113 | +- For the idle sweep, queue config is `ticker_max_count = 1`, |
| 114 | + `ticker_max_lag = 0 s`, `ticker_idle_period = 0 s` so every tick fires |
| 115 | + regardless of event volume — isolating the effect of `tick_period_ms`. |
0 commit comments