Merge pull request #32852 from backstage/freben/move-search

catalog: relate the `search` table to `final_entities`, not `refresh_state`
This commit is contained in:
Fredrik Adelöw
2026-02-17 21:55:57 +01:00
committed by GitHub
4 changed files with 333 additions and 9 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend': patch
---
Changed the `search` table foreign key to point to `final_entities` instead of `refresh_state`
@@ -0,0 +1,69 @@
/*
* 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
/**
* Changes the search table's foreign key from refresh_state(entity_id)
* to final_entities(entity_id). This allows search entries to reference
* final entities directly, with CASCADE delete when entities are removed.
*
* @param {import('knex').Knex} knex
*/
exports.up = async function up(knex) {
// Step 1: Drop the old foreign key constraint (to refresh_state)
await knex.schema.alterTable('search', table => {
table.dropForeign(['entity_id']);
});
// Step 2: Delete orphaned rows where entity_id doesn't exist in final_entities
await knex('search')
.whereNotIn('entity_id', knex('final_entities').select('entity_id'))
.delete();
// Step 3: Add new FK to final_entities(entity_id) with CASCADE
await knex.schema.alterTable('search', table => {
table
.foreign('entity_id')
.references('entity_id')
.inTable('final_entities')
.onDelete('CASCADE');
});
};
/**
* @param {import('knex').Knex} knex
*/
exports.down = async function down(knex) {
// Step 1: Drop FK to final_entities
await knex.schema.alterTable('search', table => {
table.dropForeign(['entity_id']);
});
// Step 2: Delete orphaned rows where entity_id doesn't exist in refresh_state
await knex('search')
.whereNotIn('entity_id', knex('refresh_state').select('entity_id'))
.delete();
// Step 3: Add back FK to refresh_state(entity_id) with CASCADE
await knex.schema.alterTable('search', table => {
table
.foreign('entity_id')
.references('entity_id')
.inTable('refresh_state')
.onDelete('CASCADE');
});
};
@@ -23,6 +23,7 @@ import { CatalogScmEventsServiceSubscriber } from '@backstage/plugin-catalog-nod
import { Knex } from 'knex';
import { applyDatabaseMigrations } from '../database/migrations';
import {
DbFinalEntitiesRow,
DbRefreshKeysRow,
DbRefreshStateRow,
DbSearchRow,
@@ -79,6 +80,16 @@ describe('GenericScmEventRefreshProvider', () => {
});
}
async function insertFinalEntity(knex: Knex, id: string) {
await knex<DbFinalEntitiesRow>('final_entities').insert({
entity_id: id,
entity_ref: `k:ns/${id}`,
hash: 'h',
stitch_ticket: '',
final_entity: '{}',
});
}
describe.each(databases.eachSupportedId())('%p', databaseId => {
it('handles location.updated', async () => {
const { knex, subscriber } = await initialize(databaseId);
@@ -109,6 +120,11 @@ describe('GenericScmEventRefreshProvider', () => {
},
]);
// Insert final_entities for entities that will have search rows
await insertFinalEntity(knex, '4');
await insertFinalEntity(knex, '5');
await insertFinalEntity(knex, '6');
await knex<DbSearchRow>('search').insert([
// match exact blob in location
{
@@ -188,6 +204,13 @@ describe('GenericScmEventRefreshProvider', () => {
},
]);
// Insert final_entities for entities that will have search rows
await insertFinalEntity(knex, '4');
await insertFinalEntity(knex, '5');
await insertFinalEntity(knex, '6');
await insertFinalEntity(knex, '7');
await insertFinalEntity(knex, '8');
await knex<DbSearchRow>('search').insert([
// match blob in location
{
@@ -70,6 +70,15 @@ describe('migrations', () => {
last_discovery_at: new Date(),
})
.into('refresh_state');
await knex
.insert({
entity_id: 'i1',
hash: 'h',
stitch_ticket: '',
final_entity: '{}',
entity_ref: 'k:ns/n1',
})
.into('final_entities');
await knex
.insert({ entity_id: 'i1', key: 'k1', value: 'v1' })
.into('search');
@@ -87,15 +96,6 @@ describe('migrations', () => {
type: 't',
})
.into('relations');
await knex
.insert({
entity_id: 'i1',
hash: 'h',
stitch_ticket: '',
final_entity: '{}',
entity_ref: 'k:ns/n1',
})
.into('final_entities');
await knex.delete().from('refresh_state').where({ entity_id: 'i1' });
@@ -690,4 +690,231 @@ describe('migrations', () => {
await knex.destroy();
},
);
it.each(databases.eachSupportedId())(
'20260214000000_search_fk_final_entities.js, %p',
async databaseId => {
const knex = await databases.init(databaseId);
// Run migrations up to just before the target migration
await migrateUntilBefore(
knex,
'20260214000000_search_fk_final_entities.js',
);
// Insert rows into refresh_state
await knex('refresh_state').insert([
{
entity_id: 'id1',
entity_ref: 'component:default/service-a',
unprocessed_entity: '{}',
processed_entity: '{}',
errors: '[]',
next_update_at: knex.fn.now(),
last_discovery_at: knex.fn.now(),
},
{
entity_id: 'id2',
entity_ref: 'component:default/service-b',
unprocessed_entity: '{}',
processed_entity: '{}',
errors: '[]',
next_update_at: knex.fn.now(),
last_discovery_at: knex.fn.now(),
},
{
entity_id: 'id3',
entity_ref: 'api:default/my-api',
unprocessed_entity: '{}',
processed_entity: '{}',
errors: '[]',
next_update_at: knex.fn.now(),
last_discovery_at: knex.fn.now(),
},
]);
// Insert rows into final_entities (only id1 and id2, not id3 - to test orphan deletion)
await knex('final_entities').insert([
{
entity_id: 'id1',
entity_ref: 'component:default/service-a',
hash: 'hash1',
stitch_ticket: 'ticket1',
final_entity: '{}',
},
{
entity_id: 'id2',
entity_ref: 'component:default/service-b',
hash: 'hash2',
stitch_ticket: 'ticket2',
final_entity: '{}',
},
]);
// Insert rows into search table (with entity_id FK to refresh_state before migration)
await knex('search').insert([
{
entity_id: 'id1',
key: 'kind',
value: 'component',
original_value: 'Component',
},
{
entity_id: 'id1',
key: 'metadata.name',
value: 'service-a',
original_value: 'service-a',
},
{
entity_id: 'id2',
key: 'kind',
value: 'component',
original_value: 'Component',
},
{
entity_id: 'id2',
key: 'metadata.name',
value: 'service-b',
original_value: 'service-b',
},
// id3 has no final_entities row, so this should be deleted during migration
{ entity_id: 'id3', key: 'kind', value: 'api', original_value: 'API' },
{
entity_id: 'id3',
key: 'metadata.name',
value: 'my-api',
original_value: 'my-api',
},
]);
// Verify initial state
const preMigrationCount = await knex('search').count('* as count');
expect(Number(preMigrationCount[0].count)).toBe(6);
// Run the migration
await migrateUpOnce(knex);
// Verify orphaned rows (id3) were deleted since id3 is not in final_entities
const postMigrationRows = await knex('search').orderBy([
'entity_id',
'key',
]);
expect(postMigrationRows).toEqual([
{
entity_id: 'id1',
key: 'kind',
value: 'component',
original_value: 'Component',
},
{
entity_id: 'id1',
key: 'metadata.name',
value: 'service-a',
original_value: 'service-a',
},
{
entity_id: 'id2',
key: 'kind',
value: 'component',
original_value: 'Component',
},
{
entity_id: 'id2',
key: 'metadata.name',
value: 'service-b',
original_value: 'service-b',
},
]);
// Verify FK cascade works: deleting from final_entities should cascade to search
await knex('final_entities').where({ entity_id: 'id1' }).delete();
const searchAfterDelete = await knex('search').orderBy([
'entity_id',
'key',
]);
expect(searchAfterDelete).toEqual([
{
entity_id: 'id2',
key: 'kind',
value: 'component',
original_value: 'Component',
},
{
entity_id: 'id2',
key: 'metadata.name',
value: 'service-b',
original_value: 'service-b',
},
]);
// Restore id1 for down migration test
await knex('final_entities').insert({
entity_id: 'id1',
entity_ref: 'component:default/service-a',
hash: 'hash1',
stitch_ticket: 'ticket1',
final_entity: '{}',
});
await knex('search').insert([
{
entity_id: 'id1',
key: 'kind',
value: 'component',
original_value: 'Component',
},
]);
// Run the down migration
await migrateDownOnce(knex);
// Verify data is still intact after down migration
const revertedSearchRows = await knex('search').orderBy([
'entity_id',
'key',
]);
expect(revertedSearchRows).toEqual([
{
entity_id: 'id1',
key: 'kind',
value: 'component',
original_value: 'Component',
},
{
entity_id: 'id2',
key: 'kind',
value: 'component',
original_value: 'Component',
},
{
entity_id: 'id2',
key: 'metadata.name',
value: 'service-b',
original_value: 'service-b',
},
]);
// Verify FK is back to refresh_state: deleting from refresh_state should cascade
await knex('refresh_state').where({ entity_id: 'id1' }).delete();
const afterRefreshStateDelete = await knex('search').orderBy([
'entity_id',
'key',
]);
expect(afterRefreshStateDelete).toEqual([
{
entity_id: 'id2',
key: 'kind',
value: 'component',
original_value: 'Component',
},
{
entity_id: 'id2',
key: 'metadata.name',
value: 'service-b',
original_value: 'service-b',
},
]);
await knex.destroy();
},
);
});