From bc32c13de69a0d3b80413379666a181d566e573b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Sat, 16 May 2026 19:38:40 +0200 Subject: [PATCH] catalog-backend: add missing index on relations.target_entity_ref MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The relations table had indexes on originating_entity_id and source_entity_ref but none on target_entity_ref. Several query paths join or filter on this column: - Orphan deletion (LEFT JOIN relations ON target_entity_ref) - Entity ancestry (INNER JOIN relations ON target_entity_ref) - Eager pruning (JOIN relations ON target_entity_ref) Without an index these queries seq-scan the full table (~3.5M rows, 714 MB heap). On a production replica, a single point lookup takes ~122ms via seq scan. With the index it drops to <1ms. The index is ~141 MB based on column width (~35 bytes avg) across ~3.5M rows. On PostgreSQL it's created with CONCURRENTLY to avoid blocking reads/writes. Signed-off-by: Fredrik Adelöw Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Fredrik Adelöw --- .changeset/relations-target-index.md | 5 + .../20260516000000_relations_target_index.js | 92 +++++++++++++++++++ plugins/catalog-backend/report.sql.md | 1 + .../src/tests/migrations.test.ts | 71 ++++++++++++++ 4 files changed, 169 insertions(+) create mode 100644 .changeset/relations-target-index.md create mode 100644 plugins/catalog-backend/migrations/20260516000000_relations_target_index.js diff --git a/.changeset/relations-target-index.md b/.changeset/relations-target-index.md new file mode 100644 index 0000000000..9077ed1916 --- /dev/null +++ b/.changeset/relations-target-index.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Added a missing index on `relations.target_entity_ref`. Several query paths (orphan deletion, entity ancestry, eager pruning) join or filter on this column, but no index existed — causing full sequential scans of the relations table on every invocation. On a production catalog with ~3.5M relation rows, individual lookups were taking ~122ms (full table scan) instead of <1ms (index scan). diff --git a/plugins/catalog-backend/migrations/20260516000000_relations_target_index.js b/plugins/catalog-backend/migrations/20260516000000_relations_target_index.js new file mode 100644 index 0000000000..268e92f47f --- /dev/null +++ b/plugins/catalog-backend/migrations/20260516000000_relations_target_index.js @@ -0,0 +1,92 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * Adds an index on `relations.target_entity_ref`. + * + * The `relations` table had indexes on `originating_entity_id` and + * `source_entity_ref` but none on `target_entity_ref`. Several query + * paths (orphan deletion, entity ancestry, eager pruning) join or + * filter on `target_entity_ref`, causing full sequential scans of the + * table (~3.5M rows, ~714 MB heap). + * + * On PostgreSQL this uses CREATE INDEX CONCURRENTLY to avoid blocking + * reads and writes. The index is ~141 MB based on the column's average + * width of ~35 bytes across ~3.5M rows. + */ + +/** + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + const client = knex.client.config.client; + + if (client.includes('pg')) { + // Check if index already exists in the current schema (idempotent). + // The pg_class lookup is scoped via pg_namespace to handle + // schema-division mode where each plugin has its own schema. + const result = await knex.raw( + `SELECT c.oid, i.indisvalid + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + LEFT JOIN pg_index i ON i.indexrelid = c.oid + WHERE c.relname = ? + AND c.relkind = 'i' + AND n.nspname = current_schema()`, + ['relations_target_entity_ref_idx'], + ); + + if (result.rows.length > 0) { + if (result.rows[0].indisvalid) { + return; // Already exists and valid + } + // Invalid — drop and recreate + await knex.raw( + 'DROP INDEX CONCURRENTLY IF EXISTS relations_target_entity_ref_idx', + ); + } + + await knex.raw( + 'CREATE INDEX CONCURRENTLY relations_target_entity_ref_idx ON relations (target_entity_ref)', + ); + } else { + // SQLite / MySQL — simple CREATE INDEX + await knex.schema.alterTable('relations', table => { + table.index(['target_entity_ref'], 'relations_target_entity_ref_idx'); + }); + } +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + const client = knex.client.config.client; + + if (client.includes('pg')) { + await knex.raw( + 'DROP INDEX CONCURRENTLY IF EXISTS relations_target_entity_ref_idx', + ); + } else { + await knex.schema.alterTable('relations', table => { + table.dropIndex([], 'relations_target_entity_ref_idx'); + }); + } +}; + +exports.config = { transaction: false }; diff --git a/plugins/catalog-backend/report.sql.md b/plugins/catalog-backend/report.sql.md index 0ea80db3f4..1d172db3ba 100644 --- a/plugins/catalog-backend/report.sql.md +++ b/plugins/catalog-backend/report.sql.md @@ -114,6 +114,7 @@ - `relations_source_entity_id_idx` (`originating_entity_id`) - `relations_source_entity_ref_idx` (`source_entity_ref`) +- `relations_target_entity_ref_idx` (`target_entity_ref`) ## Table `search` diff --git a/plugins/catalog-backend/src/tests/migrations.test.ts b/plugins/catalog-backend/src/tests/migrations.test.ts index 8e95268acc..dc9e9baa42 100644 --- a/plugins/catalog-backend/src/tests/migrations.test.ts +++ b/plugins/catalog-backend/src/tests/migrations.test.ts @@ -1282,4 +1282,75 @@ describe.each(databases.eachSupportedId())('migrations, %p', databaseId => { await knex.destroy(); }); + + it('20260516000000_relations_target_index.js', async () => { + const knex = await databases.init(databaseId); + + await migrateUntilBefore(knex, '20260516000000_relations_target_index.js'); + + // Set up FK dependency chain: relations → refresh_state + await knex('refresh_state').insert({ + entity_id: 'e1', + entity_ref: 'k:ns/n1', + unprocessed_entity: '{}', + errors: '[]', + next_update_at: knex.fn.now(), + last_discovery_at: knex.fn.now(), + }); + + await knex('relations').insert([ + { + originating_entity_id: 'e1', + source_entity_ref: 'k:ns/n1', + type: 'ownedBy', + target_entity_ref: 'group:default/team-a', + }, + { + originating_entity_id: 'e1', + source_entity_ref: 'k:ns/n1', + type: 'dependsOn', + target_entity_ref: 'component:default/other', + }, + ]); + + // Verify index does not exist before migration + const client = knex.client.config.client; + async function indexExists(name: string): Promise { + if (typeof client === 'string' && client.includes('pg')) { + const r = await knex.raw( + `SELECT 1 FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE c.relname = ? AND c.relkind = 'i' + AND n.nspname = current_schema()`, + [name], + ); + return r.rows.length > 0; + } else if (typeof client === 'string' && client.includes('mysql')) { + const [rows] = await knex.raw( + 'SHOW INDEX FROM `relations` WHERE Key_name = ?', + [name], + ); + return rows.length > 0; + } + // SQLite + const r = await knex.raw( + `SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = ?`, + [name], + ); + return r.length > 0; + } + + expect(await indexExists('relations_target_entity_ref_idx')).toBe(false); + + await migrateUpOnce(knex); + + // Verify the index was created + expect(await indexExists('relations_target_entity_ref_idx')).toBe(true); + + // Verify rollback drops the index + await migrateDownOnce(knex); + expect(await indexExists('relations_target_entity_ref_idx')).toBe(false); + + await knex.destroy(); + }); });