Skip to content

perf(executor): emit count(*) OVER() only when the count mode consumes it#67

Merged
milmazz merged 6 commits into
mainfrom
perf/executor-window-count
Jul 12, 2026
Merged

perf(executor): emit count(*) OVER() only when the count mode consumes it#67
milmazz merged 6 commits into
mainfrom
perf/executor-window-count

Conversation

@milmazz

@milmazz milmazz commented Jul 12, 2026

Copy link
Copy Markdown
Owner

Summary

Every read query used to carry count(*) OVER() AS _bier_full_count, forcing PostgreSQL to materialize the entire filtered set through a WindowAgg before LIMIT could apply — even though only Prefer: count=exact|estimated ever consume that value (Response.render derives page rows from the body and nulls the total for count=none, and count=planned uses a separate EXPLAIN).

This branch threads the request's count mode into the SQL builders and emits the window only when it is consumed:

  • Table reads (build/5): window iff count_mode in [:exact, :estimated]; otherwise full_count degrades to a cheap page-row count(*), preserving the frozen two-column [body, full_count] result contract.
  • RPC reads (build_function/6): window iff count_mode != :none (RPC's count_for/2 consumes it for every non-none mode).
  • Mutation representations (build_representation/4): never — only body is 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:

Shape Plan Execution
with window (before, count=none) Bitmap Heap Scan 5,000 rows → Sort → WindowAgg → Limit 8.3 ms
without window (after, count=none) Index Scan Backward on items_pkey, stops at 25 rows 0.095 ms

R1 (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=exact intentionally keeps the window (verified: WindowAgg present, 2.3 ms — unchanged behavior).

One observable output change (deliberate)

application/vnd.pgrst.plan responses now EXPLAIN the query shape the request's Prefer: count= mode would actually run (163262a, beyond the written plan): a plan request without Prefer: count= no longer shows a WindowAgg node, and one with count=exact does. 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 by test/bier/plan_count_test.exs. All other HTTP behavior (bodies, statuses, Content-Range) is byte-identical.

Testing

  • Full suite: 742 passed, 14 excluded, 0 failures (was 732 on main; +10 new tests in test/bier/query_executor_count_test.exs and test/bier/plan_count_test.exs). spec/** and test/support/** untouched.
  • Gates: format --check-formatted, compile --warnings-as-errors, credo --strict, docs --warnings-as-errors all pass. hex.audit fails on pre-existing dependency advisories (plug 1.20.2, postgrex 0.22.2) — this branch does not touch deps.
  • Live smoke against bier_bench: 25-row page correct and id-descending, Content-Range: 0-24/* without Prefer, 0-24/5000 with count=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 the fix/bench-http-load-stability branch) is left to a follow-up after merge.

Follow-ups (deferred by final review, non-blocking)

  • Bier.Plan.explain hardcodes the :json format arg, so plan-for-geojson EXPLAINs the JSON shape — pre-existing, worth its own issue.
  • Two ~5-line coverage additions next time the count test file is touched: RPC :planned keeps the window; an embed-path mutation representation.

🤖 Generated with Claude Code

milmazz and others added 6 commits July 12, 2026 11:54
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
@milmazz

milmazz commented Jul 12, 2026

Copy link
Copy Markdown
Owner Author

Synced with main (083414a): merged 98dd0cd (geo+json broadening / byte-parity restructure of the advanced read path) and 60009e0 (JWT role-claim-key). Conflicts were confined to lib/bier/query_executor.ex; resolution notes:

  • build_function is now arity 7 — both sides had added a 6th param (format on main, count_mode here); merged as (fn_def, ret_relation, args, plan, relations, format, count_mode \\\\ :none) with the RPC gate (full_count?: count_mode != :none) unchanged.
  • build_representation/5 keeps main's opts (:format) and this branch's unconditional full_count?: false.
  • The window-count gating was re-applied to main's new three-layer advanced shape (innerpaged → aggregate) via the same window_count_col/1 / full_count_col/1 helpers used by build_simple.

Re-verified after the merge: full suite 790 passed, 0 failures; mix precommit now green end-to-end (main's plug/postgrex bumps cleared the hex.audit advisories). Performance unaffected: R2 count=none EXPLAIN still plans a backward PK index scan with no WindowAgg (0.131 ms execution), count=exact intentionally keeps the window, and the live smoke shows p50 1.37 ms unloaded with byte-identical bodies and correct Content-Range in both modes.

@milmazz
milmazz merged commit 9fc5311 into main Jul 12, 2026
3 checks passed
@milmazz
milmazz deleted the perf/executor-window-count branch July 12, 2026 18:51
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>
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