Skip to content

perf: emit sargable per-state SQL for list queries - #162

Merged
bodymindarts merged 5 commits into
mainfrom
feat/sargable-list-queries
Jul 27, 2026
Merged

perf: emit sargable per-state SQL for list queries#162
bodymindarts merged 5 commits into
mainfrom
feat/sargable-list-queries

Conversation

@nicolasburtey

@nicolasburtey nicolasburtey commented Jul 24, 2026

Copy link
Copy Markdown
Member

Summary

Root fix for the non-sargable es-entity list queries found in the
sb-st7-2lps stress test (2 loans/s, 4h soak): every generated list query
wrapped optional filters in COALESCE(col = $k, $k IS NULL) and the cursor
predicate in COALESCE((col, id) < ($c, $i), $i IS NULL). Under generic
plans 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_disbursals the windowed mean exec time grew linearly with total
table 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 list whose filters are all-optional had
the same shape; disbursals is just the first table big enough to expose it.

What changed

Cursor specialization (list_by_fn.rs, shared by list_for_fn.rs and
list_for_filters_fn.rs): each generated list fn now dispatches at runtime
on the destructured cursor values and emits distinct static es_query!
literals per state:

  • Page 1 (no cursor): no cursor predicate at all — rides index ordering
    with early-exit LIMIT.
  • Cursor page: bare (col, id) {comp} ($c, $i) row comparison —
    sargable against composite indexes.
  • Nullable sort columns: dedicated NULL-cursor variants replicating the
    exact NULLS FIRST/LAST edge semantics previously encoded in the COALESCE
    fallbacks (col IS NOT NULL OR id > $i ASC; col IS NULL AND id < $i
    DESC; col IS NULL OR (col, id) < ($c, $i) for non-NULL cursors DESC).
  • nullable-annotated non-Option columns: NULL-ness of the cursor
    value 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-ness
combination — present filters compile to sargable col = $k (or
col IS NULL for optional columns filtering on None), absent filters
are 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 — Filters structs, list* signatures, cursor
types all stay the same. Consumers only need to regenerate their sqlx
offline cache.

Verification

  • New integration harness (tests/sargable_list_queries.rs +
    tests/entities/transfer.rs) mirroring lana's Disbursal repo shape
    (2 non-optional + 1 optional list_for filters, by(created_at) sort,
    nullable score sort column).
  • Plan shape: EXPLAIN with enable_seqscan = off shows
    Index Cond for the specialized page-1 and cursor-page queries, and no
    Index Cond for the legacy COALESCE catch-all (documents the
    before/after).
  • Correctness: reference-implementation test paginates every filter
    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.
  • All 127 pre-existing integration tests, 95 macro unit tests, mdbook
    tests pass; all feature combos (graphql, instrument,
    event-context, ...) compile.

Watch items (from the brief)

  • Codegen size: the matrix is (filter combos) x (cursor states) x
    (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 cache churn: consumers must re-run sqlx prepare.
  • Index coverage: sargable SQL only helps where indexes exist; lana
    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 static es_query! literals chosen at runtime from cursor presence and filter Some/None state, 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. Nullable Option sort columns add explicit NULL-cursor arms; nullable-annotated non-Option types 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 sargable col = $k or col IS NULL; inactive filters are omitted. Above four list_for columns, only no-filter, single-filter, and all-filter combinations are specialized; other combos keep the COALESCE fallback arm.

Book docs for list_for_filters describe the new matrix and cap. Integration coverage (transfer entity + 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.

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
nicolasburtey marked this pull request as ready for review July 25, 2026 11:37
@Nsandomeno
Nsandomeno requested a review from bodymindarts July 27, 2026 13:53
@bodymindarts

Copy link
Copy Markdown
Member

Taking this PR over

nicolasburtey and others added 2 commits July 27, 2026 13:34
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>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Fix All in Cursor

❌ 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.

Comment thread tests/sargable_list_queries.rs
Comment thread es-entity-macros/src/repo/list_for_filters_fn.rs Outdated
bodymindarts and others added 2 commits July 27, 2026 22:49
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>
@bodymindarts
bodymindarts merged commit b7c32b7 into main Jul 27, 2026
7 checks passed
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
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.

2 participants