Skip to content

bench: timer extend workload under heartbeat load - #255

Merged
lesnik512 merged 1 commit into
mainfrom
bench/timer-extend
Oct 26, 2026
Merged

lesnik512 merged 1 commit into
mainfrom
bench/timer-extend

Conversation

@lesnik512

@lesnik512 lesnik512 commented Oct 26, 2026 •

Copy link
Copy Markdown
Member

Adds just bench-extend (python -m benchmarks extend). It's a heartbeat workload for publish(..., timer_conflict="extend"). It isn't gated, and the CI sweep and baseline.json don't change. It measures the extend that #254 added.

Workload

  • Pings arrive at a fixed rate whether or not earlier ones have committed, round-robin over the timers, so each timer is pinged every 2 s and its deadline moves to ping time + 6 s.
  • Before the run starts, every timer is already in the table, as in steady state.
  • At a quarter of the run, 5% of the timers stop pinging. They must fire, and every other timer that fires counts as a false expiry.
  • Pings commit one per transaction or in batches of up to N. A batch waits at most 10 ms to fill, and its pings run in timer_id order so concurrent batches lock rows in the same order. Each transaction takes a connection from a pool of 20.
  • A subscriber with max_fetch_interval=1 fires the expired timers.
  • The table has outbox_autovacuum_ddl applied and autovacuum on. The gated sweep turns autovacuum off on its tables.
  • A point stops offering load and is marked saturated once the backlog exceeds 5 s of pings.
  • Statement, tuple and WAL counts come from the existing probe. Per-statement times come from pg_stat_statements, and the autovacuum count from pg_stat_user_tables.

Results

One run, 60 s per point. Apple M2, OrbStack, Postgres 17 at default settings (synchronous_commit on), one Python client process. The two saturated rows come from reruns after a fix to how the offered rate is computed.

point offered/s committed/s commits/s ping p50 / p95 / p99 ms pool wait p99 ms extend stmt mean / max ms errors
r1000-b1 963 963 963 2.1 / 4.3 / 12.4 0.1 0.023 / 1.4 0
r3000-b1 2,890 2,890 2,890 3.1 / 54.2 / 193.1 185.6 0.024 / 3.6 0
r3000-b10 2,890 2,890 289 5.9 / 9.2 / 14.2 0.1 0.018 / 2.7 0
r5000-b1 (saturated) 4,964 1,764 1,764 5273 / 15244 / 16993 16923 0.064 / 26.5 0
r10000-b1 (saturated) 9,996 697 697 9779 / 25783 / 29177 29050 0.034 / 4.7 pool TimeoutError 28708
r10000-b50 9,633 9,624 192 14.3 / 408.0 / 617.8 509.0 0.016 / 2.2 0
hot-r3000-b1 2,887 2,887 2,887 3.6 / 68.7 / 150.8 142.0 0.019 / 2.5 0
hot-r3000-b10 2,887 2,887 289 6.1 / 9.1 / 11.5 0.1 0.015 / 8.6 0
point WAL B/ping WAL rec/ping FPI HOT % dead tuples autovacuums index MB fetch mean / max ms silent fired false expiries lateness p50 / max s extends that hit a lease
r1000-b1 637 6.79 141 0 32,279 1 2.4 0.237 / 1.0 100 / 100 0 0.18 / 0.21 0
r3000-b1 624 6.72 328 0 100,057 1 5.8 0.510 / 1.4 300 / 300 0 0.43 / 0.74 0
r3000-b10 567 5.44 301 0 99,169 1 6.1 0.426 / 1.4 300 / 300 0 0.97 / 1.24 0
r5000-b1 (saturated) 777 7.75 499 0 2,411 1 4.5 0.559 / 5.3 555 / 500 10,040 6.47 / 12.79 5,714
r10000-b1 (saturated) 1,293 11.18 971 0 4,780 1 5.0 0.352 / 2.3 1000 / 1000 14,105 10.52 / 30.94 0
r10000-b50 575 5.38 968 0 148,359 1 26.0 1.058 / 7.1 1000 / 1000 0 1.93 / 3.22 0
hot-r3000-b1 573 6.15 31 0 45,416 1 6.2 0.463 / 1.5 3 / 3 0 0.79 / 0.79 0
hot-r3000-b10 541 5.30 14 0 45,444 1 6.2 0.386 / 1.2 3 / 3 0 0.07 / 0.08 0

The hot points spread the same rate over 60 timers, so pings to one timer collide.

Findings

  • The statement is cheap. Postgres runs an extend in about 0.02 ms. What limits throughput is commits and round trips.
  • One commit per ping tops out at about 3,000 pings/s here. Pool waits are already near 190 ms p99 at that rate. At 5,000 and 10,000/s it saturates and collapses: committed throughput drops below the 3,000/s point.
    • Once the backlog piles up, so do pings that miss their deadlines. Live timers then fire: 10,000 to 14,000 false expiries.
    • I can't tell from this run whether the bottleneck is Postgres or the single Python client. Statement time is tiny, so the client and the commit round trips are the likely limit. A setup with several client processes would answer that.
  • Batching is the lever. 50 pings per transaction sustained 9,600/s at 192 commits/s, with a p99 of 618 ms. 10 per transaction at 3,000/s cut p99 from 193 ms to 14 ms.
  • Every extend is a new row version. About 550 to 650 B and 5 to 7 records of WAL per ping, 0% HOT, and one dead tuple per ping. Autovacuum ran once per minute, the default autovacuum_naptime, so dead tuples reached the ping count. At 10,000/s the index grew to 26 MB for 20,000 live rows.
  • The claim query slows as dead entries pile up, from 0.24 ms mean at 1,000/s to 1.06 ms at 10,000/s with batching. It's measurable but small.
  • Expiry lateness stays under the 1 s fetch interval while pings keep up, apart from the 10,000/s batched point (3.2 s max).
  • Same-timer contention cost nothing measurable at 60 timers × 50 pings/s each.
  • 5,714 extends hit a leased row in the saturated r5000 run. Each of those got None and armed no timer. An extend leaves a running timer to its worker: the next ping after the handler finishes inserts a fresh timer, so each one costs at most one expiry. The count only grows once Postgres can't keep up; it was 0 at every point that kept pace.

Not measured

  • Host CPU and I/O. The harness reports Postgres statement time and buffer counts, not container CPU.
  • Normal application traffic on the same database.
  • Production-scale timeouts (600 s). Timers here are scaled down to a 2 s ping interval and a 6 s timeout so expiries happen inside a run.
  • Repeat runs. Each number above comes from a single run.

@github-actions

github-actions Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

Benchmark gate

✅ gate passed

scenario msg/s delete/msg WALrec/msg WALB/msg fpi upd del dead_tup
consumer/w1/b10 819 1.000 6.72 921 242 5000 5000 10000
consumer/w1/b100 1401 1.000 7.11 1154 243 5000 5000 10000
consumer/w2/b10 1481 1.000 6.72 924 242 5000 5000 10000
consumer/w2/b100 2113 1.000 7.11 1154 243 5000 5000 10000
consumer/w4/b10 2119 1.000 6.79 984 261 5000 5000 10000
consumer/w4/b100 2052 1.000 7.11 1155 244 5000 5000 10000
consumer/w1/b100/tfbs100 8166 0.010 6.11 1130 243 5000 5000 10000
consumer/w1/b100/interval1-10 1321 1.000 7.11 1154 243 5000 5000 10000
producer/w1/b100 4283 0.000 3.04 597 1 0 0 0
producer/w1/b100/ppt1 1193 0.000 4.04 635 0 0 0 0

Gated (fails the build): delete_calls + tuple counters (upd/del/ins) + the producer's insert_calls, exact; select_calls and other_calls within +2; wal_records within a 10% band; fetch_calls within 2x. msg/s, WAL bytes and total calls are informational (timing/FPI noise).

Base automatically changed from feat/timer-extend to main October 26, 2026 15:13
@lesnik512
lesnik512 merged commit 9c3bfa8 into main Oct 26, 2026
15 checks passed
@lesnik512
lesnik512 deleted the bench/timer-extend branch October 26, 2026 15:16
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.

1 participant