Merge pull request #34272 from backstage/freben/relations-target-index
catalog-backend: add missing index on relations.target_entity_ref
This commit is contained in:
@@ -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 };
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user