@@ -88,6 +88,9 @@ pass `vacuum_cost_delay` / `vacuum_cost_limit` to let vacuum keep pace. A
8888settings are applied. Full guidance, the eligibility-vs-throughput split, and the
8989I/O caution on ` vacuum_cost_delay=0 ` are in
9090[ Autovacuum tuning] ( ../operations/alembic.md#autovacuum-tuning-recommended ) .
91+ A table that only holds heartbeat timers is the exception: its live set stays
92+ small, and the tuning made no measurable difference there (see
93+ [ below] ( #timer-extends-under-heartbeat-load ) ).
9194
9295## Timer extends under heartbeat load
9396
@@ -101,14 +104,15 @@ runs it in about 0.02 ms. The cost is what comes with it:
101104
102105So the ceiling is set by commits and round trips, not by the statement.
103106
104- One run of ` just bench-extend ` , on an Apple M2 with OrbStack, Postgres 17 at
105- default settings, one Python client process and a 20-connection pool. Each
106- identity pings every 2 seconds and has a 6-second timeout:
107+ One 60-second run of ` just bench-extend ` per point, on an Apple M2 with
108+ OrbStack, Postgres 17 at default settings, one Python client process and a
109+ 20-connection pool. Each identity pings every 2 seconds and has a 6-second
110+ timeout:
107111
108112| Offered pings/s | Pings per transaction | Kept up | Ping p99 | Expiry lateness max |
109113| ---| ---| ---| ---| ---|
110114| 1,000 | 1 | yes | 12 ms | 0.2 s |
111- | 3,000 | 1 | yes | 193 ms | 0.7 s |
115+ | 3,000 | 1 | for 60 s only (see below) | 193 ms | 0.7 s |
112116| 3,000 | 10 | yes | 14 ms | 1.2 s |
113117| 5,000 | 1 | no | 17 s | 12.8 s |
114118| 10,000 | 50 | yes | 618 ms | 3.2 s |
@@ -119,10 +123,42 @@ saturated runs. Plan for a wide margin. Grouping pings into short transactions
119123(sorted by ` timer_id ` , so concurrent transactions lock rows in the same order)
120124raises the ceiling a lot, at the cost of latency.
121125
122- Dead tuples pile up at the ping rate, about 150,000 in a minute at 10,000
123- pings/s, and autovacuum only runs once per ` autovacuum_naptime ` , which
124- defaults to 60 seconds. The claim query slowed from about 0.2 to 1 ms over
125- such a minute.
126+ 3,000 pings/s with a commit per ping is this machine's limit, not a safe
127+ rate. The 60-second run kept up, but three 15-minute runs at that rate fell
128+ behind after 50 to 85 seconds, whatever the autovacuum settings. One
129+ commit per ping held at 1,000 pings/s for 15 minutes, and 50 pings per
130+ commit held at 10,000 pings/s.
131+
132+ ### Over 15 minutes
133+
134+ Each identity is one live row, so the table stays small, and Postgres'
135+ default autovacuum trigger (dead tuples above 20% of live rows) is crossed
136+ within every minute. Autovacuum ran once per ` autovacuum_naptime ` , which
137+ defaults to 60 seconds, in all of these runs, untuned ones included. Dead
138+ tuples peak at about the ping rate times that interval.
139+
140+ One 15-minute run per row, same machine. * untuned* has no autovacuum
141+ reloptions, * tuned* applies ` outbox_autovacuum_ddl() ` , and * unthrottled*
142+ adds ` vacuum_cost_delay=0 ` :
143+
144+ | Pings/s | Pings per transaction | Autovacuum | Table MB, start / end | Index MB, start / end | Dead tuples, peak | Claim mean, last minute |
145+ | ---| ---| ---| ---| ---| ---| ---|
146+ | 1,000 | 1 | untuned | 0.5 / 2.0 | 0.3 / 3.6 | 53,622 | 0.38 ms |
147+ | 1,000 | 1 | tuned | 0.5 / 1.9 | 0.3 / 3.5 | 53,771 | 0.42 ms |
148+ | 1,000 | 1 | unthrottled | 0.5 / 1.9 | 0.3 / 6.1 | 54,571 | 0.34 ms |
149+ | 10,000 | 50 | untuned | 5.0 / 25.5 | 2.4 / 46.8 | 590,515 | 3.27 ms |
150+ | 10,000 | 50 | tuned | 5.0 / 25.4 | 2.4 / 40.2 | 608,410 | 3.10 ms |
151+ | 10,000 | 50 | unthrottled | 5.0 / 25.4 | 2.4 / 60.4 | 523,700 | 2.79 ms |
152+
153+ From the second minute on, table size held flat, dead tuples stayed in a band
154+ (about 22,000 to 23,500 sampled at 1,000 pings/s, 270,000 to 320,000 at
155+ 10,000), and the claim query's per-minute mean stayed between about 0.3 and
156+ 0.5 ms at 1,000 pings/s and mostly between 2.7 and 3.9 ms at 10,000, with
157+ one minute dipping to 1.3 to 1.9 ms in each 10,000 run. Index size rose
158+ in a step or two early on and then held. The differences between the three
159+ settings are within run-to-run noise: the largest index came from the
160+ unthrottled runs. For a table that only holds heartbeat timers, autovacuum
161+ tuning doesn't change the outcome; the commit rate does.
126162
127163## Measuring it yourself
128164
@@ -134,7 +170,9 @@ can run it against a local Postgres:
134170- ` just bench-check ` gates the deterministic counts against
135171 ` benchmarks/baseline.json ` ; this is what CI runs.
136172- ` just bench-extend ` runs the timer extend sweep above and prints its tables.
137- Nothing in it is gated: every number depends on timing.
173+ Nothing in it is gated: every number depends on timing. The 15-minute
174+ table came from `just bench-extend --only r1000-b1 r10000-b50 --seconds 900
175+ --vacuum untuned tuned unthrottled` ; ` --json` writes every sample.
138176
139177The statement-count reductions (` select_calls ` , ` delete_calls ` , ` insert_calls ` ,
140178…) are machine-independent and exact. Wall-clock throughput is not: it scales
0 commit comments