Skip to content

perf(indexes): make mostly-NULL secondary indexes partial - #19

Open
iemejia wants to merge 1 commit into
microsoft:mainfrom
iemejia:perf/partial-secondary-indexes
Open

perf(indexes): make mostly-NULL secondary indexes partial#19
iemejia wants to merge 1 commit into
microsoft:mainfrom
iemejia:perf/partial-secondary-indexes

Conversation

@iemejia

@iemejia iemejia commented Jul 31, 2026

Copy link
Copy Markdown

Summary

Migration 0023 makes three mostly-NULL secondary indexes partial (WHERE <col> IS NOT NULL):

  • 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

Why

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):

Index Before After Reduction
idx_worker_queue_session_id 3264 kB 272 kB ~12×
idx_worker_queue_tag 3168 kB 176 kB ~18×
idx_orch_lock 1120 kB 536 kB ~2×
bulk insert of 500k rows 1576 ms 1329 ms ~16% faster

Reproduce 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 an IS NULL index scan, which the planner never chose anyway (NULL is the majority value → seq scan).

Notes

  • Migration numbering: 0022 is reserved by the concurrent worker_queue dequeue change; this migration is independent of it. The runner applies pending migrations by version and tolerates the gap, so 0023 applies cleanly with or without 0022 present.

Testing

  • basic_tests, postgres_provider_test (incl. session + tag-filtering), and session_e2e_tests all pass.
  • Migration verified to apply via both psql and 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.

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