chore: Add tests for ProviderDatabase and evictOrphanedEntityProviders

Signed-off-by: Jack Palmer <jackpalmer@spotify.com>
This commit is contained in:
Jack Palmer
2025-02-25 20:10:40 +00:00
parent 259b22efdd
commit 1dc906c10a
7 changed files with 206 additions and 45 deletions
+1 -4
View File
@@ -218,10 +218,7 @@ export interface Config {
useUrlReadersSearch?: boolean;
/**
* Evicts entities from the catalog that are no longer referenced by any
* added entity providers.
*
* Defaults to false.
* Evicts entities from the catalog that are no longer referenced by entity providers added to the catalog.
*/
evictOrphanedEntityProviders?: boolean;
};
@@ -59,21 +59,21 @@ describe('DefaultProviderDatabase', () => {
await db<DbRefreshStateRow>('refresh_state').insert(ref);
};
describe('replaceUnprocessedEntities', () => {
const createLocations = async (db: Knex, entityRefs: string[]) => {
for (const ref of entityRefs) {
await insertRefreshStateRow(db, {
entity_id: uuid.v4(),
entity_ref: ref,
unprocessed_entity: '{}',
processed_entity: '{}',
errors: '[]',
next_update_at: '2021-04-01 13:37:00',
last_discovery_at: '2021-04-01 13:37:00',
});
}
};
const createLocations = async (db: Knex, entityRefs: string[]) => {
for (const ref of entityRefs) {
await insertRefreshStateRow(db, {
entity_id: uuid.v4(),
entity_ref: ref,
unprocessed_entity: '{}',
processed_entity: '{}',
errors: '[]',
next_update_at: '2021-04-01 13:37:00',
last_discovery_at: '2021-04-01 13:37:00',
});
}
};
describe('replaceUnprocessedEntities', () => {
it.each(databases.eachSupportedId())(
'replaces all existing state correctly for simple dependency chains, %p',
async databaseId => {
@@ -988,4 +988,86 @@ describe('DefaultProviderDatabase', () => {
},
);
});
describe('listReferenceSourceKeys', () => {
it.each(databases.eachSupportedId())(
'returns the source_keys from "refresh_state_references", %p',
async databaseId => {
const { knex, db } = await createDatabase(databaseId);
await createLocations(knex, [
'location:default/root',
'location:default/root-1',
]);
await insertRefRow(knex, {
source_key: 'foo',
target_entity_ref: 'location:default/root',
});
await insertRefRow(knex, {
source_key: 'bar',
target_entity_ref: 'location:default/root-1',
});
const res = await db.transaction(async tx =>
db.listReferenceSourceKeys(tx),
);
expect(res).toEqual(['bar', 'foo']);
},
);
it.each(databases.eachSupportedId())(
'returns only unique source_keys", %p',
async databaseId => {
const { knex, db } = await createDatabase(databaseId);
await createLocations(knex, [
'location:default/root',
'location:default/root-1',
]);
await insertRefRow(knex, {
source_key: 'foo',
target_entity_ref: 'location:default/root',
});
await insertRefRow(knex, {
source_key: 'foo',
target_entity_ref: 'location:default/root-1',
});
const res = await db.transaction(async tx =>
db.listReferenceSourceKeys(tx),
);
expect(res).toEqual(['foo']);
},
);
it.each(databases.eachSupportedId())(
'does not return null source_keys", %p',
async databaseId => {
const { knex, db } = await createDatabase(databaseId);
await createLocations(knex, [
'location:default/root',
'location:default/root-1',
]);
await insertRefRow(knex, {
source_key: 'foo',
target_entity_ref: 'location:default/root',
});
await insertRefRow(knex, {
target_entity_ref: 'location:default/root-1',
});
const res = await db.transaction(async tx =>
db.listReferenceSourceKeys(tx),
);
expect(res).toEqual(['foo']);
},
);
});
});
@@ -199,7 +199,7 @@ export class DefaultProviderDatabase implements ProviderDatabase {
}
}
async listEntityProviderNames(txOpaque: Transaction): Promise<string[]> {
async listReferenceSourceKeys(txOpaque: Transaction): Promise<string[]> {
const tx = txOpaque as Knex | Knex.Transaction;
const rows = await tx<DbRefreshStateReferencesRow>(
@@ -177,7 +177,7 @@ export interface ProviderDatabase {
/**
* List the names of all the entity providers that have references in the provider database.
*/
listEntityProviderNames(txOpaque: Transaction): Promise<string[]>;
listReferenceSourceKeys(txOpaque: Transaction): Promise<string[]>;
}
// TODO(Rugvip): This is only partial for now
@@ -0,0 +1,74 @@
/*
* Copyright 2025 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.
*/
import { EntityProvider } from '@backstage/plugin-catalog-node';
import { mockServices } from '@backstage/backend-test-utils';
import { DefaultProviderDatabase } from '../database/DefaultProviderDatabase';
import { evictOrphanedEntityProviders } from './evictOrphanedEntityProviders';
describe('evictOrphanedEntityProviders', () => {
const db = {
transaction: jest.fn().mockImplementation(cb => cb((() => {}) as any)),
replaceUnprocessedEntities: jest.fn(),
listReferenceSourceKeys: jest.fn(),
} as unknown as jest.Mocked<DefaultProviderDatabase>;
const providers = [
{ getProviderName: () => 'provider1' },
{ getProviderName: () => 'provider2' },
] as unknown as EntityProvider[];
const logger = mockServices.logger.mock();
it('replaces unprocessed entities for orphaned providers with empty items', async () => {
db.listReferenceSourceKeys.mockResolvedValue(['foo', 'bar']);
await evictOrphanedEntityProviders({ db, providers, logger });
expect(db.replaceUnprocessedEntities).toHaveBeenCalledTimes(2);
expect(db.replaceUnprocessedEntities).toHaveBeenNthCalledWith(
1,
expect.anything(),
{
sourceKey: 'foo',
type: 'full',
items: [],
},
);
expect(db.replaceUnprocessedEntities).toHaveBeenNthCalledWith(
2,
expect.anything(),
{
sourceKey: 'bar',
type: 'full',
items: [],
},
);
});
it('does not replace unprocessed entities for providers that are not orphaned', async () => {
db.listReferenceSourceKeys.mockResolvedValue(['foo', 'provider1']);
await evictOrphanedEntityProviders({ db, providers, logger });
expect(db.replaceUnprocessedEntities).not.toHaveBeenCalledWith(
expect.anything(),
{
sourceKey: 'provider1',
type: 'full',
items: [],
},
);
});
});
@@ -15,15 +15,18 @@
*/
import { EntityProvider } from '@backstage/plugin-catalog-node';
import { ProviderDatabase } from '../database/types';
import { LoggerService } from '@backstage/backend-plugin-api';
import { ProviderDatabase } from '../database/types';
async function getOrphanedEntityProviderNames(
db: ProviderDatabase,
providers: EntityProvider[],
): Promise<string[]> {
async function getOrphanedEntityProviderNames({
db,
providers,
}: {
db: ProviderDatabase;
providers: EntityProvider[];
}): Promise<string[]> {
const dbProviderNames = await db.transaction(async tx =>
db.listEntityProviderNames(tx),
db.listReferenceSourceKeys(tx),
);
const providerNames = providers.map(p => p.getProviderName());
@@ -33,11 +36,15 @@ async function getOrphanedEntityProviderNames(
);
}
async function removeEntitiesForProvider(
db: ProviderDatabase,
providerName: string,
logger: LoggerService,
) {
async function removeEntitiesForProvider({
db,
providerName,
logger,
}: {
db: ProviderDatabase;
providerName: string;
logger: LoggerService;
}) {
try {
await db.transaction(async tx => {
await db.replaceUnprocessedEntities(tx, {
@@ -55,15 +62,16 @@ async function removeEntitiesForProvider(
}
}
export async function evictOrphanedEntityProviders(
db: ProviderDatabase,
providers: EntityProvider[],
logger: LoggerService,
) {
for (const providerName of await getOrphanedEntityProviderNames(
db,
providers,
)) {
await removeEntitiesForProvider(db, providerName, logger);
export async function evictOrphanedEntityProviders(options: {
db: ProviderDatabase;
providers: EntityProvider[];
logger: LoggerService;
}) {
for (const providerName of await getOrphanedEntityProviderNames(options)) {
await removeEntitiesForProvider({
db: options.db,
providerName,
logger: options.logger,
});
}
}
@@ -647,11 +647,11 @@ export class CatalogBuilder {
await connectEntityProviders(providerDatabase, entityProviders);
if (config.getOptionalBoolean('catalog.evictOrphanedEntityProviders')) {
await evictOrphanedEntityProviders(
providerDatabase,
entityProviders,
await evictOrphanedEntityProviders({
db: providerDatabase,
providers: entityProviders,
logger,
);
});
}
return {