Skip to content

Commit 3162569

Browse files
authored
bench: sample table health over an extend run and compare autovacuum variants (#258)
The extend sweep ran each point for 60 s, so it saw one autovacuum cycle and could not tell whether table size, index size or fetch time settle. A sampler now records them every --sample-interval seconds, and --vacuum runs each point untuned, with outbox_autovacuum_ddl defaults, or with the cost throttle off.
1 parent 4e7bf49 commit 3162569

4 files changed

Lines changed: 288 additions & 25 deletions

File tree

‎benchmarks/__main__.py‎

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@
1111
import os
1212
import pathlib
1313
import sys
14+
import typing
1415

15-
from benchmarks.config import DEFAULT_DSN, ExtendConfig, RunConfig
16+
from benchmarks.config import DEFAULT_DSN, ExtendConfig, RunConfig, VacuumVariant
1617
from benchmarks.extend import ExtendResult, format_extend, run_extend, to_json
1718
from benchmarks.report import compare, format_markdown, format_table, to_baseline
1819
from benchmarks.workload import RunResult, make_engine, run_consumer, run_producer
@@ -126,17 +127,35 @@ async def _run_extend_sweep(dsn: str, points: list[ExtendConfig]) -> list[Extend
126127
results = []
127128
for cfg in points:
128129
result = await run_extend(dsn, cfg)
129-
sys.stdout.write(f"{cfg.name}: {result.committed} pings in {result.wall_seconds:.1f}s\n")
130+
sys.stdout.write(f"{cfg.name}/{cfg.vacuum}: {result.committed} pings in {result.wall_seconds:.1f}s\n")
130131
sys.stdout.flush()
131132
results.append(result)
132133
return results
133134

134135

135-
def _extend(dsn: str, *, seconds: float | None, only: list[str] | None, json_path: str | None) -> int:
136-
"""Run the extend sweep and print its tables; nothing is gated."""
137-
points = [p for p in _EXTEND_POINTS if not only or p.name in only]
136+
def _extend_points(
137+
*,
138+
only: list[str] | None,
139+
seconds: float | None,
140+
vacuum: list[VacuumVariant] | None,
141+
sample_interval: float | None,
142+
) -> list[ExtendConfig]:
143+
"""Return the selected points, each crossed with every requested vacuum variant."""
144+
overrides: dict[str, float] = {}
138145
if seconds is not None:
139-
points = [dataclasses.replace(p, seconds=seconds) for p in points]
146+
overrides["seconds"] = seconds
147+
if sample_interval is not None:
148+
overrides["sample_interval"] = sample_interval
149+
return [
150+
dataclasses.replace(p, **overrides, vacuum=variant)
151+
for p in _EXTEND_POINTS
152+
if not only or p.name in only
153+
for variant in (vacuum or [p.vacuum])
154+
]
155+
156+
157+
def _extend(dsn: str, *, points: list[ExtendConfig], json_path: str | None) -> int:
158+
"""Run the extend sweep and print its tables; nothing is gated."""
140159
results = asyncio.run(_run_extend_sweep(dsn, points))
141160
sys.stdout.write("\n" + format_extend(results) + "\n")
142161
if json_path is not None:
@@ -168,6 +187,8 @@ def _build_parser() -> argparse.ArgumentParser:
168187
extend.add_argument("--seconds", type=float)
169188
extend.add_argument("--only", nargs="+", choices=[p.name for p in _EXTEND_POINTS])
170189
extend.add_argument("--json")
190+
extend.add_argument("--vacuum", nargs="+", choices=typing.get_args(VacuumVariant))
191+
extend.add_argument("--sample-interval", type=float)
171192
return parser
172193

173194

@@ -179,7 +200,10 @@ def main(argv: list[str] | None = None) -> int:
179200
if args.command == "check":
180201
return _check(dsn, markdown=args.markdown)
181202
if args.command == "extend":
182-
return _extend(dsn, seconds=args.seconds, only=args.only, json_path=args.json)
203+
points = _extend_points(
204+
only=args.only, seconds=args.seconds, vacuum=args.vacuum, sample_interval=args.sample_interval
205+
)
206+
return _extend(dsn, points=points, json_path=args.json)
183207
return _run(dsn, args.messages, args.repeats, write_baseline=args.write_baseline)
184208

185209

‎benchmarks/config.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Workload configuration for a single benchmark run."""
22

33
import dataclasses
4+
import typing
45

56

67
# Matches the payload size in Tsvettsikh's «50 оттенков Transactional Outbox»
@@ -10,6 +11,10 @@
1011
# The DSN the harness runs against. Overridden by POSTGRES_DSN in compose.
1112
DEFAULT_DSN = "postgresql+asyncpg://outbox:outbox@localhost:5432/outbox"
1213

14+
# How an extend point's table is vacuumed: no reloptions, outbox_autovacuum_ddl's defaults,
15+
# or those defaults with autovacuum's cost throttle turned off.
16+
VacuumVariant = typing.Literal["untuned", "tuned", "unthrottled"]
17+
1318
# Tags the harness' own connections so the probe can assert it owns the database.
1419
APPLICATION_NAME = "faststream-outbox-bench"
1520

@@ -49,8 +54,17 @@ class ExtendConfig:
4954
pool_size: int = 20
5055
max_fetch_interval: float = 1.0
5156
queue: str = "timeouts"
57+
vacuum: VacuumVariant = "tuned"
58+
# Seconds between table-health samples taken while the load runs.
59+
sample_interval: float = 10.0
5260

5361
def __post_init__(self) -> None:
62+
if self.vacuum not in typing.get_args(VacuumVariant):
63+
msg = f"{self.name}: vacuum must be one of {typing.get_args(VacuumVariant)}, got {self.vacuum!r}"
64+
raise ValueError(msg)
65+
if self.sample_interval <= 0:
66+
msg = f"{self.name}: sample_interval must be positive, got {self.sample_interval}"
67+
raise ValueError(msg)
5468
if self.ping_interval >= self.timeout_seconds:
5569
msg = f"{self.name}: each identity pings every {self.ping_interval:.2f}s, not before its timeout"
5670
raise ValueError(msg)

‎benchmarks/extend.py‎

Lines changed: 131 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,12 @@
1010
task on a pool of ``pool_size`` connections, so pool waits show up once Postgres falls behind. A point
1111
stops 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

1821
import asyncio
@@ -50,6 +53,24 @@
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)
88123
class 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

111147
def 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+
123180
def _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+
230313
async 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

247332
async 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+
309418
def 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

349470
def to_json(results: typing.Sequence[ExtendResult]) -> str:

0 commit comments

Comments
 (0)