diff --git a/.changeset/moody-shrimps-whisper.md b/.changeset/moody-shrimps-whisper.md new file mode 100644 index 0000000000..ea37ed5ef3 --- /dev/null +++ b/.changeset/moody-shrimps-whisper.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Fixes a bug in the catalog where entities were not being marked as orphaned. diff --git a/plugins/catalog-backend/src/database/DefaultProcessingDatabase.test.ts b/plugins/catalog-backend/src/database/DefaultProcessingDatabase.test.ts index 00b3de2fc7..1364e17f37 100644 --- a/plugins/catalog-backend/src/database/DefaultProcessingDatabase.test.ts +++ b/plugins/catalog-backend/src/database/DefaultProcessingDatabase.test.ts @@ -1182,4 +1182,134 @@ describe('Default Processing Database', () => { 60_000, ); }); + + describe('listAncestors', () => { + let nextId = 1; + function makeEntity(ref: string) { + return { + entity_id: String(nextId++), + entity_ref: ref, + unprocessed_entity: JSON.stringify({ + kind: 'Location', + apiVersion: '1.0.0', + metadata: { + name: 'xyz', + }, + }), + errors: '[]', + next_update_at: '2019-01-01 23:00:00', + last_discovery_at: '2021-04-01 13:37:00', + }; + } + + it.each(databases.eachSupportedId())( + 'should return entities to process, %p', + async databaseId => { + const { knex, db } = await createDatabase(databaseId); + + await knex('refresh_state').insert( + makeEntity('location:default/root-1'), + ); + await knex('refresh_state').insert( + makeEntity('location:default/root-2'), + ); + await knex('refresh_state').insert( + makeEntity('component:default/foobar'), + ); + + await insertRefRow(knex, { + source_key: 'source', + target_entity_ref: 'location:default/root-2', + }); + await insertRefRow(knex, { + source_entity_ref: 'location:default/root-2', + target_entity_ref: 'location:default/root-1', + }); + await insertRefRow(knex, { + source_entity_ref: 'location:default/root-1', + target_entity_ref: 'component:default/foobar', + }); + + const result = await db.transaction(async tx => + db.listAncestors(tx, { entityRef: 'component:default/foobar' }), + ); + expect(result.entityRefs).toEqual([ + 'location:default/root-1', + 'location:default/root-2', + ]); + }, + ); + }); + + describe('listParents', () => { + let nextId = 1; + function makeEntity(ref: string) { + return { + entity_id: String(nextId++), + entity_ref: ref, + unprocessed_entity: JSON.stringify({ + kind: 'Location', + apiVersion: '1.0.0', + metadata: { + name: 'xyz', + }, + }), + errors: '[]', + next_update_at: '2019-01-01 23:00:00', + last_discovery_at: '2021-04-01 13:37:00', + }; + } + + it.each(databases.eachSupportedId())( + 'should return entities to process, %p', + async databaseId => { + const { knex, db } = await createDatabase(databaseId); + + await knex('refresh_state').insert( + makeEntity('location:default/root-1'), + ); + await knex('refresh_state').insert( + makeEntity('location:default/root-2'), + ); + await knex('refresh_state').insert( + makeEntity('component:default/foobar'), + ); + + await insertRefRow(knex, { + source_key: 'source', + target_entity_ref: 'location:default/root-2', + }); + await insertRefRow(knex, { + source_entity_ref: 'location:default/root-2', + target_entity_ref: 'location:default/root-1', + }); + await insertRefRow(knex, { + source_entity_ref: 'location:default/root-1', + target_entity_ref: 'component:default/foobar', + }); + await insertRefRow(knex, { + source_entity_ref: 'location:default/root-2', + target_entity_ref: 'component:default/foobar', + }); + + const result1 = await db.transaction(async tx => + db.listParents(tx, { entityRef: 'component:default/foobar' }), + ); + expect(result1.entityRefs).toEqual([ + 'location:default/root-1', + 'location:default/root-2', + ]); + + const result2 = await db.transaction(async tx => + db.listParents(tx, { entityRef: 'location:default/root-1' }), + ); + expect(result2.entityRefs).toEqual(['location:default/root-2']); + + const result3 = await db.transaction(async tx => + db.listParents(tx, { entityRef: 'location:default/root-2' }), + ); + expect(result3.entityRefs).toEqual([]); + }, + ); + }); }); diff --git a/plugins/catalog-backend/src/database/DefaultProcessingDatabase.ts b/plugins/catalog-backend/src/database/DefaultProcessingDatabase.ts index 9785ac3935..0b9d3653a0 100644 --- a/plugins/catalog-backend/src/database/DefaultProcessingDatabase.ts +++ b/plugins/catalog-backend/src/database/DefaultProcessingDatabase.ts @@ -31,6 +31,8 @@ import { ListAncestorsOptions, ListAncestorsResult, UpdateEntityCacheOptions, + ListParentsOptions, + ListParentsResult, } from './types'; import { DeferredEntity } from '../processing/types'; import { RefreshIntervalFunction } from '../processing/refresh'; @@ -419,6 +421,23 @@ export class DefaultProcessingDatabase implements ProcessingDatabase { ); } + async listParents( + txOpaque: Transaction, + options: ListParentsOptions, + ): Promise { + const tx = txOpaque as Knex.Transaction; + + const rows = await tx( + 'refresh_state_references', + ) + .where({ target_entity_ref: options.entityRef }) + .select(); + + const entityRefs = rows.map(r => r.source_entity_ref!).filter(Boolean); + + return { entityRefs }; + } + async refresh(txOpaque: Transaction, options: RefreshOptions): Promise { const tx = txOpaque as Knex.Transaction; const { entityRef } = options; diff --git a/plugins/catalog-backend/src/database/types.ts b/plugins/catalog-backend/src/database/types.ts index 041a3fdc5b..9d28387e2c 100644 --- a/plugins/catalog-backend/src/database/types.ts +++ b/plugins/catalog-backend/src/database/types.ts @@ -91,6 +91,14 @@ export type ListAncestorsResult = { entityRefs: string[]; }; +export type ListParentsOptions = { + entityRef: string; +}; + +export type ListParentsResult = { + entityRefs: string[]; +}; + export interface ProcessingDatabase { transaction(fn: (tx: Transaction) => Promise): Promise; @@ -148,4 +156,9 @@ export interface ProcessingDatabase { txOpaque: Transaction, options: ListAncestorsOptions, ): Promise; + + listParents( + txOpaque: Transaction, + options: ListParentsOptions, + ): Promise; } diff --git a/plugins/catalog-backend/src/processing/DefaultCatalogProcessingEngine.test.ts b/plugins/catalog-backend/src/processing/DefaultCatalogProcessingEngine.test.ts index 044c3e7c4b..6b55b8b9eb 100644 --- a/plugins/catalog-backend/src/processing/DefaultCatalogProcessingEngine.test.ts +++ b/plugins/catalog-backend/src/processing/DefaultCatalogProcessingEngine.test.ts @@ -29,6 +29,7 @@ describe('DefaultCatalogProcessingEngine', () => { getProcessableEntities: jest.fn(), updateProcessedEntity: jest.fn(), updateEntityCache: jest.fn(), + listParents: jest.fn(), } as unknown as jest.Mocked; const orchestrator: jest.Mocked = { process: jest.fn(), @@ -209,18 +210,19 @@ describe('DefaultCatalogProcessingEngine', () => { db.transaction.mockImplementation(cb => cb((() => {}) as any)); + db.listParents.mockResolvedValue({ entityRefs: [] }); db.getProcessableEntities .mockResolvedValueOnce({ items: [{ ...refreshState, resultHash: 'NOT RIGHT' }], }) .mockResolvedValue({ items: [] }); - await engine.start(); await waitForExpect(() => { expect(orchestrator.process).toBeCalledTimes(1); expect(hash.digest).toBeCalledTimes(1); expect(db.updateProcessedEntity).toBeCalledTimes(1); + expect(db.listParents).toBeCalledTimes(1); }); expect(db.updateEntityCache).not.toHaveBeenCalled(); @@ -236,6 +238,7 @@ describe('DefaultCatalogProcessingEngine', () => { expect(hash.digest).toBeCalledTimes(2); expect(db.updateProcessedEntity).toBeCalledTimes(1); expect(db.updateEntityCache).toBeCalledTimes(1); + expect(db.listParents).toBeCalledTimes(2); }); expect(db.updateEntityCache).toHaveBeenCalledWith(expect.anything(), { id: '', diff --git a/plugins/catalog-backend/src/processing/DefaultCatalogProcessingEngine.ts b/plugins/catalog-backend/src/processing/DefaultCatalogProcessingEngine.ts index d0faff25d5..59cd4d94ae 100644 --- a/plugins/catalog-backend/src/processing/DefaultCatalogProcessingEngine.ts +++ b/plugins/catalog-backend/src/processing/DefaultCatalogProcessingEngine.ts @@ -189,10 +189,18 @@ export class DefaultCatalogProcessingEngine implements CatalogProcessingEngine { let hashBuilder = this.createHash().update(errorsString); if (result.ok) { + const { entityRefs: parents } = + await this.processingDatabase.transaction(tx => + this.processingDatabase.listParents(tx, { + entityRef, + }), + ); + hashBuilder = hashBuilder .update(stableStringify({ ...result.completedEntity })) .update(stableStringify([...result.deferredEntities])) - .update(stableStringify([...result.relations])); + .update(stableStringify([...result.relations])) + .update(stableStringify([...parents])); } const resultHash = hashBuilder.digest('hex');