fix(catalog): make catalog entity deletion transactional

additionally, make the catalog build respect the deferred stitching mode
after deleting entities.

Signed-off-by: Hellgren Heikki <heikki.hellgren@op.fi>
This commit is contained in:
Hellgren Heikki
2026-03-11 09:24:30 +02:00
parent c08e4daf24
commit a91bd1b8a0
2 changed files with 90 additions and 83 deletions
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend': patch
---
Improved catalog entity deletion so parent invalidation and deferred relation restitch scheduling are coordinated more safely.
@@ -525,94 +525,96 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog {
}
async removeEntityByUid(uid: string): Promise<void> {
const dbConfig = this.database.client.config;
const relationPeerRefs = await this.database.transaction(async tx => {
const dbConfig = tx.client.config;
// Clear the hashed state of the immediate parents of the deleted entity.
// This makes sure that when they get reprocessed, their output is written
// down again. The reason for wanting to do this, is that if the user
// deletes entities that ARE still emitted by the parent, the parent
// processing will still generate the same output hash as always, which
// means it'll never try to write down the children again (it assumes that
// they already exist). This means that without the code below, the database
// never "heals" from accidental deletes.
if (dbConfig.client.includes('mysql')) {
// MySQL doesn't support the syntax we need to do this in a single query,
// http://dev.mysql.com/doc/refman/5.6/en/update.html
const results = await this.database<DbRefreshStateRow>('refresh_state')
.select('entity_id')
.whereIn('entity_ref', function parents(builder) {
return builder
.from<DbRefreshStateRow>('refresh_state')
.innerJoin<DbRefreshStateReferencesRow>(
'refresh_state_references',
{
'refresh_state_references.target_entity_ref':
'refresh_state.entity_ref',
},
)
.where('refresh_state.entity_id', '=', uid)
.select('refresh_state_references.source_entity_ref');
});
await this.database<DbRefreshStateRow>('refresh_state')
.update({
result_hash: 'child-was-deleted',
next_update_at: this.database.fn.now(),
})
.whereIn(
'entity_id',
results.map(key => key.entity_id),
);
} else {
await this.database<DbRefreshStateRow>('refresh_state')
.update({
result_hash: 'child-was-deleted',
next_update_at: this.database.fn.now(),
})
.whereIn('entity_ref', function parents(builder) {
return builder
.from<DbRefreshStateRow>('refresh_state')
.innerJoin<DbRefreshStateReferencesRow>(
'refresh_state_references',
{
'refresh_state_references.target_entity_ref':
'refresh_state.entity_ref',
},
)
.where('refresh_state.entity_id', '=', uid)
.select('refresh_state_references.source_entity_ref');
});
}
// Stitch the entities that the deleted one had relations to. If we do not
// do this, the entities in the other end of the relations will still look
// like they have a relation to the entity that was deleted, despite not
// having any corresponding rows in the relations table.
const relationPeers = await this.database
.from<DbRelationsRow>('relations')
.innerJoin<DbRefreshStateReferencesRow>('refresh_state', {
'refresh_state.entity_ref': 'relations.target_entity_ref',
})
.where('relations.originating_entity_id', '=', uid)
.andWhere('refresh_state.entity_id', '!=', uid)
.select({ ref: 'relations.target_entity_ref' })
.union(other =>
other
.from<DbRelationsRow>('relations')
.innerJoin<DbRefreshStateReferencesRow>('refresh_state', {
'refresh_state.entity_ref': 'relations.source_entity_ref',
// Clear the hashed state of the immediate parents of the deleted entity.
// This makes sure that when they get reprocessed, their output is written
// down again. The reason for wanting to do this, is that if the user
// deletes entities that ARE still emitted by the parent, the parent
// processing will still generate the same output hash as always, which
// means it'll never try to write down the children again (it assumes that
// they already exist). This means that without the code below, the database
// never "heals" from accidental deletes.
if (dbConfig.client.includes('mysql')) {
// MySQL doesn't support the syntax we need to do this in a single query,
// http://dev.mysql.com/doc/refman/5.6/en/update.html
const results = await tx<DbRefreshStateRow>('refresh_state')
.select('entity_id')
.whereIn('entity_ref', function parents(builder) {
return builder
.from<DbRefreshStateRow>('refresh_state')
.innerJoin<DbRefreshStateReferencesRow>(
'refresh_state_references',
{
'refresh_state_references.target_entity_ref':
'refresh_state.entity_ref',
},
)
.where('refresh_state.entity_id', '=', uid)
.select('refresh_state_references.source_entity_ref');
});
await tx<DbRefreshStateRow>('refresh_state')
.update({
result_hash: 'child-was-deleted',
next_update_at: tx.fn.now(),
})
.where('relations.originating_entity_id', '=', uid)
.andWhere('refresh_state.entity_id', '!=', uid)
.select({ ref: 'relations.source_entity_ref' }),
);
.whereIn(
'entity_id',
results.map(key => key.entity_id),
);
} else {
await tx<DbRefreshStateRow>('refresh_state')
.update({
result_hash: 'child-was-deleted',
next_update_at: tx.fn.now(),
})
.whereIn('entity_ref', function parents(builder) {
return builder
.from<DbRefreshStateRow>('refresh_state')
.innerJoin<DbRefreshStateReferencesRow>(
'refresh_state_references',
{
'refresh_state_references.target_entity_ref':
'refresh_state.entity_ref',
},
)
.where('refresh_state.entity_id', '=', uid)
.select('refresh_state_references.source_entity_ref');
});
}
await this.database<DbRefreshStateRow>('refresh_state')
.where('entity_id', uid)
.delete();
const relationPeers = await tx
.from<DbRelationsRow>('relations')
.innerJoin<DbRefreshStateRow>('refresh_state', {
'refresh_state.entity_ref': 'relations.target_entity_ref',
})
.where('relations.originating_entity_id', '=', uid)
.andWhere('refresh_state.entity_id', '!=', uid)
.select({ ref: 'relations.target_entity_ref' })
.union(other =>
other
.from<DbRelationsRow>('relations')
.innerJoin<DbRefreshStateRow>('refresh_state', {
'refresh_state.entity_ref': 'relations.source_entity_ref',
})
.where('relations.originating_entity_id', '=', uid)
.andWhere('refresh_state.entity_id', '!=', uid)
.select({ ref: 'relations.source_entity_ref' }),
);
await this.stitcher.stitch({
entityRefs: new Set(relationPeers.map(p => p.ref)),
await tx<DbRefreshStateRow>('refresh_state')
.where('entity_id', uid)
.delete();
return new Set(relationPeers.map(p => p.ref));
});
if (relationPeerRefs.size > 0) {
await this.stitcher.stitch({
entityRefs: relationPeerRefs,
});
}
}
async entityAncestry(rootRef: string): Promise<EntityAncestryResponse> {