Skip to content

Benchmark harness for the outbox transport#130

Merged
lesnik512 merged 13 commits into
mainfrom
bench-harness-spec
Jul 15, 2026
Merged

Benchmark harness for the outbox transport#130
lesnik512 merged 13 commits into
mainfrom
bench-harness-spec

Conversation

@lesnik512

Copy link
Copy Markdown
Member

Adds benchmarks/ — a harness that drives the real FastStream outbox broker against Postgres and reports per-message DB counters plus throughput, with a CI gate on the structural subset. Establishes the performance baseline for the outbox work. Ships the instrument, not a fix.

Design and the measurements behind it (the change's truth home): planning/changes/2026-07-14.01-benchmark-harness.md.

What it measures

Two scenarios against a fresh per-run table, 256-byte payloads (commensurable with Tsvettsikh's talk):

  • Consumer — seed N rows, drain through the real broker with a no-op handler. Sweeps max_workers ∈ {1,2,4} × fetch_batch_size ∈ {10,100}.
  • Producer — N publish() in one transaction.

The probe snapshots Postgres catalogs (pg_stat_statements, pg_stat_wal, pg_stat_user_tables) around the workload and returns per-op statement counts + tuple/WAL deltas. It force-flushes stats via engine.dispose() and runs every probe query on AUTOCOMMIT so it never counts itself (empty workload → calls == 0).

The gate

just bench-check compares a run against the committed benchmarks/baseline.json and fails a PR on drift. It gates only structural, load-independent per-message work:

  • exact: delete_calls/insert_calls/select_calls (from calls_by_op) and the tuple counters tup_upd/tup_del/tup_ins
  • ±10% band: wal_records
  • never gated: total calls, wal_bytes, wall-clock (timing/FPI noise)

The original plan gated total pg_stat_statements.calls exactly. Implementation falsified that: the subscriber's fetch loop polls on a wall-clock timer, so total calls was 519 idle vs 894 under CPU load. The gate moved to per-operation + tuple counts, which are identical idle and under load. The spec records this pivot in full.

Headline baseline numbers (today's code)

  • 1 terminal DELETE per message — the per-row flush is the entire structural round-trip budget.
  • dead_tup == 2 × messages, exact — a direct measurement of the talk's "2× mutations → 2× autovacuum" cost for this outbox variant.
  • tup_hot_upd == 0 — HOT is structurally impossible (the claim UPDATE mutates both partial indexes' key columns).
  • Producer: ~2 statements per publish() (INSERT … RETURNING + SELECT pg_notify).

Verification

  • The gate bites: perturbing the terminal flush with an extra statement per message makes bench-check fail on select_calls (0 → 5000) across all consumer points; reverted, it returns green.
  • Full suite passes at --cov-fail-under=100 (benchmarks/* is coverage-omitted; a test imports it).
  • benchmarks/ is dev tooling, never shipped in the wheel.

No runtime behavior changes; no architecture/ capability is affected.

🤖 Generated with Claude Code

lesnik512 and others added 13 commits July 14, 2026 23:19
Places the library in Variant 2 of Tsvettsikh's outbox taxonomy and
records the measurements that shape the harness: per-row terminal DELETE
is the entire round-trip budget (1.044 rt/msg), WAL and wall-clock
disagree by two orders of magnitude on whether batching matters, and
fillfactor cannot help the claim UPDATE (HOT is structurally impossible).

Gate tiers are set by measured determinism, not assumption: calls is
exact (0.0% spread), wal_records tolerant (3.3%), wal_bytes ungated
(65.8%, FPI-dominated).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Snapshots pg_stat_statements/pg_stat_wal/pg_stat_user_tables around a
workload and returns a ProbeResult delta. Filters its own queries out
of the round-trip count by SQL text (NOT ILIKE '%pg_stat%'), subtracts
cumulative table/WAL counters from a start snapshot, and settles on
pg_stat_user_tables before reading (the stats collector flushes lazily).
assert_owns_database guards against a foreign backend polluting the
database-wide statement counters.
The settle loop polled n_tup_upd + n_tup_del, which never moves for an
insert-only workload, and its 0.75s stability window was shorter than
Postgres' 1s PGSTAT_MIN_INTERVAL, so the seed's stats could still be
unflushed when the origin was captured and leaked into the delta.
Replace it with engine.dispose() before each snapshot: Postgres
force-flushes a backend's pending stats when the backend exits, so the
flush is exact rather than eventual.

Run every probe statement on an AUTOCOMMIT connection. SQLAlchemy's
implicit BEGIN/COMMIT/ROLLBACK are tracked by pg_stat_statements and
contain no 'pg_stat' substring, so they slipped past the self-exclusion
filter and inflated the exactly-gated 'calls' metric by a run-dependent
amount. The workload's own transaction control is still counted.

Also pin schemaname in the catalog lookups and fail fast at probe open
on a missing table, instead of dying with NoResultFound at the end of a
long run.
engine.dispose() only closes checked-in connections. A workload that still
holds a connection when the probe block exits never flushes that backend's
stats, so tup_ins/tup_upd/tup_del/wal_records silently under-report (or read
zero) while calls stays correct and looks plausible. Assert the pool has
zero checked-out connections on the exit path and raise RuntimeError naming
the count, matching the existing assert_owns_database standard of failing
loudly instead of reporting a quietly wrong number.

Also document the other footgun review found: constructing an AsyncEngine
inside the probe window inflates calls by 6 via SQLAlchemy's one-time
dialect-init queries, none of which match the pg_stat filter.
…and exact

The benchmark gate must assert structural, per-message quantities, not totals
that scale with wall-clock. Five measurement defects, all reproduced under load:

- probes: break `calls` down by SQL operation into `ProbeResult.calls_by_op`
  (a second aggregate grouped by the leading keyword, on the same AUTOCOMMIT
  connection, self-excluding via `pg_stat` in its text). The terminal DELETE is
  exactly 1/message on the happy path, so `calls_by_op['delete']` isolates a
  load-independent gate metric; the total `calls` stays informational.
- workload: pass min/max_fetch_interval=0.001 so drain is DB-bound, not
  sleep-bound (the seed emits no pg_notify, so the fetch loop otherwise slept
  its full max_fetch_interval and max_workers had zero throughput effect).
- workload: count distinct row identities (unique seed payload per row) for
  drain detection so a lease-expiry redelivery can't stop the broker early;
  assert count(*)==0 outside the probe block to catch a partial drain.
- workload: disable autovacuum on the bench table so a mid-window autovacuum
  can't reap dead tuples; dead_tup becomes an exact, deterministic garbage count.
- workload: silence the broker (logger=None) so per-message INFO logging stays
  out of the window; reuse probes._autocommit in _checkpoint; drop the no-op
  future=True from make_engine.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Gate raw structural totals (delete_calls + tuple counters exact, wal_records
within a 10% band), not the load-dependent total calls or FPI-noisy WAL bytes.
Adds pure-function unit tests that import benchmarks.* to prove the coverage
omit keeps the Postgres-only modules out of the 100% gate.
Adds a bench job that runs just bench-check via docker compose (not a
services: container, since GitHub Actions service containers can't take
command args and therefore can't set shared_preload_libraries for
pg_stat_statements).
The plan proposed gating total pg_stat_statements.calls on exact equality;
implementation falsified that (fetch-loop polling makes it load-dependent,
519 idle vs 894 loaded). Record the pivot to per-operation + tuple-counter
gating, correct the discovery numbers (dead_tup=2x messages, ~7 WAL
records/msg not 11), and document the force-flush probe and the descoped
publish_batch.
The bench job is the only CI job that builds the docker compose application
image, whose Dockerfile does COPY uv.lock + uv sync --frozen. uv.lock is
git-ignored, so a clean CI checkout lacks it and docker build fails on
COPY uv.lock. Generate the lock in the job first. Verified by building from
a clean git-archive checkout with and without the step. Also cap the job at
20 minutes so a pathological drain hang fails fast.
@lesnik512 lesnik512 merged commit 1b126d4 into main Jul 15, 2026
7 checks passed
@lesnik512 lesnik512 deleted the bench-harness-spec branch July 15, 2026 09:08
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