1010task on a pool of ``pool_size`` connections, so pool waits show up once Postgres falls behind. A point
1111stops offering load once the backlog passes five seconds of pings and is reported as saturated.
1212
13- The table carries the recommended autovacuum settings, unlike the gated sweep's tables, because dead
14- tuples and their cleanup are part of what this measures. Statement, tuple and WAL counters come from
15- the same probe as the gated sweep. Nothing here is gated: every number depends on timing.
13+ The table's autovacuum settings follow the point's ``vacuum`` variant, ``outbox_autovacuum_ddl``'s
14+ defaults unless it says otherwise, because dead tuples and their cleanup are part of what this measures.
15+ Every ``sample_interval`` seconds a separate connection records table and index size, dead tuples,
16+ autovacuum runs and the fetch statement's cumulative counters, so a long run shows whether they settle.
17+ Statement, tuple and WAL counters come from the same probe as the gated sweep. Nothing here is gated:
18+ every number depends on timing.
1619"""
1720
1821import asyncio
5053 """ ,
5154)
5255
56+ # Mentions pg_stat, so the probe's NOT ILIKE '%pg_stat%' filter drops it from the statement counters.
57+ _SAMPLE_SQL = text (
58+ """
59+ SELECT pg_relation_size(t.relid) AS table_bytes,
60+ pg_indexes_size(t.relid) AS index_bytes,
61+ t.n_dead_tup, t.n_live_tup, t.autovacuum_count,
62+ coalesce(f.calls, 0) AS fetch_calls,
63+ coalesce(f.total_ms, 0) AS fetch_ms
64+ FROM pg_stat_user_tables t,
65+ LATERAL (
66+ SELECT sum(s.calls) AS calls, sum(s.total_exec_time) AS total_ms
67+ FROM pg_stat_statements s
68+ WHERE s.query NOT ILIKE '%pg_stat%' AND lower(split_part(btrim(s.query), ' ', 1)) = 'with'
69+ ) f
70+ WHERE t.relname = :table AND t.schemaname = current_schema()
71+ """ ,
72+ )
73+
5374_VACUUM_SQL = text (
5475 """
5576 SELECT t.autovacuum_count, t.n_live_tup
@@ -84,6 +105,20 @@ class _Tally:
84105 silent_fired : asyncio .Event = dataclasses .field (default_factory = asyncio .Event )
85106
86107
108+ @dataclasses .dataclass (frozen = True )
109+ class Sample :
110+ """Table health *elapsed* seconds after sampling began; the fetch counters are cumulative."""
111+
112+ elapsed : float
113+ table_bytes : int
114+ index_bytes : int
115+ dead_tuples : int
116+ live_tuples : int
117+ autovacuum_count : int
118+ fetch_calls : int
119+ fetch_ms : float
120+
121+
87122@dataclasses .dataclass (frozen = True )
88123class ExtendResult :
89124 """One extend point: client-side timings, expiry outcomes and the probe's catalog deltas."""
@@ -106,6 +141,7 @@ class ExtendResult:
106141 autovacuum_count : int
107142 live_tuples : int
108143 probe : ProbeResult
144+ samples : tuple [Sample , ...] = ()
109145
110146
111147def percentiles (values : typing .Sequence [float ]) -> dict [str , float ]:
@@ -120,6 +156,27 @@ def _rank(q: float) -> float:
120156 return {"p50" : _rank (0.50 ), "p95" : _rank (0.95 ), "p99" : _rank (0.99 ), "max" : ordered [- 1 ]}
121157
122158
159+ def autovacuum_ddl (cfg : ExtendConfig , table_name : str ) -> str | None :
160+ """Return the ``ALTER TABLE`` that sets up *cfg*'s vacuum variant, or ``None`` to leave the table untuned."""
161+ if cfg .vacuum == "untuned" :
162+ return None
163+ return outbox_autovacuum_ddl (table_name , vacuum_cost_delay = 0 if cfg .vacuum == "unthrottled" else None )
164+
165+
166+ def _sample_at (samples : typing .Sequence [Sample ], elapsed : float ) -> Sample :
167+ """Return the last sample taken at or before *elapsed*, or the first one."""
168+ return next ((s for s in reversed (samples ) if s .elapsed <= elapsed ), samples [0 ])
169+
170+
171+ def fetch_mean_ms (samples : typing .Sequence [Sample ], start : float , end : float ) -> float :
172+ """Mean fetch statement time between the samples at *start* and *end*; 0 when nothing ran."""
173+ if not samples :
174+ return 0.0
175+ first , last = _sample_at (samples , start ), _sample_at (samples , end )
176+ calls = last .fetch_calls - first .fetch_calls
177+ return (last .fetch_ms - first .fetch_ms ) / calls if calls else 0.0
178+
179+
123180def _body (identity : int , deadline : dt .datetime ) -> dict [str , typing .Any ]:
124181 return {"identity" : identity , "deadline" : deadline .isoformat ()}
125182
@@ -129,7 +186,8 @@ async def _create_table(engine: AsyncEngine, cfg: ExtendConfig) -> tuple[MetaDat
129186 table = make_outbox_table (metadata , table_name = f"bench_extend_{ uuid .uuid4 ().hex [:10 ]} " )
130187 async with engine .begin () as conn :
131188 await conn .run_sync (metadata .create_all )
132- await conn .execute (text (outbox_autovacuum_ddl (table .name )))
189+ if (ddl := autovacuum_ddl (cfg , table .name )) is not None :
190+ await conn .execute (text (ddl ))
133191 start = utcnow ()
134192 rows = []
135193 for identity in range (cfg .timers ):
@@ -227,25 +285,52 @@ def _spawn() -> None:
227285 return started
228286
229287
288+ async def _sample_until (stop : asyncio .Event , engine : AsyncEngine , table : Table , interval : float ) -> list [Sample ]:
289+ """Sample table health every *interval* seconds, plus once more after *stop* is set."""
290+ samples : list [Sample ] = []
291+ started = time .perf_counter ()
292+ async with _autocommit (engine ) as conn :
293+ while True :
294+ row = (await conn .execute (_SAMPLE_SQL , {"table" : table .name })).one ()
295+ samples .append (
296+ Sample (
297+ elapsed = time .perf_counter () - started ,
298+ table_bytes = int (row .table_bytes ),
299+ index_bytes = int (row .index_bytes ),
300+ dead_tuples = int (row .n_dead_tup ),
301+ live_tuples = int (row .n_live_tup ),
302+ autovacuum_count = int (row .autovacuum_count ),
303+ fetch_calls = int (row .fetch_calls ),
304+ fetch_ms = float (row .fetch_ms ),
305+ ),
306+ )
307+ if stop .is_set ():
308+ return samples
309+ with contextlib .suppress (TimeoutError ):
310+ await asyncio .wait_for (stop .wait (), timeout = interval )
311+
312+
230313async def run_extend (dsn : str , cfg : ExtendConfig ) -> ExtendResult :
231314 """Run one extend point against a fresh table and return its measurements."""
232315 engine = make_engine (dsn , pool_size = cfg .pool_size )
233316 subscriber_engine = make_engine (dsn )
317+ sampler_engine = make_engine (dsn , pool_size = 1 )
234318 try :
235319 await ensure_extension (engine )
236320 metadata , table = await _create_table (engine , cfg )
237321 try :
238- return await _measure (engine , subscriber_engine , table , cfg )
322+ return await _measure (engine , subscriber_engine , sampler_engine , table , cfg )
239323 finally :
240324 async with engine .begin () as conn :
241325 await conn .run_sync (metadata .drop_all )
242326 finally :
327+ await sampler_engine .dispose ()
243328 await subscriber_engine .dispose ()
244329 await engine .dispose ()
245330
246331
247332async def _measure (
248- engine : AsyncEngine , subscriber_engine : AsyncEngine , table : Table , cfg : ExtendConfig
333+ engine : AsyncEngine , subscriber_engine : AsyncEngine , sampler_engine : AsyncEngine , table : Table , cfg : ExtendConfig
249334) -> ExtendResult :
250335 tally = _Tally ()
251336 publisher = OutboxBroker (outbox_table = table , logger = None )
@@ -263,12 +348,16 @@ async def _expire(body: dict[str, typing.Any]) -> None:
263348
264349 await _checkpoint (engine )
265350 await assert_owns_database (engine )
351+ stop_sampling = asyncio .Event ()
266352 async with probe (engine , table .name ) as sink :
267353 await expirer .start ()
354+ sampler = asyncio .create_task (_sample_until (stop_sampling , sampler_engine , table , cfg .sample_interval ))
268355 try :
269356 started = await _offer_load (engine = engine , publisher = publisher , cfg = cfg , tally = tally )
270357 await _wait_for_silent_expiries (cfg , tally , started )
271358 finally :
359+ stop_sampling .set ()
360+ samples = await sampler
272361 await expirer .stop ()
273362 await subscriber_engine .dispose ()
274363 wall = (tally .last_commit or time .perf_counter ()) - started
@@ -296,6 +385,7 @@ async def _expire(body: dict[str, typing.Any]) -> None:
296385 autovacuum_count = int (vacuum .autovacuum_count ),
297386 live_tuples = int (vacuum .n_live_tup ),
298387 probe = sink [0 ],
388+ samples = tuple (samples ),
299389 )
300390
301391
@@ -306,8 +396,27 @@ async def _wait_for_silent_expiries(cfg: ExtendConfig, tally: _Tally, started: f
306396 await asyncio .wait_for (tally .silent_fired .wait (), timeout = max (0.0 , give_up - time .perf_counter ()))
307397
308398
399+ def _label (cfg : ExtendConfig ) -> str :
400+ return f"{ cfg .name } /{ cfg .vacuum } "
401+
402+
403+ def _drift_row (r : ExtendResult ) -> str :
404+ samples = r .samples
405+ first , last = samples [0 ], samples [- 1 ]
406+ mb = 2 ** 20
407+ table_peak = max (s .table_bytes for s in samples )
408+ index_peak = max (s .index_bytes for s in samples )
409+ early = fetch_mean_ms (samples , first .elapsed , first .elapsed + 60.0 )
410+ late = fetch_mean_ms (samples , last .elapsed - 60.0 , last .elapsed )
411+ return (
412+ f"| { _label (r .config )} | { first .table_bytes / mb :.1f} / { last .table_bytes / mb :.1f} / { table_peak / mb :.1f} "
413+ f"| { first .index_bytes / mb :.1f} / { last .index_bytes / mb :.1f} / { index_peak / mb :.1f} "
414+ f"| { max (s .dead_tuples for s in samples ):,} | { last .autovacuum_count } | { early :.3f} / { late :.3f} |"
415+ )
416+
417+
309418def format_extend (results : typing .Sequence [ExtendResult ]) -> str :
310- """Render the extend points as two markdown tables: the write path, then table and expiry health."""
419+ """Render the extend points as markdown tables: the write path, table and expiry health, then drift ."""
311420 write = [
312421 (
313422 "| point | offered/s | committed/s | commits/s | ping p50 / p95 / p99 ms | pool wait p99 ms "
@@ -329,7 +438,7 @@ def format_extend(results: typing.Sequence[ExtendResult]) -> str:
329438 insert_ms = r .statement_ms .get ("insert" , (0.0 , 0.0 ))
330439 fetch_ms = r .statement_ms .get ("with" , (0.0 , 0.0 ))
331440 errors = ", " .join (f"{ k } { v } " for k , v in sorted (r .errors .items ())) or "0"
332- label = f"{ cfg . name } { ' (saturated)' if r .saturated else '' } "
441+ label = f"{ _label ( cfg ) } { ' (saturated)' if r .saturated else '' } "
333442 offered_rate = r .offered / max (r .offered_seconds , 1e-9 )
334443 write .append (
335444 f"| { label } | { offered_rate :,.0f} | { r .committed / span :,.0f} | { r .transactions / span :,.0f} "
@@ -338,12 +447,24 @@ def format_extend(results: typing.Sequence[ExtendResult]) -> str:
338447 )
339448 hot = 100.0 * p .tup_hot_upd / p .tup_upd if p .tup_upd else 0.0
340449 health .append (
341- f"| { cfg . name } | { p .wal_bytes / pings :,.0f} | { p .wal_records / pings :.2f} | { p .wal_fpi :,} | { hot :.0f} "
450+ f"| { _label ( cfg ) } | { p .wal_bytes / pings :,.0f} | { p .wal_records / pings :.2f} | { p .wal_fpi :,} | { hot :.0f} "
342451 f"| { p .dead_tup :,} | { r .autovacuum_count } | { p .index_bytes / 2 ** 20 :.1f} "
343452 f"| { fetch_ms [0 ]:.3f} / { fetch_ms [1 ]:.1f} | { r .expired_silent } / { cfg .silent_timers } "
344453 f"| { r .false_expiries } | { r .lateness ['p50' ]:.2f} / { r .lateness ['max' ]:.2f} | { r .leased } |" ,
345454 )
346- return "\n " .join ([* write , "" , * health ])
455+ tables = [* write , "" , * health ]
456+ sampled = [r for r in results if r .samples ]
457+ if sampled :
458+ tables += [
459+ "" ,
460+ (
461+ "| point | table MB start / end / peak | index MB start / end / peak | dead tuples peak "
462+ "| autovacuums | fetch mean ms first / last minute |"
463+ ),
464+ "|---|---|---|---|---|---|" ,
465+ * (_drift_row (r ) for r in sampled ),
466+ ]
467+ return "\n " .join (tables )
347468
348469
349470def to_json (results : typing .Sequence [ExtendResult ]) -> str :
0 commit comments