Skip to content

fix(indexer): give the planner real distinct counts for provider snapshot join keys - #3836

Merged
baktun14 merged 1 commit into
mainfrom
fix/indexer-provider-snapshot-join-key-statistics
Sep 13, 2026
Merged

baktun14 merged 1 commit into
mainfrom
fix/indexer-provider-snapshot-join-key-statistics

Conversation

@baktun14

@baktun14 baktun14 commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Why

#3811 bounded /v1/gpu-breakdown to a date window but never measured the new query on production-size data. Measured now on a copy of the production database (715 GB, snapshots through 2026-03-12): the 30-day default is fast, but from about 120 days up the planner abandons the index probes and hashes providerSnapshotNode and providerSnapshotNodeGPU whole, which costs 30x more than the nested-loop plan it passed over.

The reason is statistics, not the SQL. ANALYZE samples 30k rows, and on a high-cardinality FK column that sample lands 10 to 20 times below the truth: providerSnapshotNode.snapshotId is recorded as 171,288 distinct values against 3.49M real, and providerSnapshotNodeGPU.snapshotNodeId as 405,255 against 4.37M. The planner therefore expects 27 and 63 rows per index probe where 1 to 6 turn up, and concludes that hashing 38M rows beats 14k probes. No change to the query shape can fix that, because the estimate is what drives the choice.

Prod runs the same default_statistics_target, so the same plans are expected there.

What

n_distinct overrides on the two join keys, plus extended statistics on lower(vendor) and lower(name) so the case-insensitive filter is no longer estimated at about four rows (which is what made the planner scan the GPU table first). The overrides are negative, which Postgres reads as a fraction of the table rather than a fixed count, so they stay correct as the tables grow: each snapshot has around 3.7 nodes and each GPU node around 5.8 GPUs.

Measured on the production copy, same six windows before and after, two runs each, sessions pinned to UTC as Sequelize pins them:

Window Filter Before (warm) After (warm) Plan after
30 d none 21 ms 19 ms index nested loops
30 d nvidia/h100 27 ms 21 ms index nested loops
90 d none 79 ms 56 ms index nested loops
180 d none 4801 ms 169 ms index nested loops
366 d none 8179 ms 224 ms index nested loops
366 d nvidia/h100 7174 ms 237 ms index nested loops

No sequential scan of either table survives at any window, and every result set hashes identically before and after, which it must: statistics change the plan, not the rows.

The CREATE STATISTICS is guarded on server_version_num >= 140000 because expression statistics need Postgres 14. Prod runs 14.22 and the CI database image is 14.18, so the branch executes in both; the guard only keeps the file applicable to an older sandbox, testnet or local database. ALTER COLUMN SET (...) and CREATE STATISTICS take only SHARE UPDATE EXCLUSIVE, so the migration does not block writes. The two ANALYZEs are what make the overrides take effect; I left out an ANALYZE of providerSnapshot that was in the investigation script, since autovacuum keeps that table current in prod and a one-shot analyze in a migration buys nothing.

A note on what CI checked

validate (api) is skipped on this PR, because the path filter only triggers it on apps/api/** and the api's workspace dependencies, and the indexer's drizzle folder is neither. The api functional suite is the only thing in CI that applies these migrations to a real database, so nothing here exercised the migration. That gap predates this PR and affects every indexer migration; widening the api filter to cover apps/indexer/drizzle/** would be its own change.

I ran it locally instead, twice: apps/api functional gpu specs against a freshly migrated database (14 passing), and drizzle-kit's migrator against a scratch database, reading back n_distinct=-0.27 and n_distinct=-0.17 on the two columns and stxkind {m,e} on the statistics object, which confirms both the MCV and the per-expression statistics were built.

Verification still outstanding

Local numbers on the production copy are roughly 3 to 4x faster than prod for the same query, so the 366-day window should land near 1 s rather than the 15 to 40 s it would take today. That needs confirming on console-api-mainnet-staging.akash.network once this deploys, against the Loki p50/p95 for the route. I will record it here.

…shot join keys

The gpu breakdown stops probing the indexes on "providerSnapshotNode"
and "providerSnapshotNodeGPU" and hashes both tables whole once its
window passes roughly 120 days, because the 30k row sample ANALYZE
takes puts the distinct counts of the two join keys 10 to 20 times
below the real ones. On a copy of the production database a 366 day
window took 8.2 s warm before the overrides and 224 ms after, for
byte identical results; 180 days goes from 4.8 s to 169 ms and the
30 day default is unchanged. A negative n_distinct records the ratio
instead of a count, so the values stay right as the tables grow.
The expression statistics do the same job for the case insensitive
vendor and model filter, which was estimated at about four rows and
made the planner scan the GPU table first.
@coderabbitai

coderabbitai Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

On-demand reviews are free for the next 13 days. After that, they cost $0.25 per reviewed file.

Or wait 52 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used all 2 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Essentials

Run ID: b2da714d-cca8-444f-9c5d-e35a328b63d7

📥 Commits

Reviewing files that changed from the base of the PR and between 93ca389 and b2479fd.

📒 Files selected for processing (2)
  • apps/indexer/drizzle/0011_fix_provider_snapshot_join_key_statistics.sql
  • apps/indexer/drizzle/meta/_journal.json

Comment @coderabbitai help to get the list of available commands.

@claude claude 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.

I reviewed this PR and didn't find any bugs. Because it's a production database migration that manually overrides planner statistics (n_distinct) and adds version-gated DDL, and the author's own description flags "verification still outstanding" on the staging query performance, a human look would still be worthwhile.

What was reviewed: the n_distinct override values and their negative-fraction semantics, the version-guarded DO block for CREATE STATISTICS on Postgres >= 14, the trailing ANALYZE statements, and the _journal.json registration. Two candidate issues were also examined and ruled out: ANALYZE running inside drizzle-kit's per-migration transaction wrapper, and the CREATE STATISTICS ... ON lower(vendor), lower(name) expression-list syntax.

Extended reasoning...

Overview

The diff adds a single SQL migration for apps/indexer (0011_fix_provider_snapshot_join_key_statistics.sql) plus its _journal.json registration entry. The migration: (1) sets n_distinct storage parameters on two FK columns to correct the planner's cardinality estimate for a join used by /v1/gpu-breakdown; (2) conditionally (Postgres >= 14 only, via a DO $$ ... $$ block checking server_version_num) creates MCV extended statistics on lower(vendor)/lower(name); (3) runs ANALYZE on both affected tables so the new settings take effect. No application source or test files are touched.

Security risks

None identified. This is a DDL-only migration adjusting planner statistics metadata; it does not touch access control, input handling, or expose data. The EXECUTE inside the DO block is a static string with no user input, so no injection surface.

Level of scrutiny

This warrants a higher-than-default level of scrutiny despite being "just SQL": it runs against a 715 GB production table set, changes planner behavior for a live query path, and the author explicitly states in the PR description that post-deploy verification against staging Loki metrics is still outstanding — i.e., the author themselves hasn't fully closed the loop. Migrations are also inherently harder to walk back than application code (columns/indices already built on assumptions baked in), so correctness of syntax and transaction semantics matters more here than in typical PRs.

Other factors

Two candidate correctness concerns were raised and then ruled out by the automated investigation: whether ANALYZE can run inside the implicit transaction that drizzle-kit migrate wraps around each migration file, and whether CREATE STATISTICS ... ON lower(vendor), lower(name) is valid syntax (versus needing each expression independently parenthesized, as CREATE STATISTICS requires unlike CREATE INDEX). I was unable to independently execute Postgres or fetch drizzle-orm/drizzle-kit source in this sandbox (network and package-manager access are blocked here) to re-verify those two points myself, so I'm relying on the prior investigation's conclusion that they are not bugs. Given that reliance, plus the explicit "verification still outstanding" note from the author and the production-scale blast radius of a statistics-altering migration, I'm deferring rather than approving so a human can do a final sanity check before this merges.

@baktun14
baktun14 added this pull request to the merge queue Sep 13, 2026
Merged via the queue into main with commit 9a564fa Sep 13, 2026
50 checks passed
@baktun14
baktun14 deleted the fix/indexer-provider-snapshot-join-key-statistics branch September 13, 2026 21:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant