catalog-backend: add missing 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
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 <freben@gmail.com>

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
Fredrik Adelöw
2026-05-16 19:38:40 +02:00
parent a29edc58fb
commit bc32c13de6
4 changed files with 169 additions and 0 deletions
+5
View File
@@ -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).
@@ -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 };
+1
View File
@@ -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`
@@ -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<boolean> {
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();
});
});