perf(executor): emit count(*) OVER() only when the count mode consumes it#67
Merged
Conversation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The window count forces the whole filtered set through a WindowAgg before LIMIT applies (R2 bench: 8.3ms vs 0.3ms for a 25-row page over a 5k-row category). Prefer: count=none (the default) and count=planned never consume it: Response derives page rows from the body and planned uses EXPLAIN. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The plan media type must show the window count exactly when the real query would carry it (Prefer: count=exact/estimated). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…r-window-count # Conflicts: # lib/bier/query_executor.ex
Owner
Author
|
Synced with main (
Re-verified after the merge: full suite 790 passed, 0 failures; |
milmazz
added a commit
that referenced
this pull request
Jul 13, 2026
…67 rerun report The 0.4 read fraction is only sustainable against a cold-probed ceiling: over the ~40-min run the rig drifts and PostgREST queue-spiraled in round 3 at the same arrival rate (p99 74ms -> 556ms -> 2.7s). 0.3 keeps the late-run effective fraction at or below the cold 0.4 knee. Also pin k6's name tag to URL templates so id-randomizing scenarios stop minting >100k time series per stage. REPORT.md regenerated post-#67 (3 rounds, clean): R2 flips from 0.80x to 3.85x throughput; Bier now leads max throughput in all four scenarios. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
milmazz
added a commit
that referenced
this pull request
Jul 13, 2026
…he bench harness Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
milmazz
added a commit
that referenced
this pull request
Jul 13, 2026
* feat(bench/http): Bier vs PostgREST HTTP benchmark harness k6-driven native macOS benchmark (run.sh) comparing Bier and PostgREST v14.12 across read/write scenarios, with parity checks, closed-loop ceiling calibration, and open-loop latency measurement; plus the design spec, plan, and generated REPORT.md. Two stability fixes over the initial harness: - run.sh raises `ulimit -n` to 65536 before launching either server; the open-loop latency burst otherwise hit macOS's 256-fd default and crashed the Bier BEAM with :emfile. - Split the arrival-rate fraction per scenario class (reads 0.7/0.4, writes 0.3/0.2): the closed-loop ceiling over-predicts open-loop write throughput, driving PostgREST past its knee and tripping the dropped-iterations guard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(bench/http): make the harness complete a full run end-to-end Four independent fixes, each verified by re-running the harness; together they produced the first fully completed run (1 round, all 8 latency stages, 0 dropped iterations, 0 failed requests): - run.sh: bounded server teardown. stop_server sent one SIGTERM and waited unbounded — an orphaned PostgREST once hung a run for ~5h. Now SIGTERM -> 10s liveness poll -> SIGKILL -> reap any orphaned port listener; the EXIT trap sweeps both bench ports. - scenarios.js: VU burst headroom (preAllocatedVUs 1000 -> 2000, maxVUs 1000 -> 8000) so a server stall (PostgREST's GHC stop-the-world GC) is measured as open-loop queueing tail latency instead of shedding load and voiding the stage. - run.sh: ephemeral-port drain guard before each open-loop stage. k6's arrival-rate executors round-robin iterations across the whole VU pool, opening ~2x preAllocatedVUs keep-alive connections per stage regardless of concurrency; on exit they all sit in client-side TIME_WAIT (30s), and back-to-back short stages exhausted macOS's 16,384-port ephemeral range (EADDRNOTAVAIL at the 5th consecutive stage). Wait for TIME_WAIT toward the bench ports to drop below 1000 before each latency stage. - run.sh: full-run RATE_FRACTION_READ 0.7 -> 0.4, calibrated by probing PostgREST's open-loop knee (r1, 60s windows): 0.5-0.7 of the closed-loop ceiling queue-spiral into its 10s pool-acquisition timeout 100% of the time; 0.4 sustains with zero drops while still capturing GC pauses as p90/p99 tail. WARMUP/DURATION/ROUNDS/VUS are now env-overridable around the per-mode defaults (e.g. ROUNDS=1 for a single-round full run). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ZEyhWcdyTwk4e7M2qQrPo * first complete run report * ignore Claude worktrees * fix(bench): recalibrate read fraction to 0.3 + pin k6 name tags; post-#67 rerun report The 0.4 read fraction is only sustainable against a cold-probed ceiling: over the ~40-min run the rig drifts and PostgREST queue-spiraled in round 3 at the same arrival rate (p99 74ms -> 556ms -> 2.7s). 0.3 keeps the late-run effective fraction at or below the cold 0.4 knee. Also pin k6's name tag to URL templates so id-randomizing scenarios stop minting >100k time series per stage. REPORT.md regenerated post-#67 (3 rounds, clean): R2 flips from 0.80x to 3.85x throughput; Bier now leads max throughput in all four scenarios. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bench): disable autovacuum on bench.events; run 4 report confirms tight M2 tails ~80k updates per M2 window tripped autovacuum every write stage; when the pass landed inside a measurement window it inflated whichever server was being measured (p99 5ms -> ~50-134ms, p50/p90 flat) — run 2 hit Bier, run 3 hit PostgREST. TRUNCATE + post-reset ANALYZE already restore identical state, so autovacuum only added nondeterminism. Run 4 with the fix: M2 p99 tight across all rounds for both servers (Bier 2.38ms 2.36-3.47, PostgREST 6.22ms 5.90-12.19). Throughput and p50/p90 reproduce runs 2-4; read-side p99 remains rig-noisy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: add README Benchmarks section + changelog entries for #67 and the bench harness Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Every read query used to carry
count(*) OVER() AS _bier_full_count, forcing PostgreSQL to materialize the entire filtered set through a WindowAgg beforeLIMITcould apply — even though onlyPrefer: count=exact|estimatedever consume that value (Response.renderderives page rows from the body and nulls the total forcount=none, andcount=planneduses a separate EXPLAIN).This branch threads the request's count mode into the SQL builders and emits the window only when it is consumed:
build/5): window iffcount_mode in [:exact, :estimated]; otherwisefull_countdegrades to a cheap page-rowcount(*), preserving the frozen two-column[body, full_count]result contract.build_function/6): window iffcount_mode != :none(RPC'scount_for/2consumes it for every non-none mode).build_representation/4): never — onlybodyis selected out of that subquery; the mutated-row count comes from the(SELECT count(*) FROM pgrst_source)sibling.Why
The HTTP benchmark's R2 scenario (
GET /items?category=eq.<cat>&order=id.desc&limit=25, 5k rows per category) was the only scenario where Bier lost to PostgREST (p50 2.44 ms vs 0.85 ms, 0.80x max throughput).EXPLAIN (ANALYZE)on the bench DB:count=none)count=none)items_pkey, stops at 25 rowsR1 (single row by PK) showed parity only because its filtered set is 1 row; writes never used this path — matching the benchmark report exactly.
count=exactintentionally keeps the window (verified: WindowAgg present, 2.3 ms — unchanged behavior).One observable output change (deliberate)
application/vnd.pgrst.planresponses now EXPLAIN the query shape the request'sPrefer: count=mode would actually run (163262a, beyond the written plan): a plan request withoutPrefer: count=no longer shows a WindowAgg node, and one withcount=exactdoes. Previously the endpoint always showed the windowed shape regardless of the request. Conformance cases 1625–1628 assert status/headers only and stay green; the new behavior is pinned bytest/bier/plan_count_test.exs. All other HTTP behavior (bodies, statuses,Content-Range) is byte-identical.Testing
test/bier/query_executor_count_test.exsandtest/bier/plan_count_test.exs).spec/**andtest/support/**untouched.format --check-formatted,compile --warnings-as-errors,credo --strict,docs --warnings-as-errorsall pass.hex.auditfails on pre-existing dependency advisories (plug 1.20.2, postgrex 0.22.2) — this branch does not touch deps.bier_bench: 25-row page correct and id-descending,Content-Range: 0-24/*without Prefer,0-24/5000withcount=exact, unloaded p50 ≈ 1.4 ms (curl overhead dominated; the DB-side win is the EXPLAIN above).The citable 3-round benchmark rerun (
bench/http/run.sh, lives on thefix/bench-http-load-stabilitybranch) is left to a follow-up after merge.Follow-ups (deferred by final review, non-blocking)
Bier.Plan.explainhardcodes the:jsonformat arg, so plan-for-geojson EXPLAINs the JSON shape — pre-existing, worth its own issue.:plannedkeeps the window; an embed-path mutation representation.🤖 Generated with Claude Code