Skip to content

fix: make the benchmarks measure what they claim, before going public - #44

Merged
jonaas-dev merged 5 commits into
mainfrom
fix/benchmark-methodology
Sep 6, 2026
Merged

fix: make the benchmarks measure what they claim, before going public#44
jonaas-dev merged 5 commits into
mainfrom
fix/benchmark-methodology

Conversation

@jonaas-dev

Copy link
Copy Markdown
Owner

Fixes everything found in the pre-publication audit (~/Repos/analysis/projects/sql-performance/audit-20260906.md). Every finding was reproduced against a real PostgreSQL 17 instance, and every fix has a test that was watched failing first.

Why this is one PR

It is larger than the usual 400-line budget because the findings are entangled: the pagination and select_star bugs cannot be fixed without first wiring up the dataset size, and none of it can be verified without the integration suite. Split into 5 reviewable commits.

Blocking issues fixed

Before After
pagination OFFSET 500000 fixed, page size varied. On small/medium both queries returned 0 rows and still drew a chart. Offset varies, page size fixed. Measured: OFFSET climbs 0.2 → 5.0 ms with depth, keyset flat at ~0.2 ms.
index_usage Both EXPLAIN runs happened after CREATE INDEX — byte-identical plans, cost=4.67..127.18 both. The "no index" label was false. Unindexed plan captured before the index exists. Now Seq Scan (cost=0.00..352.00) vs Bitmap Heap Scan (cost=5.55..225.84).
select_star LIMITs swept 1M→100k regardless of table size; on 10k rows every point returned the same 10,000 rows. Speedup read 12.0x → 1.9x → 3.6x → 4.7x, pure noise. Limits derived from the real row count. Speedup now a stable ~5.2x.
size selector Validated, stored, rendered — and never passed to the seeder. init.sql hardcoded 1M rows, sql/seed.py was called by nothing, DB_SEED_SIZE was unused. Wired end to end. Changing size truncates and reseeds.
methodology One cold run per point, slow query always first — bias pointed at the desired conclusion. Discarded warm-up + median of 5, shared by all four benchmarks.

Also fixed

  • Path traversal: GET /results/.. read a metadata.json outside results/ and returned it.
  • DROP TABLE orders: would destroy a user table of that name. Now sqlperf_orders.
  • Seed off-by-one: (random() * 10)::INT + 1 yields 11 on a 10-element array because the cast rounds. Measured 4.9% of rows with NULL name/surname/city/country.
  • Tautological test: assert has_error or has_results where has_results checked for "Generate", the button label present on every page. It could never fail.
  • Ignored DB config: routes called get_db_connection() with no arguments, discarding the app's configuration.
  • Result collisions: two runs in the same second overwrote each other.
  • Hung workers: a blocked TRUNCATE waits forever; a lock_timeout now bounds it.
  • Dead code removed: unused SECRET_KEY and its hardcoded default, TMP_DIR, START, STEP. required_tables is now enforced rather than decorative.

Verification

  • 55 tests (was 36), 93% coverage (was 66%, with the benchmark run() methods at 0%).
  • New integration suite guards the measurement contract: every benchmark must fetch a non-empty result set, data points must stay inside the dataset, the two index plans must differ.
  • ruff check . clean.
  • Full docker compose stack exercised end to end: all four benchmarks return 200 with charts and no errors.
  • CI added — .github/ was empty, so nothing verified any of this on a PR.

Note

The landing and history screenshots still show the previous UI (the size selector labels changed) and are worth retaking before publishing. The four benchmark images were regenerated from the corrected code.

🤖 Generated with Claude Code

https://claude.ai/code/session_014abw4B6YUf54giaEyPQpbo

jonaas-dev and others added 5 commits September 6, 2026 15:59
Three of the four benchmarks were reporting numbers that did not support
their own thesis. Verified against a real PostgreSQL 17 instance.

- pagination held OFFSET at 500000 and varied the page size instead, so on
  the small and medium datasets both queries returned zero rows while the
  chart still rendered. It now varies the offset with a fixed page size.
- index_usage captured both EXPLAIN plans after CREATE INDEX, making them
  byte-identical and the "no index" label false. The unindexed plan is now
  captured before the index exists.
- select_star swept LIMITs from 1M down regardless of table size, so every
  data point returned the whole table. Limits now derive from the real row
  count, and rows_fetched is shown in the comparison table.
- join_vs_subquery dropped and recreated a table called `orders`, which
  would destroy a user table of that name. Renamed to sqlperf_orders.

All four now share measure(), which discards a warm-up run and reports the
median of five. Previously each point was a single cold run with the slow
query always going first, biasing every result toward its own conclusion.

required_tables is now enforced by check_requirements() instead of being a
decorative attribute, and BenchmarkNotApplicable replaces silently charting
an empty result set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014abw4B6YUf54giaEyPQpbo
The size parameter was validated, stored in metadata and rendered in the
UI, but never reached the seeder: init.sql hardcoded 1M rows in a PL/pgSQL
loop, sql/seed.py was never invoked by anything, and DB_SEED_SIZE was read
into Config and unused. Every stored result recorded a size that had never
been applied.

- init.sql is now schema only; seeding belongs to sql/seed.py.
- seed() is set-based (INSERT ... SELECT generate_series) instead of a
  row-by-row loop, which takes 1M rows from minutes to seconds and makes
  reseeding viable from a web request.
- seed() is idempotent on the exact row count, so switching size reseeds in
  both directions.
- A lock_timeout guards the TRUNCATE: a concurrent reader would otherwise
  block it forever, and a client-side timeout cannot interrupt libpq waiting
  on the socket, so the worker would hang.

Also fixes an off-by-one in the old init.sql array indexing: casting a float
to INT in PostgreSQL rounds rather than truncates, so
`(random() * 10)::INT + 1` produced index 11 on a 10-element array. Measured
over 100k samples, 4.9% of rows got NULL for name, surname, city and country.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014abw4B6YUf54giaEyPQpbo
- load_result() built a filesystem path straight from the URL segment.
  `GET /results/..` read a metadata.json outside results/ and returned its
  contents. Result ids are now validated against the results directory.
- save_result() used a second-precision timestamp with exist_ok=True, so two
  runs in the same second silently overwrote each other.
- routes called get_db_connection() with no arguments, so the app's
  configured database was ignored and Config() was rebuilt from the
  environment. The app config is now threaded through.
- test_generate_route_without_db asserted `has_error or has_results`, where
  has_results checked for "Generate" — the button label, present on every
  page including the error path. The assertion could never fail. It now
  asserts the generic error is shown and that no internals leak.

Drops the unused SECRET_KEY (no sessions or flash messages) and its
hardcoded default, plus the dead TMP_DIR, START and STEP constants.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014abw4B6YUf54giaEyPQpbo
.github/ existed but was empty: nothing verified the test suite on a PR, and
the "tests passing" badge was a hand-written image. CI now runs ruff and
pytest against a real PostgreSQL service, so the integration tests that
guard the measurement contract cannot silently skip.

- Coverage now includes benchmarks/ and sql/, which were excluded while the
  benchmark run() methods sat at 0%. Gate raised back from 50% to 70%
  (currently 93%).
- docker-compose: dedicated seed service gated on db health, app gated on
  seed completion, no bind mount of the source tree (which put the real .env
  inside the container), app bound to 127.0.0.1, obsolete version key
  dropped.
- .dockerignore no longer ships results/, .github/ or test caches.
- Adds .python-version and requires-python; the badge said 3.11+, the
  Dockerfile 3.12 and the local venv 3.14.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014abw4B6YUf54giaEyPQpbo
The README documented features that did not exist and made claims the code
did not support:

- A dataset size table and DB_SEED_SIZE variable that were never wired up.
- "SELECT * transfers 16x more data" — 16 columns versus 3 is not 16x of
  anything; the measured speedup is ~5x.
- "Red line: without index (Seq Scan). Green line: with B-tree index (Index
  Scan)" while both stored plans were the same Seq Scan.
- "OFFSET degrades linearly with page number" next to a chart whose x-axis
  was page size.
- Test count given as 33 in one section and 36 in another.
- A plugin example passing rows_fetched as an int where the dataclass wants
  a list, filtering on cities absent from the seed data, and documenting
  required_tables as required when nothing read it.

Adds a section describing the measurement method and its limits, and an
explicit warning that the tool truncates users and creates and drops its own
tables. Benchmark images regenerated from the corrected code against 100k
rows.

Adds CONTRIBUTING.md and SECURITY.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014abw4B6YUf54giaEyPQpbo
@jonaas-dev
jonaas-dev merged commit fd29606 into main Sep 6, 2026
1 check passed
@jonaas-dev
jonaas-dev deleted the fix/benchmark-methodology branch September 6, 2026 14:05
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