Skip to content

Commit 58185cc

Browse files
committed
bench: tick-rate latency results + README
Idle sweep (30 s per cell, 100 ev/s, single laptop, PG16): tick_period_ms=1000: p50 503, p99 994, max 1004 ms (1 tick/sec floor) tick_period_ms=100: p50 53, p99 103, max 105 ms (default; clean) tick_period_ms=10: p50 8, p99 864, max 1013 ms (tail blows up) tick_period_ms=1: p50 3, p99 460, max 548 ms (tail blows up) Held-xmin (tick=100ms, 1000 ev/s, 5 min RR tx open): baseline: p50 52.6, p99 103.4, max 143.8 ms held-xmin: p50 53.8, p99 104.7, max 235.6 ms Median essentially flat under held-xmin; worst-case roughly doubles. Milder than expected — consistent with the design goal of stable latency under load on moderate timescales. Longer runs / higher tick rates would amplify the bloat-driven tail (PR #62 territory). Refs #69, PR #204
1 parent 702578a commit 58185cc

6 files changed

Lines changed: 220 additions & 0 deletions

File tree

benchmark/tick-rate/README.md

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
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`.

benchmark/tick-rate/holder.log

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
BEGIN
2+
holder_pid
3+
------------
4+
22133
5+
(1 row)
6+
7+
pg_sleep
8+
----------
9+
10+
(1 row)
11+
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"scenario": "held-xmin",
3+
"rows": [
4+
{
5+
"tick_period_ms": 100,
6+
"rate_eps": 1000,
7+
"duration_s": 60.00015414899997,
8+
"sent": 60000,
9+
"received": 60000,
10+
"p50_ms": 52.60300636291504,
11+
"p95_ms": 98.95706176757812,
12+
"p99_ms": 103.40499877929688,
13+
"max_ms": 143.76401901245117,
14+
"mean_ms": 52.75166385571162
15+
}
16+
]
17+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"scenario": "held-xmin",
3+
"rows": [
4+
{
5+
"tick_period_ms": 100,
6+
"rate_eps": 1000,
7+
"duration_s": 300.00017162899996,
8+
"sent": 300000,
9+
"received": 300000,
10+
"p50_ms": 53.83014678955078,
11+
"p95_ms": 100.18181800842285,
12+
"p99_ms": 104.72607612609863,
13+
"max_ms": 235.55588722229004,
14+
"mean_ms": 54.032915080388385
15+
}
16+
]
17+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
=== Baseline: no held-xmin, 60s @ 1000 ev/s, tick=100ms ===
2+
running held-xmin scenario, tick_period_ms=100, rate=1000 ev/s, duration=60.0s ...
3+
4+
=== Scenario: held-xmin ===
5+
| tick_period_ms | rate_eps | sent | recvd | p50 | p95 | p99 | max | mean |
6+
|---:|---:|---:|---:|---:|---:|---:|---:|---:|
7+
| 100 | 1000 | 60000 | 60000 | 52.60 | 98.96 | 103.40 | 143.76 | 52.75 |
8+
9+
wrote results-baseline.json
10+
11+
=== Starting RR-held-xmin holder in background ===
12+
holder shell pid: 22130; sleeping 3s for it to settle ...
13+
backend snapshot of holder:
14+
pid | state | query_start | xact_start
15+
-------+--------+-------------------------------+-------------------------------
16+
22510 | active | 2026-05-04 23:49:46.553183+00 | 2026-05-04 23:49:46.553183+00
17+
22133 | active | 2026-05-04 23:49:43.551462+00 | 2026-05-04 23:49:43.551462+00
18+
22506 | active | 2026-05-04 23:49:45.734026+00 | 2026-05-04 23:49:45.734026+00
19+
(3 rows)
20+
21+
22+
=== Held-xmin: 300s @ 1000 ev/s, tick=100ms ===
23+
running held-xmin scenario, tick_period_ms=100, rate=1000 ev/s, duration=300.0s ...
24+
25+
=== Scenario: held-xmin ===
26+
| tick_period_ms | rate_eps | sent | recvd | p50 | p95 | p99 | max | mean |
27+
|---:|---:|---:|---:|---:|---:|---:|---:|---:|
28+
| 100 | 1000 | 300000 | 300000 | 53.83 | 100.18 | 104.73 | 235.56 | 54.03 |
29+
30+
wrote results-held-xmin.json
31+
32+
=== Killing holder ===
33+
pg_terminate_backend
34+
----------------------
35+
(0 rows)
36+
37+
38+
=== Done. ===
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
running tick_period_ms=10000, rate=100 ev/s, duration=30.0s ...
2+
running tick_period_ms=1000, rate=100 ev/s, duration=30.0s ...
3+
running tick_period_ms=100, rate=100 ev/s, duration=30.0s ...
4+
running tick_period_ms=10, rate=100 ev/s, duration=30.0s ...
5+
running tick_period_ms=1, rate=100 ev/s, duration=30.0s ...
6+
7+
=== Scenario: idle ===
8+
| tick_period_ms | rate_eps | sent | recvd | p50 | p95 | p99 | max | mean |
9+
|---:|---:|---:|---:|---:|---:|---:|---:|---:|
10+
| 10000 | 100 | 3000 | 3000 | 503.97 | 953.56 | 993.55 | 1003.24 | 503.71 |
11+
| 1000 | 100 | 3000 | 3000 | 503.32 | 953.87 | 993.89 | 1004.16 | 503.32 |
12+
| 100 | 100 | 3000 | 3000 | 52.62 | 98.86 | 103.48 | 105.28 | 52.74 |
13+
| 10 | 100 | 3000 | 3000 | 8.05 | 263.53 | 863.50 | 1013.39 | 41.86 |
14+
| 1 | 100 | 3000 | 3000 | 3.26 | 161.67 | 460.47 | 547.91 | 22.07 |
15+
Traceback (most recent call last):
16+
File "/home/user/pgque/benchmark/tick-rate/bench.py", line 247, in <module>
17+
sys.exit(main())
18+
^^^^^^
19+
File "/home/user/pgque/benchmark/tick-rate/bench.py", line 240, in main
20+
with open(args.out_json, "w") as f:
21+
^^^^^^^^^^^^^^^^^^^^^^^^
22+
PermissionError: [Errno 13] Permission denied: 'benchmark/tick-rate/results-idle.json'

0 commit comments

Comments
 (0)