Skip to content

feat(remote): offer the rewrite when no index helps - #236

Open
veksen wants to merge 1 commit into
mainfrom
feat-rewrite-improvements
Open

feat(remote): offer the rewrite when no index helps#236
veksen wants to merge 1 commit into
mainfrom
feat-rewrite-improvements

Conversation

@veksen

@veksen veksen commented Aug 14, 2026

Copy link
Copy Markdown
Member

Goal

A query whose only real fix is a rewrite reports no_improvement_found and stops there, because the analyzer derives index candidates and nothing else. That is Query-Doctor/Site#4013, which carries the measured evidence: 13 of one project's 73 queries match a rewrite rule and carry 97% of the run's cost, and the largest drops from 165,054,297.01 to 445,527.16.

This is the producer. The rewrite rules and the ranking already shipped in @query-doctor/core; nothing called them.

Blocked on core 0.29.0. Query-Doctor/Site#4015 adds costRewrites and indexCandidates to core, and a chore(core): release 0.29.0 PR publishes them. package.json here asks for ^0.29.0 and the lockfile is unchanged, so npm ci fails until both land.

What

Before: a CI run's queries carried no improvements array, whatever their shape.

After: a settled query carries every proven way to make it cheaper, ranked by measured saving — the index set and any rewrite, together. The state is unchanged, so the gate fails on what it failed on before. A query that reports no_improvement_found can now carry a rewrite beside it, which is the point.

How

Read src/remote/query-optimizer.ts first.

deriveImprovements is a module-level function beside the file's other pure derivations. It builds the index candidate from the result the optimizer already produced, derives the rewrites the query's shape allows, costs each against the same statistics, and ranks both kinds through rankImprovements.

Rewrites come from recent.query, the string the optimizer costed. RecentQuery.analyze runs before replaceLimit(50) and the PssRewriter, so analysis.rewrites describes a different string; costing those would compare two queries rather than two shapes.

The costing pass gets a flat budget from queryTimeoutMs. Reusing options.timeoutMs, which the retry ladder grows to 80s, would let one query hold the single worker for twice that. Spending only what the index search left over would starve the expensive queries a rewrite is the only fix for. Flat costs a first attempt 5s it did not spend before, and caps the worst case at the attempt's own budget plus 5s.

The deadline is armed only once a query yields a candidate, so a query matching no rule pays a parse and nothing else.

onOptimizeReady builds the optimization once and both emits and returns it. withOptimization is Object.assign(this, …), so a listener reads the object handed to the emit; the previous second literal would have given CI the improvements and left the live relay without them. resultToImprovementsAvailable was that second literal and is gone.

src/remote/optimization.ts and src/reporters/site-api.ts carry the field. Both use core's own Improvement type. Site's zod schema already accepts improvements on both settled states, so nothing changes there.

Tests

src/remote/rewrite-improvements.test.ts seeds the correlated aggregate EXISTS shape with the index that serves it already present, so the index search comes back empty and the query settles on no_improvement_found. It then asserts the optimization carries a HOIST_CORRELATED_EXISTS rewrite, graded unconditional, cheaper than the original by more than 90%. It fails on main.

The query goes through RecentQuery.analyze rather than the constructor, so the string costed is the one the pipeline produces after the LIMIT substitution and the pg_stat_statements rewrite — the divergence deriveImprovements exists to handle.

The fixture holds real rows. An empty one measured a base cost of 0.01, because stock Postgres sizes a relation from its own page count and ignores injected reltuples, and the rewrite was then correctly dropped for costing more.

npm run typecheck clean, npm test 446 passed across 45 files.

Follow-ups

Query-Doctor/Site#4014: withTimeout is Promise.race and cannot stop costRewrites, so an expired pass keeps planning outside _inflight. Pre-existing for optimizer.run through the same helper; this adds a second racer.

Not filed, for whoever next touches RecentQuery: rewrite derivation probably belongs there rather than in the optimizer. computeDisplayQuery already parses the exact post-substitution string and discards the AST, and rewrites is the same kind of derived property as tags, nudges and tableReferences, which the class already carries. That would also make "does this query admit a hoist" an in-process assertion instead of a container test.

The analyzer derived index candidates and nothing else, so a query whose only
real fix is a rewrite reported no_improvement_found and stopped there. That
state means the index search came back empty, which is narrower than it sounds.

deriveImprovements now derives the rewrites the query's shape allows, plans each
one under the same statistics the original was planned under, and ranks both
kinds on the one number a reader acts on. The improvements ride beside the state
rather than changing it, so the CI gate still fails on what it failed on before.

Rewrites are derived from the query the optimizer costed, not from the earlier
analysis, which ran before the LIMIT substitution and the pg_stat_statements
rewrite.

The costing pass gets its own flat budget rather than the query's. Reusing
options.timeoutMs would double how long one query can hold the single worker,
and spending what the index search left over would starve the expensive queries
a rewrite is the only fix for.

onOptimizeReady now builds the optimization once. withOptimization mutates the
query in place, so an event listener reads the object handed to the emit, and
the second literal would have left the live relay without the improvements CI
receives.

Needs @query-doctor/core 0.29.0, which exports costRewrites and indexCandidates.

Co-Authored-By: Claude <noreply@anthropic.com>
@veksen
veksen force-pushed the feat-rewrite-improvements branch from dc5b5f3 to 3411a30 Compare August 14, 2026 11:16
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