Add covering index for Zoekt reserved storage bytes by node
What does this MR do and why?
Adds a covering index on zoekt_indices (zoekt_node_id) INCLUDE (reserved_storage_bytes).
Search::Zoekt::Node.available_for_search_indexing, which Search::Zoekt::SelectionService uses to rank nodes for placement, sums reserved_storage_bytes per node over the whole zoekt_indices table. Today that reads every heap row through index_zoekt_indices_on_zoekt_node_id_and_id. With the covering index it becomes an index-only scan, with about 30 times fewer buffers on a production-sized clone.
Found while measuring queries for !264628. That MR doesn't change this query. The cost already exists on master.
No application code changes: the planner picks the index for the existing query.
References
Screenshots or screen recordings
Not applicable: database index only.
How to set up and validate locally
- Run the migration:
bin/rails db:migrate - In
gdk psql, check that the placement aggregate uses the new index:With little local data, the planner may still choose a sequential scan.EXPLAIN SELECT zoekt_node_id, sum(reserved_storage_bytes) FROM zoekt_indices GROUP BY zoekt_node_id;
Database
zoekt_indices is a small table (table_size: small), about 126,000 rows on gprd. The index is 3.9 MB, and creating it with CREATE INDEX CONCURRENTLY on a Database Lab clone took 351 ms (command). It's a post-deployment migration because the index isn't needed for correctness.
Query
The existing Node.available_for_search_indexing query, from a gitlab-production-main Database Lab clone taken on 2026-10-26. The last_seen_at bound is a fixed time before the snapshot, so nodes count as online on the clone. The plans with the index use index_zoekt_indices_on_zoekt_node_id_include_reserved_bytes, created with the migration's definition and name.
SELECT zoekt_nodes.*, (zoekt_nodes.usable_storage_bytes - COALESCE(reserved_storage_bytes_total, 0)) AS unclaimed_storage_bytes
FROM "zoekt_nodes"
LEFT OUTER JOIN (
SELECT "zoekt_nodes"."zoekt_node_id", sum(reserved_bytes) AS reserved_storage_bytes_total
FROM ((SELECT 0, "zoekt_indices"."zoekt_node_id", sum(reserved_storage_bytes) AS reserved_bytes FROM "zoekt_indices" GROUP BY "zoekt_indices"."zoekt_node_id")) zoekt_nodes
GROUP BY "zoekt_nodes"."zoekt_node_id"
) reserved_bytes ON zoekt_nodes.id = reserved_bytes.zoekt_node_id
WHERE (0 = ANY(services))
AND "zoekt_nodes"."last_seen_at" >= '2026-10-24 08:24:28.000000'
AND ((zoekt_nodes.usable_storage_bytes - COALESCE(reserved_storage_bytes_total, 0)) > 0)
ORDER BY unclaimed_storage_bytes DESC| Run | Plan | Time | Buffers |
|---|---|---|---|
| Before, cold after clone reset | 176342 | 3.7 s | 120,675 (9,717 read) |
| Before, warm | 176343 | 78 ms | 120,411 |
| After, production visibility map | 176352 | 23 ms | 3,747 (8,950 heap fetches) |
After, after VACUUM |
176354 | 19 ms | 538 (0 heap fetches) |
"Production visibility map" means the index was added with only ANALYZE (command), so the clone kept gprd's vacuum state. That's the closer estimate for gprd, since zoekt_indices is updated often.
Write cost: one more index entry on each insert and on each update that changes zoekt_node_id or reserved_storage_bytes. It doesn't cost HOT updates: changes to reserved_storage_bytes already can't be HOT, because index_zoekt_indices_on_watermark_level_reserved_storage_bytes has WHERE (reserved_storage_bytes > 0). On a clone without the new index, 200 updates changing only reserved_storage_bytes gave 0 HOT updates, against 175 of 200 for a control update of the unindexed updated_at.
Migration output
Up
main: -- index_exists?(:zoekt_indices, :zoekt_node_id, {include: :reserved_storage_bytes, name: "index_zoekt_indices_on_zoekt_node_id_include_reserved_bytes", algorithm: :concurrently})
main: -> 0.0040s
main: -- execute("SET statement_timeout TO 0")
main: -> 0.0003s
main: -- add_index(:zoekt_indices, :zoekt_node_id, {include: :reserved_storage_bytes, name: "index_zoekt_indices_on_zoekt_node_id_include_reserved_bytes", algorithm: :concurrently})
main: -> 0.0024s
main: -- execute("RESET statement_timeout")
main: -> 0.0002s
main: == 20261024125031 AddIndexZoektIndicesOnNodeIdIncludeReservedBytes: migrated (0.0451s)Down
main: -- index_name_exists?(:zoekt_indices, "index_zoekt_indices_on_zoekt_node_id_include_reserved_bytes")
main: -> 0.0009s
main: -- execute("SET statement_timeout TO 0")
main: -> 0.0003s
main: -- remove_index(:zoekt_indices, {algorithm: :concurrently, name: "index_zoekt_indices_on_zoekt_node_id_include_reserved_bytes"})
main: -> 0.0017s
main: -- execute("RESET statement_timeout")
main: -> 0.0003s
main: == 20261024125031 AddIndexZoektIndicesOnNodeIdIncludeReservedBytes: reverted (0.0403s)MR acceptance checklist
Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.