Skip to content

Commit 6887108

Browse files
authored
docs: size heartbeat timers by commit rate, with 15-minute autovacuum results (#259)
1 parent 3162569 commit 6887108

2 files changed

Lines changed: 66 additions & 12 deletions

File tree

‎docs/concepts/performance.md‎

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,9 @@ pass `vacuum_cost_delay` / `vacuum_cost_limit` to let vacuum keep pace. A
8888
settings are applied. Full guidance, the eligibility-vs-throughput split, and the
8989
I/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

102105
So 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)
120124
raises 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

139177
The statement-count reductions (`select_calls`, `delete_calls`, `insert_calls`,
140178
…) are machine-independent and exact. Wall-clock throughput is not: it scales

‎docs/usage/timers.md‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -163,9 +163,25 @@ that matters, either:
163163

164164
Every extend writes a new row version, even when the deadline does not
165165
move. `next_attempt_at` is indexed, so these updates are not HOT updates:
166-
each one adds index entries and a dead tuple for autovacuum to clean up.
167-
Size autovacuum for your heartbeat rate (see
168-
[Alembic migrations](../operations/alembic.md), `outbox_autovacuum_ddl`).
166+
each one leaves a dead tuple and dead index entries until autovacuum
167+
reclaims them.
168+
169+
A table of heartbeat timers holds about one live row per timer, so Postgres'
170+
default autovacuum trigger, dead tuples above 20% of the live rows, is
171+
crossed within a minute as long as each timer is pinged more often than
172+
every five minutes. Autovacuum then runs once per `autovacuum_naptime`, a
173+
cluster setting that defaults to 60 seconds, and dead tuples peak at about
174+
the ping rate times that interval: around 600,000 at 10,000 pings/s. In
175+
15-minute benchmark runs at 1,000 and 10,000 pings/s, table size, dead
176+
tuples and claim time stopped growing within two minutes, and neither
177+
`outbox_autovacuum_ddl` nor `vacuum_cost_delay=0` made a measurable
178+
difference (see [Performance](../concepts/performance.md#timer-extends-under-heartbeat-load)).
179+
180+
What limits a heartbeat is the commit rate. Group pings into short
181+
transactions once you are past about a thousand pings per second. If the
182+
same table also carries ordinary messages, the queue-table advice in
183+
[Alembic migrations](../operations/alembic.md#autovacuum-tuning-recommended)
184+
still applies.
169185

170186
## Cancellation
171187

0 commit comments

Comments
 (0)