perf(indexes): make mostly-NULL secondary indexes partial - #19
Open
iemejia wants to merge 1 commit into
Open
Conversation
Why --- Three secondary indexes cover columns that are NULL for the majority of rows, yet a plain B-tree still stores an entry for every row (including the NULLs): worker_queue.session_id -- NULL for every non-session work item worker_queue.tag -- NULL for every untagged (default) work item orchestrator_queue.lock_token -- NULL for every unclaimed row Each is only ever probed by a concrete non-NULL value (session routing/takeover, tag routing, and the orchestrator ack DELETE ... WHERE lock_token = <token>), so the NULL entries are pure overhead: they bloat the index and add maintenance and autovacuum work that no query ever benefits from. Fix --- Migration 0023 recreates the three indexes as partial `WHERE <col> IS NOT NULL`. Postgres skips a partial index entirely for rows that don't satisfy its predicate, so the ~95% of inserts with a NULL value now do no work on these indexes -- no index tuple, no page split, no WAL, and nothing for autovacuum to reclaim later. That is the source of the speedup; it is a write-path win, not a lookup change. Reads by a concrete value still use the indexes -- the NULL rows just carry no entry (and NULL-valued rows were never index-served anyway: NULL is the majority value, so the planner already seq-scanned for `IS NULL`). Measured (500k worker rows ~5% sessioned/tagged, 100k orch rows ~10% claimed): idx_worker_queue_session_id 3264 kB -> 272 kB (~12x smaller) idx_worker_queue_tag 3168 kB -> 176 kB (~18x smaller) idx_orch_lock 1120 kB -> 536 kB (~2x smaller) bulk insert of 500k rows 1576 ms -> 1329 ms (~16% faster) Reproduce with scripts/bench-secondary-indexes.sh. Notes ----- - Trade-off: a distribution-dependent write-path win. The savings scale with how NULL-heavy the columns are (the figures above use a ~95%-NULL, i.e. non-session/untagged, population); a session- or tag-heavy workload sees little benefit -- but no regression, as value lookups still use the indexes (verified the parameterized `tag = ANY($1)` uses the partial index even under a forced generic plan). The only path dropped is an `IS NULL` index scan, which the planner never chose anyway (NULL is the majority value -> seq scan). - Independent of, and numbered after, the concurrent worker_queue dequeue change (which reserves migration 0022); the runner applies pending migrations by version and tolerates the gap, so this applies cleanly with or without 0022. - Verified: basic, provider (incl. session + tag-filtering), and session e2e suites pass. Migration applies via both psql and the crate migration runner.
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
Migration
0023makes three mostly-NULL secondary indexes partial (WHERE <col> IS NOT NULL):worker_queue.session_id— NULL for every non-session work itemworker_queue.tag— NULL for every untagged (default) work itemorchestrator_queue.lock_token— NULL for every unclaimed rowWhy
Each of these columns is NULL for the majority of rows, yet a plain B-tree still stores an entry for every row (including the NULLs). Each index is only ever probed by a concrete non-NULL value — session routing/takeover, tag routing, and the orchestrator ack
DELETE … WHERE lock_token = <token>— so the NULL entries are pure overhead: they bloat the index and add maintenance/autovacuum work that no query benefits from.Fix
Recreate the three indexes as partial
WHERE <col> IS NOT NULL. Postgres skips a partial index entirely for rows that don't satisfy its predicate, so the ~95% of inserts with a NULL value now do no work on these indexes — no index tuple, no page split, no WAL, and nothing for autovacuum to reclaim later. That is the source of the speedup; it is a write-path win, not a lookup change. Reads by a concrete value still use the indexes.Measured
500k worker rows (~5% sessioned/tagged), 100k orchestrator rows (~10% claimed):
idx_worker_queue_session_ididx_worker_queue_tagidx_orch_lockReproduce with
scripts/bench-secondary-indexes.sh.Trade-off
This is a distribution-dependent write-path optimization. The savings scale with how NULL-heavy the columns are (the figures use a ~95%-NULL, i.e. non-session/untagged, population); a session- or tag-heavy workload sees little benefit — but no regression, as value lookups still use the indexes. The parameterized
tag = ANY($1)was verified to still use the partial index even under a forced generic plan. The only path dropped is anIS NULLindex scan, which the planner never chose anyway (NULL is the majority value → seq scan).Notes
0022is reserved by the concurrentworker_queuedequeue change; this migration is independent of it. The runner applies pending migrations by version and tolerates the gap, so0023applies cleanly with or without0022present.Testing
basic_tests,postgres_provider_test(incl. session + tag-filtering), andsession_e2e_testsall pass.psqland the crate's own migration runner (partial indexes present and value lookups still index-served in freshly created schemas).Related: #18. That PR takes migration 0022; this one is 0023 and is independent of it.