perf: emit sargable per-state SQL for list queries - #162
Merged
Conversation
The generated list queries wrapped every optional filter in COALESCE(col = $k, $k IS NULL) and every cursor predicate in COALESCE((col, id) < ($c, $i), $i IS NULL). A generic plan must serve both NULL and non-NULL parameters through those expressions, so the predicates never become index quals and every list call full-scans the entity table (measured on lana core_disbursals under stress: mean exec time growing linearly with total table size, ~300 buffer blocks hit per call). Replace the catch-alls with a runtime-dispatched variant matrix of static es_query! literals, keeping every query compile-time checked: - Cursor specialization (list_by, list_for, list_for_filters): page 1 emits no cursor predicate at all (rides index ordering); cursor pages emit a bare (col, id) row comparison. Nullable sort columns get dedicated NULL-cursor variants replicating the documented NULLS FIRST/LAST edge semantics; nullable-annotated non-Option columns keep the legacy predicate when a cursor is present (NULL-ness is invisible to Rust) but still get the bare page-1 form. - Filter-combination specialization (list_for_filters): one query per filter Some-ness combination; present filters compile to sargable col = $k (or col IS NULL for optional columns filtering on None). Capped above 4 filter columns: only no-filter, single-filter, and all-filter combinations specialize, the rest fall back to the legacy COALESCE query, which is also retained as the wildcard arm. Public API (Filters structs, list* signatures, cursor types) is unchanged; consumers only need to regenerate their sqlx offline cache. Verification: new integration test entity mirrors the lana Disbursal shape (2 non-optional + 1 optional list_for filters, by(created_at) sort, nullable score sort). EXPLAIN with enable_seqscan=off shows Index Cond for the specialized queries and none for the legacy catch-all; a reference-implementation test paginates every filter combination x sort x direction and asserts exact row/order equality, including NULL-cursor transitions in both directions.
nicolasburtey
marked this pull request as ready for review
July 25, 2026 11:37
Member
|
Taking this PR over |
SET enable_seqscan is session-scoped, so running it through the pool did not guarantee the EXPLAINs saw it. Acquire one dedicated connection for ANALYZE + SET + EXPLAIN, and RESET before returning it to the pool.
…-fn tests - Rename CursorState::AfterLegacy to AfterMaybeNull: the variant is not a compatibility shim - it is required for correctness when a nullable- annotated non-Option sort column's NULL-ness is invisible to Rust. Replace 'legacy' terminology with fallback/catch-all across the macro crate and book docs. - Restore readable formatting on the expected-token unit test assertions in list_by_fn.rs, list_for_fn.rs and list_for_filters_fn.rs (token streams unchanged; assertions compare to_string()). - Drop the EXPLAIN plan-shape test: it asserted against hand-transcribed SQL lookalikes rather than macro-generated queries, so it could not catch codegen regressions. Row-level correctness remains covered by the reference-pagination tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 4a5677c. Configure here.
The test seeded 8 rows but paged with first: 13, so on a clean database everything fit on page 1 and the specialized After/AfterNull cursor variants never executed - coverage depended on foreign rows left in the shared table by other tests. Page with first: 3 so the seeded rows force at least 3 pages in both directions (ASC crosses the NULL -> value boundary, DESC the value -> NULL boundary), and assert the page count so the test can never silently degrade to a single page again. Reported-by: Cursor Bugbot Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
For entities at or below the specialization cap every filter Some-ness combination x cursor state has an explicit match arm, making the wildcard fallback arm unreachable dead code. Track during arm generation whether any combination was skipped and only emit the fallback arm (and its two catch-all COALESCE queries) when one was - trimming two dead queries per generated fn from the binary and the sqlx offline cache. Note: the unreachable arm never triggered unreachable_patterns - rustc suppresses that lint for external proc-macro expansions - so this is dead-code hygiene, not a build fix. The fallback param-layout test moves to a 5-column entity where the fallback is still emitted, and the specialization test now asserts no COALESCE is emitted for fully-specialized entities. Reported-by: Cursor Bugbot Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
nicolasburtey
added a commit
that referenced
this pull request
Jul 30, 2026
The sargable per-state/per-combination SQL matrix introduced in #162 emits one sqlx::query! per filter combination × cursor state × direction. For an entity with N list_for columns that grows as 2^N (3^N with optional columns). Across many repos this exploded lana-bank's .sqlx offline cache from 1319 → 3373 query descriptors (+2054), adding ~16 min of release LLVM codegen to every CI build (build-rc-release went from ~37 min to ~54 min at the 0.11.9 bump). Make the multi-filter specialization matrix opt-in via a new `#[es_repo(..., sargable_filters)]` attribute, defaulting to off. With it off, list_for_filters emits only the existing catch-all COALESCE query (pre-#162 behavior); single-filter (list_for_{col}_by_{sort}) and no-filter (list_by_{sort}) queries stay sargable and cheap (O(N)), so they are generated regardless of the flag. Only repos whose multi-filter list queries are hot paths need to opt in. - options: add `sargable_filters: Option<bool>` (default false) + accessor - list_for_filters_fn: short-circuit is_specialized_combo when disabled - tests: existing specialization tests opt in; add a test proving default-off emits the catch-all only (fewer es_query calls, COALESCE present) while opt-in emits the full specialized matrix - book: document the attribute and the compile-time tradeoff
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
Root fix for the non-sargable es-entity list queries found in the
sb-st7-2lpsstress test (2 loans/s, 4h soak): every generated list querywrapped optional filters in
COALESCE(col = $k, $k IS NULL)and the cursorpredicate in
COALESCE((col, id) < ($c, $i), $i IS NULL). Under genericplans those predicates can never become index quals, so every call
full-scans the entity table (and the events table on the join side). On
core_disbursalsthe windowed mean exec time grew linearly with totaltable size (2.1 ms @ 1.8k rows → 6.1 ms @ 17.4k rows), ~300 buffer blocks
hit per call, called ~2.5x per loan from the admin GraphQL
creditFacility { disbursals }resolver.This is a class problem — every
listwhose filters are all-optional hadthe same shape; disbursals is just the first table big enough to expose it.
What changed
Cursor specialization (
list_by_fn.rs, shared bylist_for_fn.rsandlist_for_filters_fn.rs): each generated list fn now dispatches at runtimeon the destructured cursor values and emits distinct static
es_query!literals per state:
with early-exit
LIMIT.(col, id) {comp} ($c, $i)row comparison —sargable against composite indexes.
exact NULLS FIRST/LAST edge semantics previously encoded in the COALESCE
fallbacks (
col IS NOT NULL OR id > $iASC;col IS NULL AND id < $iDESC;
col IS NULL OR (col, id) < ($c, $i)for non-NULL cursors DESC).nullable-annotated non-Optioncolumns: NULL-ness of the cursorvalue is invisible to Rust, so the cursor-present variant keeps the
legacy predicate; page 1 still gets the bare form.
Filter-combination specialization (
list_for_filters_fn.rs):list_for_filters_by_*now emits one static query per filter Some-nesscombination — present filters compile to sargable
col = $k(orcol IS NULLfor optional columns filtering onNone), absent filtersare omitted entirely. Capped above 4 filter columns (only no-filter /
single-filter / all-filter combinations specialize); the legacy COALESCE
query is retained as the wildcard fallback arm, so correctness is
preserved for every combination.
Public API is unchanged —
Filtersstructs,list*signatures, cursortypes all stay the same. Consumers only need to regenerate their sqlx
offline cache.
Verification
tests/sargable_list_queries.rs+tests/entities/transfer.rs) mirroring lana'sDisbursalrepo shape(2 non-optional + 1 optional
list_forfilters,by(created_at)sort,nullable
scoresort column).EXPLAINwithenable_seqscan = offshowsIndex Condfor the specialized page-1 and cursor-page queries, and noIndex Condfor the legacy COALESCE catch-all (documents thebefore/after).
combination x sort x direction and asserts exact row/order equality
against in-Rust filtering/sorting; NULL-cursor transitions on the
nullable sort column are exercised in both directions.
tests pass; all feature combos (
graphql,instrument,event-context, ...) compile.Watch items (from the brief)
(directions) per sort column; the >4-column cap bounds this. Worth
eyeballing macro-expansion compile time on lana's biggest repos
(
CreditFacility) during adoption.sqlx prepare.needs the
core_disbursals(credit_facility_id, created_at DESC, id DESC)composite index (separate lana PR).
Note
Medium Risk
Changes SQL for every generated list path while preserving the public API; correctness hinges on nullable sort and filter edge cases, though new integration tests reduce regression risk. Consumers must regenerate sqlx offline caches and rely on appropriate indexes for the performance win.
Overview
Generated list repo methods no longer rely on a single
COALESCE(..., $ IS NULL)query shape. Codegen now emits statices_query!literals chosen at runtime from cursor presence and filterSome/Nonestate, so predicates can become index quals instead of forcing generic plans and table scans.Cursor pagination (
list_by_*,list_for_*): first page drops the cursor predicate; later pages use bare(col, id)comparisons. NullableOptionsort columns add explicit NULL-cursor arms;nullable-annotated non-Optiontypes still use the legacy COALESCE cursor form when Rust cannot distinguish SQL NULL.Multi-filter lists (
list_for_filters_by_*): each active filter combination gets its own query with sargablecol = $korcol IS NULL; inactive filters are omitted. Above fourlist_forcolumns, only no-filter, single-filter, and all-filter combinations are specialized; other combos keep the COALESCE fallback arm.Book docs for
list_for_filtersdescribe the new matrix and cap. Integration coverage (transferentity +sargable_list_queries) paginates filter × sort × direction against a Rust reference and exercises NULL sort pagination.Reviewed by Cursor Bugbot for commit 1086a35. Bugbot is set up for automated code reviews on this repo. Configure here.