diff --git a/.changeset/seven-bulldogs-grin.md b/.changeset/seven-bulldogs-grin.md new file mode 100644 index 0000000000..2d6f0c14c1 --- /dev/null +++ b/.changeset/seven-bulldogs-grin.md @@ -0,0 +1,11 @@ +--- +'@backstage/plugin-catalog-backend': minor +--- + +Introduced a new `CatalogProcessorCache` that is available to catalog processors. It allows arbitrary values to be saved that will then be visible during the next run. The cache is scoped to each individual processor and entity, but is shared across processing steps in a single processor. + +The cache is available as a new argument to each of the processing steps, except for `validateEntityKind` and `handleError`. + +This also introduces an optional `getProcessorName` to the `CatalogProcessor` interface, which is used to provide a stable identifier for the processor. While it is currently optional it will move to be required in the future. + +The breaking part of this change is the modification of the `state` field in the `EntityProcessingRequest` and `EntityProcessingResult` types. This is unlikely to have any impact as the `state` field was previously unused, but could require some minor updates. diff --git a/plugins/catalog-backend/api-report.md b/plugins/catalog-backend/api-report.md index 2422221d6d..133deb96fc 100644 --- a/plugins/catalog-backend/api-report.md +++ b/plugins/catalog-backend/api-report.md @@ -302,23 +302,27 @@ export interface CatalogProcessingOrchestrator { // // @public (undocumented) export type CatalogProcessor = { + getProcessorName?(): string; readLocation?( location: LocationSpec, optional: boolean, emit: CatalogProcessorEmit, parser: CatalogProcessorParser, + cache: CatalogProcessorCache, ): Promise; preProcessEntity?( entity: Entity, location: LocationSpec, emit: CatalogProcessorEmit, originLocation: LocationSpec, + cache: CatalogProcessorCache, ): Promise; validateEntityKind?(entity: Entity): Promise; postProcessEntity?( entity: Entity, location: LocationSpec, emit: CatalogProcessorEmit, + cache: CatalogProcessorCache, ): Promise; handleError?( error: Error, @@ -327,6 +331,12 @@ export type CatalogProcessor = { ): Promise; }; +// @public +export interface CatalogProcessorCache { + get(key: string): Promise; + set(key: string, value: ItemType): Promise; +} + // Warning: (ae-missing-release-tag) "CatalogProcessorEmit" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -868,7 +878,7 @@ export type EntityPagination = { // @public (undocumented) export type EntityProcessingRequest = { entity: Entity; - state: Map; + state?: JsonObject; }; // Warning: (ae-missing-release-tag) "EntityProcessingResult" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -877,7 +887,7 @@ export type EntityProcessingRequest = { export type EntityProcessingResult = | { ok: true; - state: Map; + state: JsonObject; completedEntity: Entity; deferredEntities: DeferredEntity[]; relations: EntityRelationSpec[]; @@ -1478,11 +1488,14 @@ export class UrlReaderProcessor implements CatalogProcessor { // Warning: (ae-forgotten-export) The symbol "Options" needs to be exported by the entry point index.d.ts constructor(options: Options_3); // (undocumented) + getProcessorName(): string; + // (undocumented) readLocation( location: LocationSpec, optional: boolean, emit: CatalogProcessorEmit, parser: CatalogProcessorParser, + cache: CatalogProcessorCache, ): Promise; } @@ -1505,21 +1518,6 @@ export class UrlReaderProcessor implements CatalogProcessor { // src/database/types.d.ts:164:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // src/database/types.d.ts:165:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // src/ingestion/processors/GithubMultiOrgReaderProcessor.d.ts:23:9 - (ae-forgotten-export) The symbol "GithubMultiOrgConfig" needs to be exported by the entry point index.d.ts -// src/ingestion/processors/types.d.ts:7:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/ingestion/processors/types.d.ts:8:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/ingestion/processors/types.d.ts:9:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/ingestion/processors/types.d.ts:10:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/ingestion/processors/types.d.ts:23:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/ingestion/processors/types.d.ts:24:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/ingestion/processors/types.d.ts:25:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/ingestion/processors/types.d.ts:26:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/ingestion/processors/types.d.ts:36:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/ingestion/processors/types.d.ts:47:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/ingestion/processors/types.d.ts:48:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/ingestion/processors/types.d.ts:49:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/ingestion/processors/types.d.ts:56:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/ingestion/processors/types.d.ts:57:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/ingestion/processors/types.d.ts:58:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // src/ingestion/types.d.ts:17:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // src/ingestion/types.d.ts:41:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen ``` diff --git a/plugins/catalog-backend/src/ingestion/LocationReaders.ts b/plugins/catalog-backend/src/ingestion/LocationReaders.ts index 624c7aaa61..b8c9125893 100644 --- a/plugins/catalog-backend/src/ingestion/LocationReaders.ts +++ b/plugins/catalog-backend/src/ingestion/LocationReaders.ts @@ -52,6 +52,13 @@ type Options = { policy: EntityPolicy; }; +const noopCache = { + async get() { + return undefined; + }, + async set() {}, +}; + /** * Implements the reading of a location through a series of processor tasks. */ @@ -167,6 +174,7 @@ export class LocationReaders implements LocationReader { item.optional, validatedEmit, this.options.parser, + noopCache, ) ) { return; @@ -215,6 +223,7 @@ export class LocationReaders implements LocationReader { item.location, emit, originLocation, + noopCache, ); } catch (e) { const message = `Processor ${ @@ -285,6 +294,7 @@ export class LocationReaders implements LocationReader { current, item.location, emit, + noopCache, ); } catch (e) { const message = `Processor ${ diff --git a/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.test.ts index 6521a2b6bf..a63a001082 100644 --- a/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.test.ts @@ -24,6 +24,7 @@ import { msw } from '@backstage/test-utils'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; import { + CatalogProcessorCache, CatalogProcessorEntityResult, CatalogProcessorErrorResult, CatalogProcessorResult, @@ -33,10 +34,17 @@ import { defaultEntityDataParser } from './util/parse'; describe('UrlReaderProcessor', () => { const mockApiOrigin = 'http://localhost'; - + const mockCache: jest.Mocked = { + get: jest.fn(), + set: jest.fn(), + }; const server = setupServer(); msw.setupDefaultHandlers(server); + beforeEach(() => { + jest.resetAllMocks(); + }); + it('should load from url', async () => { const logger = getVoidLogger(); const reader = UrlReaders.default({ @@ -53,17 +61,72 @@ describe('UrlReaderProcessor', () => { server.use( rest.get(`${mockApiOrigin}/component.yaml`, (_, res, ctx) => - res(ctx.json({ mock: 'entity' })), + res(ctx.set({ ETag: 'my-etag' }), ctx.json({ mock: 'entity' })), ), ); + const emitted = new Array(); + await processor.readLocation( + spec, + false, + result => emitted.push(result), + defaultEntityDataParser, + mockCache, + ); + + expect(emitted.length).toBe(1); + expect(emitted[0]).toEqual({ + type: 'entity', + location: spec, + entity: { mock: 'entity' }, + }); + expect(mockCache.set).toBeCalledWith('v1', { + etag: 'my-etag', + value: [{ type: 'entity', location: spec, entity: { mock: 'entity' } }], + }); + expect(mockCache.set).toBeCalledTimes(1); + }); + + it('should use cached data when available', async () => { + const logger = getVoidLogger(); + const reader = UrlReaders.default({ + logger, + config: new ConfigReader({ + backend: { reading: { allow: [{ host: 'localhost' }] } }, + }), + }); + server.use( + rest.get(`${mockApiOrigin}/component.yaml`, (_, res, ctx) => + res(ctx.status(304)), + ), + ); + const spec = { + type: 'url', + target: `${mockApiOrigin}/component.yaml`, + }; + const cacheItem = { + etag: 'my-etag', + value: [{ type: 'entity', location: spec, entity: { mock: 'entity' } }], + }; + mockCache.get.mockResolvedValue(cacheItem); + const processor = new UrlReaderProcessor({ reader, logger }); + const generated = (await new Promise(emit => - processor.readLocation(spec, false, emit, defaultEntityDataParser), + processor.readLocation( + spec, + false, + emit, + defaultEntityDataParser, + mockCache, + ), )) as CatalogProcessorEntityResult; expect(generated.type).toBe('entity'); expect(generated.location).toEqual(spec); expect(generated.entity).toEqual({ mock: 'entity' }); + expect(mockCache.get).toBeCalledWith('v1'); + expect(mockCache.get).toBeCalledTimes(1); + expect(mockCache.set).toBeCalledTimes(0); }); it('should fail load from url with error', async () => { @@ -87,7 +150,13 @@ describe('UrlReaderProcessor', () => { ); const generated = (await new Promise(emit => - processor.readLocation(spec, false, emit, defaultEntityDataParser), + processor.readLocation( + spec, + false, + emit, + defaultEntityDataParser, + mockCache, + ), )) as CatalogProcessorErrorResult; expect(generated.type).toBe('error'); @@ -116,6 +185,7 @@ describe('UrlReaderProcessor', () => { false, emit, defaultEntityDataParser, + mockCache, ); expect(reader.search).toBeCalledTimes(1); diff --git a/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.ts index e3ec8c0480..cf889eb577 100644 --- a/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.ts @@ -15,49 +15,88 @@ */ import { UrlReader } from '@backstage/backend-common'; -import { LocationSpec } from '@backstage/catalog-model'; +import { Entity, LocationSpec } from '@backstage/catalog-model'; import parseGitUrl from 'git-url-parse'; import limiterFactory from 'p-limit'; import { Logger } from 'winston'; import * as result from './results'; import { CatalogProcessor, + CatalogProcessorCache, CatalogProcessorEmit, + CatalogProcessorEntityResult, CatalogProcessorParser, + CatalogProcessorResult, } from './types'; +const CACHE_KEY = 'v1'; + type Options = { reader: UrlReader; logger: Logger; }; +// WARNING: If you change this type, you likely need to bump the CACHE_KEY as well +type CacheItem = { + etag: string; + value: { + type: 'entity'; + entity: Entity; + location: LocationSpec; + }[]; +}; + export class UrlReaderProcessor implements CatalogProcessor { constructor(private readonly options: Options) {} + getProcessorName() { + return 'url-reader'; + } + async readLocation( location: LocationSpec, optional: boolean, emit: CatalogProcessorEmit, parser: CatalogProcessorParser, + cache: CatalogProcessorCache, ): Promise { if (location.type !== 'url') { return false; } + const cacheItem = await cache.get(CACHE_KEY); + try { - const output = await this.doRead(location.target); - for (const item of output) { + const { response, etag: newEtag } = await this.doRead( + location.target, + cacheItem?.etag, + ); + + const parseResults: CatalogProcessorResult[] = []; + for (const item of response) { for await (const parseResult of parser({ data: item.data, location: { type: location.type, target: item.url }, })) { + parseResults.push(parseResult); emit(parseResult); } } + + const isOnlyEntities = parseResults.every(r => r.type === 'entity'); + if (newEtag && isOnlyEntities) { + await cache.set(CACHE_KEY, { + etag: newEtag, + value: parseResults as CatalogProcessorEntityResult[], + }); + } } catch (error) { const message = `Unable to read ${location.type}, ${error}`; - - if (error.name === 'NotFoundError') { + if (error.name === 'NotModifiedError' && cacheItem) { + for (const parseResult of cacheItem.value) { + emit(parseResult); + } + } else if (error.name === 'NotFoundError') { if (!optional) { emit(result.notFoundError(location, message)); } @@ -71,27 +110,31 @@ export class UrlReaderProcessor implements CatalogProcessor { private async doRead( location: string, - ): Promise<{ data: Buffer; url: string }[]> { + etag?: string, + ): Promise<{ response: { data: Buffer; url: string }[]; etag?: string }> { // Does it contain globs? I.e. does it contain asterisks or question marks // (no curly braces for now) const { filepath } = parseGitUrl(location); if (filepath?.match(/[*?]/)) { const limiter = limiterFactory(5); - const response = await this.options.reader.search(location); + const response = await this.options.reader.search(location, { etag }); const output = response.files.map(async file => ({ url: file.url, data: await limiter(file.content), })); - return Promise.all(output); + return { response: await Promise.all(output), etag: response.etag }; } // Otherwise do a plain read, prioritizing readUrl if available if (this.options.reader.readUrl) { - const data = await this.options.reader.readUrl(location); - return [{ url: location, data: await data.buffer() }]; + const data = await this.options.reader.readUrl(location, { etag }); + return { + response: [{ url: location, data: await data.buffer() }], + etag: data.etag, + }; } const data = await this.options.reader.read(location); - return [{ url: location, data }]; + return { response: [{ url: location, data }] }; } } diff --git a/plugins/catalog-backend/src/ingestion/processors/types.ts b/plugins/catalog-backend/src/ingestion/processors/types.ts index 2e2418c8f1..dccc60d42c 100644 --- a/plugins/catalog-backend/src/ingestion/processors/types.ts +++ b/plugins/catalog-backend/src/ingestion/processors/types.ts @@ -19,16 +19,24 @@ import { EntityRelationSpec, LocationSpec, } from '@backstage/catalog-model'; +import { JsonValue } from '@backstage/config'; export type CatalogProcessor = { + /** + * A unique identifier for the Catalog Processor. + * It's strongly recommended to implement getProcessorName as this method will be required in the future. + */ + getProcessorName?(): string; + /** * Reads the contents of a location. * - * @param location The location to read - * @param optional Whether a missing target should trigger an error - * @param emit A sink for items resulting from the read - * @param parser A parser, that is able to take the raw catalog descriptor + * @param location - The location to read + * @param optional - Whether a missing target should trigger an error + * @param emit - A sink for items resulting from the read + * @param parser - A parser, that is able to take the raw catalog descriptor * data and turn it into the actual result pieces. + * @param cache - A cache for storing values local to this processor and the current entity. * @returns True if handled by this processor, false otherwise */ readLocation?( @@ -36,6 +44,7 @@ export type CatalogProcessor = { optional: boolean, emit: CatalogProcessorEmit, parser: CatalogProcessorParser, + cache: CatalogProcessorCache, ): Promise; /** @@ -46,12 +55,13 @@ export type CatalogProcessor = { * additional data, and the input entity may actually still be incomplete * when the processor is invoked. * - * @param entity The (possibly partial) entity to process - * @param location The location that the entity came from - * @param emit A sink for auxiliary items resulting from the processing - * @param originLocation The location that the entity originally came from. + * @param entity - The (possibly partial) entity to process + * @param location - The location that the entity came from + * @param emit - A sink for auxiliary items resulting from the processing + * @param originLocation - The location that the entity originally came from. * While location resolves to the direct parent location, originLocation * tells which location was used to start the ingestion loop. + * @param cache - A cache for storing values local to this processor and the current entity. * @returns The same entity or a modified version of it */ preProcessEntity?( @@ -59,13 +69,14 @@ export type CatalogProcessor = { location: LocationSpec, emit: CatalogProcessorEmit, originLocation: LocationSpec, + cache: CatalogProcessorCache, ): Promise; /** * Validates the entity as a known entity kind, after it has been pre- * processed and has passed through basic overall validation. * - * @param entity The entity to validate + * @param entity - The entity to validate * @returns Resolves to true, if the entity was of a kind that was known and * handled by this processor, and was found to be valid. Resolves to false, * if the entity was not of a kind that was known by this processor. @@ -77,23 +88,25 @@ export type CatalogProcessor = { /** * Post-processes an emitted entity, after it has been validated. * - * @param entity The entity to process - * @param location The location that the entity came from - * @param emit A sink for auxiliary items resulting from the processing + * @param entity - The entity to process + * @param location - The location that the entity came from + * @param emit - A sink for auxiliary items resulting from the processing + * @param cache - A cache for storing values local to this processor and the current entity. * @returns The same entity or a modified version of it */ postProcessEntity?( entity: Entity, location: LocationSpec, emit: CatalogProcessorEmit, + cache: CatalogProcessorCache, ): Promise; /** * Handles an emitted error. * - * @param error The error - * @param location The location where the error occurred - * @param emit A sink for items resulting from this handling + * @param error - The error + * @param location - The location where the error occurred + * @param emit - A sink for items resulting from this handling * @returns Nothing */ handleError?( @@ -113,6 +126,34 @@ export type CatalogProcessorParser = (options: { location: LocationSpec; }) => AsyncIterable; +/** + * A cache for storing data during processing. + * + * The values stored in the cache are always local to each processor, meaning + * no processor can see cache values from other processors. + * + * The cache instance provided to the CatalogProcessor is also scoped to the + * entity being processed, meaning that each processor run can't see cache + * values from processing runs for other entities. + * + * Values that are set during a processing run will only be visible in the directly + * following run. The cache will be overwritten every run unless no new cache items + * are written, in which case the existing values remain in the cache. + * + * @public + */ +export interface CatalogProcessorCache { + /** + * Retrieve a value from the cache. + */ + get(key: string): Promise; + + /** + * Store a value in the cache. + */ + set(key: string, value: ItemType): Promise; +} + export type CatalogProcessorEmit = (generated: CatalogProcessorResult) => void; export type CatalogProcessorLocationResult = { diff --git a/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.test.ts b/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.test.ts index f7dede4adb..430d7f251f 100644 --- a/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.test.ts +++ b/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.test.ts @@ -28,6 +28,7 @@ describe('DefaultCatalogProcessingEngine', () => { transaction: jest.fn(), getProcessableEntities: jest.fn(), updateProcessedEntity: jest.fn(), + updateEntityCache: jest.fn(), } as unknown as jest.Mocked; const orchestrator: jest.Mocked = { process: jest.fn(), @@ -55,7 +56,7 @@ describe('DefaultCatalogProcessingEngine', () => { relations: [], errors: [], deferredEntities: [], - state: new Map(), + state: {}, }); const engine = new DefaultCatalogProcessingEngine( getVoidLogger(), @@ -84,7 +85,7 @@ describe('DefaultCatalogProcessingEngine', () => { metadata: { name: 'test' }, }, resultHash: '', - state: new Map(), + state: [] as any, nextUpdateAt: DateTime.now(), lastDiscoveryAt: DateTime.now(), }, @@ -100,7 +101,7 @@ describe('DefaultCatalogProcessingEngine', () => { kind: 'Location', metadata: { name: 'test' }, }, - state: expect.anything(), + state: [], // State is forwarded as is, even if it's a bad format }); }); await engine.stop(); @@ -117,7 +118,7 @@ describe('DefaultCatalogProcessingEngine', () => { relations: [], errors: [], deferredEntities: [], - state: new Map(), + state: {}, }); const engine = new DefaultCatalogProcessingEngine( getVoidLogger(), @@ -147,7 +148,7 @@ describe('DefaultCatalogProcessingEngine', () => { metadata: { name: 'test' }, }, resultHash: '', - state: new Map(), + state: { cache: { myProcessor: { myKey: 'myValue' } } }, nextUpdateAt: DateTime.now(), lastDiscoveryAt: DateTime.now(), }, @@ -163,7 +164,7 @@ describe('DefaultCatalogProcessingEngine', () => { kind: 'Location', metadata: { name: 'test' }, }, - state: expect.anything(), + state: { cache: { myProcessor: { myKey: 'myValue' } } }, }); }); await engine.stop(); @@ -181,7 +182,7 @@ describe('DefaultCatalogProcessingEngine', () => { entityRef: '', unprocessedEntity: entity, resultHash: 'the matching hash', - state: new Map(), + state: {}, nextUpdateAt: DateTime.now(), lastDiscoveryAt: DateTime.now(), }; @@ -194,7 +195,7 @@ describe('DefaultCatalogProcessingEngine', () => { relations: [], errors: [], deferredEntities: [], - state: new Map(), + state: {}, }); const engine = new DefaultCatalogProcessingEngine( @@ -221,16 +222,100 @@ describe('DefaultCatalogProcessingEngine', () => { expect(hash.digest).toBeCalledTimes(1); expect(db.updateProcessedEntity).toBeCalledTimes(1); }); + expect(db.updateEntityCache).not.toHaveBeenCalled(); db.getProcessableEntities .mockReset() - .mockResolvedValueOnce({ items: [refreshState] }) + .mockResolvedValueOnce({ + items: [{ ...refreshState, state: { something: 'different' } }], + }) .mockResolvedValue({ items: [] }); await waitForExpect(() => { expect(orchestrator.process).toBeCalledTimes(2); expect(hash.digest).toBeCalledTimes(2); expect(db.updateProcessedEntity).toBeCalledTimes(1); + expect(db.updateEntityCache).toBeCalledTimes(1); + }); + expect(db.updateEntityCache).toHaveBeenCalledWith(expect.anything(), { + id: '', + state: { ttl: 5 }, + }); + await engine.stop(); + }); + + it('should decrease the state ttl if there are errors', async () => { + const entity = { + apiVersion: '1', + kind: 'Location', + metadata: { name: 'test' }, + }; + + const refreshState = { + id: '', + entityRef: '', + unprocessedEntity: entity, + resultHash: 'the matching hash', + state: { some: 'value', ttl: 1 }, + nextUpdateAt: DateTime.now(), + lastDiscoveryAt: DateTime.now(), + }; + + hash.digest.mockReturnValue('the matching hash'); + + orchestrator.process.mockResolvedValue({ + ok: false, + errors: [], + }); + + const engine = new DefaultCatalogProcessingEngine( + getVoidLogger(), + [], + db, + orchestrator, + stitcher, + () => hash, + ); + + db.transaction.mockImplementation(cb => cb((() => {}) as any)); + + await engine.start(); + + db.getProcessableEntities + .mockResolvedValueOnce({ + items: [refreshState], + }) + .mockResolvedValue({ items: [] }); + + await waitForExpect(() => { + expect(db.updateEntityCache).toBeCalledTimes(1); + }); + + expect(db.updateEntityCache).toHaveBeenCalledWith(expect.anything(), { + id: '', + state: { some: 'value', ttl: 0 }, + }); + + // Second run, the TTL should now reach 0 and the cache should be cleared + db.getProcessableEntities + .mockResolvedValueOnce({ + items: [ + { + ...refreshState, + state: db.updateEntityCache.mock.calls[0][1].state, + }, + ], + }) + .mockResolvedValue({ items: [] }); + + db.updateEntityCache.mockReset(); + await waitForExpect(() => { + expect(db.updateEntityCache).toBeCalledTimes(1); + }); + + expect(db.updateEntityCache).toHaveBeenCalledWith(expect.anything(), { + id: '', + state: {}, }); await engine.stop(); diff --git a/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.ts b/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.ts index 4d2d74f05b..346a912009 100644 --- a/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.ts +++ b/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.ts @@ -38,6 +38,8 @@ import { EntityProviderMutation, } from './types'; +const CACHE_TTL = 5; + class Connection implements EntityProviderConnection { readonly validateEntityEnvelope = entityEnvelopeSchemaValidator(); @@ -151,6 +153,29 @@ export class DefaultCatalogProcessingEngine implements CatalogProcessingEngine { track.markProcessorsCompleted(result); + if (result.ok) { + if (stableStringify(state) !== stableStringify(result.state)) { + await this.processingDatabase.transaction(async tx => { + await this.processingDatabase.updateEntityCache(tx, { + id, + state: { + ttl: CACHE_TTL, + ...result.state, + }, + }); + }); + } + } else { + const maybeTtl = state?.ttl; + const ttl = Number.isInteger(maybeTtl) ? (maybeTtl as number) : 0; + await this.processingDatabase.transaction(async tx => { + await this.processingDatabase.updateEntityCache(tx, { + id, + state: ttl > 0 ? { ...state, ttl: ttl - 1 } : {}, + }); + }); + } + for (const error of result.errors) { // TODO(freben): Try to extract the location out of the unprocessed // entity and add as meta to the log lines @@ -167,8 +192,7 @@ export class DefaultCatalogProcessingEngine implements CatalogProcessingEngine { hashBuilder = hashBuilder .update(stableStringify({ ...result.completedEntity })) .update(stableStringify([...result.deferredEntities])) - .update(stableStringify([...result.relations])) - .update(stableStringify(Object.fromEntries(result.state))); + .update(stableStringify([...result.relations])); } const resultHash = hashBuilder.digest('hex'); @@ -208,7 +232,6 @@ export class DefaultCatalogProcessingEngine implements CatalogProcessingEngine { id, processedEntity: result.completedEntity, resultHash, - state: result.state, errors: errorsString, relations: result.relations, deferredEntities: result.deferredEntities, diff --git a/plugins/catalog-backend/src/next/DefaultLocationService.test.ts b/plugins/catalog-backend/src/next/DefaultLocationService.test.ts index 939f54e793..78dd9c1009 100644 --- a/plugins/catalog-backend/src/next/DefaultLocationService.test.ts +++ b/plugins/catalog-backend/src/next/DefaultLocationService.test.ts @@ -39,7 +39,7 @@ describe('DefaultLocationServiceTest', () => { store.listLocations.mockResolvedValueOnce([]); orchestrator.process.mockResolvedValueOnce({ ok: true, - state: new Map(), + state: {}, completedEntity: { apiVersion: 'backstage.io/v1alpha1', kind: 'Location', @@ -65,7 +65,7 @@ describe('DefaultLocationServiceTest', () => { orchestrator.process.mockResolvedValueOnce({ ok: true, - state: new Map(), + state: {}, completedEntity: { apiVersion: 'backstage.io/v1alpha1', kind: 'Component', @@ -124,7 +124,7 @@ describe('DefaultLocationServiceTest', () => { }; orchestrator.process.mockResolvedValueOnce({ ok: true, - state: new Map(), + state: {}, completedEntity: { apiVersion: 'backstage.io/v1alpha1', kind: 'Component', @@ -151,7 +151,7 @@ describe('DefaultLocationServiceTest', () => { it('should return exists false when the location does not exist beforehand', async () => { orchestrator.process.mockResolvedValueOnce({ ok: true, - state: new Map(), + state: {}, completedEntity: { apiVersion: 'backstage.io/v1alpha1', kind: 'Component', diff --git a/plugins/catalog-backend/src/next/DefaultLocationService.ts b/plugins/catalog-backend/src/next/DefaultLocationService.ts index 7d485bea40..678ce9791c 100644 --- a/plugins/catalog-backend/src/next/DefaultLocationService.ts +++ b/plugins/catalog-backend/src/next/DefaultLocationService.ts @@ -87,7 +87,6 @@ export class DefaultLocationService implements LocationService { { entity, locationKey: `${spec.type}:${spec.target}` }, ]; const entities: Entity[] = []; - const state = new Map(); // ignored while (unprocessedEntities.length) { const currentEntity = unprocessedEntities.pop(); if (!currentEntity) { @@ -95,7 +94,7 @@ export class DefaultLocationService implements LocationService { } const processed = await this.orchestrator.process({ entity: currentEntity.entity, - state, + state: {}, // we process without the existing cache }); if (processed.ok) { diff --git a/plugins/catalog-backend/src/next/DefaultRefreshService.test.ts b/plugins/catalog-backend/src/next/DefaultRefreshService.test.ts index 5df57c6897..79ced3ea43 100644 --- a/plugins/catalog-backend/src/next/DefaultRefreshService.test.ts +++ b/plugins/catalog-backend/src/next/DefaultRefreshService.test.ts @@ -138,7 +138,7 @@ describe('Refresh integration', () => { relations: [], errors: [], deferredEntities, - state: new Map(), + state: {}, }; }, }, diff --git a/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts index afc3806d36..c10a37396b 100644 --- a/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts +++ b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts @@ -17,7 +17,6 @@ import { getVoidLogger } from '@backstage/backend-common'; import { TestDatabaseId, TestDatabases } from '@backstage/backend-test-utils'; import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; -import { JsonObject } from '@backstage/config'; import { Knex } from 'knex'; import * as uuid from 'uuid'; import { Logger } from 'winston'; @@ -213,7 +212,6 @@ describe('Default Processing Database', () => { id, processedEntity, resultHash: '', - state: new Map(), relations: [], deferredEntities: [], }), @@ -232,7 +230,6 @@ describe('Default Processing Database', () => { id, processedEntity, resultHash: '', - state: new Map(), relations: [], deferredEntities: [], locationKey: 'key', @@ -285,15 +282,11 @@ describe('Default Processing Database', () => { last_discovery_at: '2021-04-01 13:37:00', }); - const state = new Map(); - state.set('hello', { t: 'something' }); - await db.transaction(tx => db.updateProcessedEntity(tx, { id, processedEntity, resultHash: '', - state, relations: [], deferredEntities: [], locationKey: 'key', @@ -308,9 +301,6 @@ describe('Default Processing Database', () => { expect(entities[0].processed_entity).toEqual( JSON.stringify(processedEntity), ); - expect(entities[0].cache).toEqual( - JSON.stringify(Object.fromEntries(state)), - ); expect(entities[0].errors).toEqual("['something broke']"); expect(entities[0].location_key).toEqual('key'); }, @@ -352,7 +342,6 @@ describe('Default Processing Database', () => { id, processedEntity, resultHash: '', - state: new Map(), relations: relations, deferredEntities: [], }), @@ -404,7 +393,6 @@ describe('Default Processing Database', () => { id, processedEntity, resultHash: '', - state: new Map(), relations: [], deferredEntities, }), @@ -422,6 +410,54 @@ describe('Default Processing Database', () => { ); }); + describe('updateEntityCache', () => { + it.each(databases.eachSupportedId())( + 'updates the entityCache, %p', + async databaseId => { + const { knex, db } = await createDatabase(databaseId); + const id = '123'; + await insertRefreshStateRow(knex, { + entity_id: id, + entity_ref: 'location:default/fakelocation', + unprocessed_entity: '{}', + processed_entity: '{}', + errors: '[]', + next_update_at: '2021-04-01 13:37:00', + last_discovery_at: '2021-04-01 13:37:00', + }); + + const state = { hello: { t: 'something' } }; + + await db.transaction(tx => + db.updateEntityCache(tx, { + id, + state, + }), + ); + + const entities = await knex( + 'refresh_state', + ).select(); + expect(entities.length).toBe(1); + expect(entities[0].cache).toEqual(JSON.stringify(state)); + + await db.transaction(tx => + db.updateEntityCache(tx, { + id, + state: undefined, + }), + ); + + const entities2 = await knex( + 'refresh_state', + ).select(); + expect(entities2.length).toBe(1); + expect(entities2[0].cache).toEqual('{}'); + }, + 60_000, + ); + }); + describe('replaceUnprocessedEntities', () => { const createLocations = async (db: Knex, entityRefs: string[]) => { for (const ref of entityRefs) { diff --git a/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.ts b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.ts index 8ba9d54b8f..33f01b14eb 100644 --- a/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.ts +++ b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.ts @@ -15,7 +15,6 @@ */ import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; -import { JsonObject } from '@backstage/config'; import { ConflictError, NotFoundError } from '@backstage/errors'; import { Knex } from 'knex'; import lodash from 'lodash'; @@ -41,6 +40,7 @@ import { UpdateProcessedEntityOptions, ListAncestorsOptions, ListAncestorsResult, + UpdateEntityCacheOptions, } from './types'; // The number of items that are sent per batch to the database layer, when @@ -70,7 +70,6 @@ export class DefaultProcessingDatabase implements ProcessingDatabase { id, processedEntity, resultHash, - state, errors, relations, deferredEntities, @@ -80,7 +79,6 @@ export class DefaultProcessingDatabase implements ProcessingDatabase { .update({ processed_entity: JSON.stringify(processedEntity), result_hash: resultHash, - cache: JSON.stringify(Object.fromEntries(state || [])), errors, location_key: locationKey, }) @@ -141,6 +139,18 @@ export class DefaultProcessingDatabase implements ProcessingDatabase { .where('entity_id', id); } + async updateEntityCache( + txOpaque: Transaction, + options: UpdateEntityCacheOptions, + ): Promise { + const tx = txOpaque as Knex.Transaction; + const { id, state } = options; + + await tx('refresh_state') + .update({ cache: JSON.stringify(state ?? {}) }) + .where('entity_id', id); + } + private deduplicateRelations(rows: DbRelationsRow[]): DbRelationsRow[] { return lodash.uniqBy( rows, @@ -508,9 +518,7 @@ export class DefaultProcessingDatabase implements ProcessingDatabase { resultHash: i.result_hash || '', nextUpdateAt: timestampToDateTime(i.next_update_at), lastDiscoveryAt: timestampToDateTime(i.last_discovery_at), - state: i.cache - ? JSON.parse(i.cache) - : new Map(), + state: i.cache ? JSON.parse(i.cache) : undefined, errors: i.errors, locationKey: i.location_key, } as RefreshStateItem), diff --git a/plugins/catalog-backend/src/next/database/types.ts b/plugins/catalog-backend/src/next/database/types.ts index 3dfe8e5592..aabd28afc8 100644 --- a/plugins/catalog-backend/src/next/database/types.ts +++ b/plugins/catalog-backend/src/next/database/types.ts @@ -36,13 +36,17 @@ export type UpdateProcessedEntityOptions = { id: string; processedEntity: Entity; resultHash: string; - state?: Map; errors?: string; relations: EntityRelationSpec[]; deferredEntities: DeferredEntity[]; locationKey?: string; }; +export type UpdateEntityCacheOptions = { + id: string; + state?: JsonObject; +}; + export type UpdateProcessedEntityErrorsOptions = { id: string; errors?: string; @@ -57,7 +61,7 @@ export type RefreshStateItem = { resultHash: string; nextUpdateAt: DateTime; lastDiscoveryAt: DateTime; // remove? - state: Map; + state?: JsonObject; errors?: string; locationKey?: string; }; @@ -118,6 +122,14 @@ export interface ProcessingDatabase { options: UpdateProcessedEntityOptions, ): Promise; + /** + * Updates the cache associated with an entity. + */ + updateEntityCache( + txOpaque: Transaction, + options: UpdateEntityCacheOptions, + ): Promise; + /** * Updates only the errors of a processed entity */ diff --git a/plugins/catalog-backend/src/next/processing/DefaultCatalogProcessingOrchestrator.test.ts b/plugins/catalog-backend/src/next/processing/DefaultCatalogProcessingOrchestrator.test.ts index c93734b37f..098f3ac14d 100644 --- a/plugins/catalog-backend/src/next/processing/DefaultCatalogProcessingOrchestrator.test.ts +++ b/plugins/catalog-backend/src/next/processing/DefaultCatalogProcessingOrchestrator.test.ts @@ -16,71 +16,227 @@ import { getVoidLogger } from '@backstage/backend-common'; import { - EntityPolicy, + Entity, + EntityPolicies, LocationEntity, + LocationSpec, LOCATION_ANNOTATION, ORIGIN_LOCATION_ANNOTATION, } from '@backstage/catalog-model'; -import { ScmIntegrationRegistry } from '@backstage/integration'; +import { ScmIntegrations } from '@backstage/integration'; import { CatalogProcessor, + CatalogProcessorCache, + CatalogProcessorEmit, CatalogProcessorParser, results, } from '../../ingestion'; import { CatalogRulesEnforcer } from '../../ingestion/CatalogRules'; import { DefaultCatalogProcessingOrchestrator } from './DefaultCatalogProcessingOrchestrator'; +import { defaultEntityDataParser } from '../../ingestion/processors/util/parse'; +import { ConfigReader } from '@backstage/config'; + +class FooBarProcessor implements CatalogProcessor { + getProcessorName = () => 'foo-bar'; + + async validateEntityKind(entity: Entity) { + return entity.kind.toLocaleLowerCase('en-US') === 'foobar'; + } + + async postProcessEntity( + entity: Entity, + _location: LocationSpec, + emit: CatalogProcessorEmit, + cache: CatalogProcessorCache, + ) { + if (await cache.get('emit')) { + emit( + results.entity( + { type: 'url', target: './new-place' }, + { + apiVersion: 'my-api/v1', + kind: 'FooBar', + metadata: { + name: 'my-new-foo-bar', + }, + }, + ), + ); + emit( + results.relation({ + type: 'my-type', + source: { kind: 'foobar', name: 'my-source', namespace: 'default' }, + target: { kind: 'foobar', name: 'my-target', namespace: 'default' }, + }), + ); + } + return entity; + } +} describe('DefaultCatalogProcessingOrchestrator', () => { - it('enforces catalog rules', async () => { - const entity: LocationEntity = { - apiVersion: 'backstage.io/v1beta1', - kind: 'Location', + describe('basic processing', () => { + const entity = { + apiVersion: 'my-api/v1', + kind: 'FooBar', metadata: { - name: 'l', + name: 'my-foo-bar', annotations: { - [ORIGIN_LOCATION_ANNOTATION]: 'url:https://example.com/origin.yaml', - [LOCATION_ANNOTATION]: 'url:https://example.com/origin.yaml', + [LOCATION_ANNOTATION]: 'url:./here', + [ORIGIN_LOCATION_ANNOTATION]: 'url:./there', }, }, - spec: { - type: 'url', - target: 'http://example.com/entity.yaml', - }, - }; - - const processor: jest.Mocked = { - validateEntityKind: jest.fn(async () => true), - readLocation: jest.fn(async (_l, _o, emit) => { - emit(results.entity({ type: 't', target: 't' }, entity)); - return true; - }), - }; - const integrations: jest.Mocked = {} as any; - const parser: CatalogProcessorParser = jest.fn(); - const policy: jest.Mocked = { - enforce: jest.fn(async x => x), - }; - const rulesEnforcer: jest.Mocked = { - isAllowed: jest.fn(), }; const orchestrator = new DefaultCatalogProcessingOrchestrator({ - processors: [processor], - integrations, + processors: [new FooBarProcessor()], + integrations: ScmIntegrations.fromConfig(new ConfigReader({})), logger: getVoidLogger(), - parser, - policy, - rulesEnforcer, + parser: defaultEntityDataParser, + policy: EntityPolicies.allOf([]), + rulesEnforcer: { isAllowed: () => true }, }); - rulesEnforcer.isAllowed.mockReturnValueOnce(true); - await expect( - orchestrator.process({ entity, state: new Map() }), - ).resolves.toEqual(expect.objectContaining({ ok: true })); + it('runs a minimal processing', async () => { + await expect(orchestrator.process({ entity })).resolves.toEqual({ + ok: true, + completedEntity: entity, + deferredEntities: [], + errors: [], + relations: [], + state: { + cache: {}, + }, + }); + }); - rulesEnforcer.isAllowed.mockReturnValueOnce(false); - await expect( - orchestrator.process({ entity, state: new Map() }), - ).resolves.toEqual(expect.objectContaining({ ok: false })); + it('emits some things', async () => { + await expect( + orchestrator.process({ + entity, + state: { cache: { 'foo-bar': { emit: true } } }, + }), + ).resolves.toEqual({ + ok: true, + completedEntity: entity, + deferredEntities: [ + { + locationKey: 'url:./new-place', + entity: { + apiVersion: 'my-api/v1', + kind: 'FooBar', + metadata: { + name: 'my-new-foo-bar', + annotations: { + [LOCATION_ANNOTATION]: 'url:./new-place', + [ORIGIN_LOCATION_ANNOTATION]: 'url:./there', + }, + }, + }, + }, + ], + errors: [], + relations: [ + { + type: 'my-type', + source: { kind: 'foobar', name: 'my-source', namespace: 'default' }, + target: { kind: 'foobar', name: 'my-target', namespace: 'default' }, + }, + ], + state: { + cache: { 'foo-bar': { emit: true } }, + }, + }); + }); + + it('accepts any state input', async () => { + await expect( + orchestrator.process({ entity, state: null as any }), + ).resolves.toMatchObject({ + ok: true, + }); + await expect( + orchestrator.process({ entity, state: [] as any }), + ).resolves.toMatchObject({ + ok: true, + }); + await expect( + orchestrator.process({ entity, state: Symbol() as any }), + ).resolves.toMatchObject({ + ok: true, + }); + await expect( + orchestrator.process({ entity, state: undefined }), + ).resolves.toMatchObject({ + ok: true, + }); + await expect( + orchestrator.process({ entity, state: 3 as any }), + ).resolves.toMatchObject({ + ok: true, + }); + await expect( + orchestrator.process({ entity, state: '}{' as any }), + ).resolves.toMatchObject({ + ok: true, + }); + await expect( + orchestrator.process({ entity, state: { cache: null } }), + ).resolves.toMatchObject({ + ok: true, + }); + }); + }); + + describe('rules', () => { + it('enforces catalog rules', async () => { + const entity: LocationEntity = { + apiVersion: 'backstage.io/v1beta1', + kind: 'Location', + metadata: { + name: 'l', + annotations: { + [ORIGIN_LOCATION_ANNOTATION]: 'url:https://example.com/origin.yaml', + [LOCATION_ANNOTATION]: 'url:https://example.com/origin.yaml', + }, + }, + spec: { + type: 'url', + target: 'http://example.com/entity.yaml', + }, + }; + + const integrations = ScmIntegrations.fromConfig(new ConfigReader({})); + const processor: jest.Mocked = { + validateEntityKind: jest.fn(async () => true), + readLocation: jest.fn(async (_l, _o, emit) => { + emit(results.entity({ type: 't', target: 't' }, entity)); + return true; + }), + }; + const parser: CatalogProcessorParser = jest.fn(); + const rulesEnforcer: jest.Mocked = { + isAllowed: jest.fn(), + }; + + const orchestrator = new DefaultCatalogProcessingOrchestrator({ + processors: [processor], + integrations, + logger: getVoidLogger(), + parser, + policy: EntityPolicies.allOf([]), + rulesEnforcer, + }); + + rulesEnforcer.isAllowed.mockReturnValueOnce(true); + await expect( + orchestrator.process({ entity, state: {} }), + ).resolves.toEqual(expect.objectContaining({ ok: true })); + + rulesEnforcer.isAllowed.mockReturnValueOnce(false); + await expect( + orchestrator.process({ entity, state: {} }), + ).resolves.toEqual(expect.objectContaining({ ok: false })); + }); }); }); diff --git a/plugins/catalog-backend/src/next/processing/DefaultCatalogProcessingOrchestrator.ts b/plugins/catalog-backend/src/next/processing/DefaultCatalogProcessingOrchestrator.ts index 384060125e..6261c0e2ff 100644 --- a/plugins/catalog-backend/src/next/processing/DefaultCatalogProcessingOrchestrator.ts +++ b/plugins/catalog-backend/src/next/processing/DefaultCatalogProcessingOrchestrator.ts @@ -24,6 +24,7 @@ import { stringifyLocationReference, } from '@backstage/catalog-model'; import { ConflictError, InputError, NotAllowedError } from '@backstage/errors'; +import { JsonValue } from '@backstage/config'; import { ScmIntegrationRegistry } from '@backstage/integration'; import path from 'path'; import { Logger } from 'winston'; @@ -45,14 +46,17 @@ import { toAbsoluteUrl, validateEntity, validateEntityEnvelope, + isObject, } from './util'; import { CatalogRulesEnforcer } from '../../ingestion/CatalogRules'; +import { ProcessorCacheManager } from './ProcessorCacheManager'; type Context = { entityRef: string; location: LocationSpec; originLocation: LocationSpec; collector: ProcessorOutputCollector; + cache: ProcessorCacheManager; }; export class DefaultCatalogProcessingOrchestrator @@ -72,17 +76,23 @@ export class DefaultCatalogProcessingOrchestrator async process( request: EntityProcessingRequest, ): Promise { - return this.processSingleEntity(request.entity); + return this.processSingleEntity(request.entity, request.state); } private async processSingleEntity( unprocessedEntity: Entity, + state: JsonValue | undefined, ): Promise { const collector = new ProcessorOutputCollector( this.options.logger, unprocessedEntity, ); + // Cache that is scoped to the entity and processor + const cache = new ProcessorCacheManager( + isObject(state) && isObject(state.cache) ? state.cache : {}, + ); + try { // This will be checked and mutated step by step below let entity: Entity = unprocessedEntity; @@ -108,6 +118,7 @@ export class DefaultCatalogProcessingOrchestrator originLocation: parseLocationReference( getEntityOriginLocationRef(entity), ), + cache, collector, }; @@ -145,7 +156,7 @@ export class DefaultCatalogProcessingOrchestrator return { ...collectorResults, completedEntity: entity, - state: new Map(), + state: { cache: cache.collect() }, ok: true, }; } catch (error) { @@ -173,6 +184,7 @@ export class DefaultCatalogProcessingOrchestrator context.location, context.collector.onEmit, context.originLocation, + context.cache.forProcessor(processor), ); } catch (e) { throw new InputError( @@ -306,6 +318,7 @@ export class DefaultCatalogProcessingOrchestrator false, context.collector.onEmit, this.options.parser, + context.cache.forProcessor(processor, target), ); if (read) { didRead = true; @@ -343,6 +356,7 @@ export class DefaultCatalogProcessingOrchestrator result, context.location, context.collector.onEmit, + context.cache.forProcessor(processor), ); } catch (e) { throw new InputError( diff --git a/plugins/catalog-backend/src/next/processing/ProcessorCacheManager.test.ts b/plugins/catalog-backend/src/next/processing/ProcessorCacheManager.test.ts new file mode 100644 index 0000000000..44c96da1ca --- /dev/null +++ b/plugins/catalog-backend/src/next/processing/ProcessorCacheManager.test.ts @@ -0,0 +1,118 @@ +/* + * Copyright 2021 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 { CatalogProcessor } from '../../ingestion/processors'; +import { ProcessorCacheManager } from './ProcessorCacheManager'; + +class MyProcessor implements CatalogProcessor { + getProcessorName = () => 'my-processor'; +} + +class OtherProcessor implements CatalogProcessor {} + +describe('ProcessorCacheManager', () => { + const myProcessor = new MyProcessor(); + const otherProcessor = new OtherProcessor(); + + it('should forward existing state and collect new state', async () => { + const cache = new ProcessorCacheManager({ + 'my-processor': { 'my-key': 'my-value' }, + }); + + // Should be empty to begin with + expect(cache.collect()).toEqual({}); + + // instance should be cached + const processorCache = cache.forProcessor(myProcessor); + expect(processorCache).toBe(cache.forProcessor(myProcessor)); + + // Initial values should be visible, writes should not + await expect(processorCache.get('my-key')).resolves.toBe( + 'my-value', + ); + + // If set hasn't been called yet we should get the existing data + expect(cache.collect()).toEqual({ + 'my-processor': { 'my-key': 'my-value' }, + }); + + processorCache.set('my-new-key', 'my-new-value'); + // Once set has been called the old values should disappear + expect(cache.collect()).toEqual({ + 'my-processor': { 'my-new-key': 'my-new-value' }, + }); + + // Getting the cache should return the initial state value + await expect(processorCache.get('my-key')).resolves.toBe( + 'my-value', + ); + await expect( + processorCache.get('my-new-key'), + ).resolves.toBeUndefined(); + + // There should be isolation between processors + await expect( + cache.forProcessor(otherProcessor).get('my-key'), + ).resolves.toBeUndefined(); + + // Collecting the state and passing it to a new manager should make the new values visible + const newCache = new ProcessorCacheManager(cache.collect()); + await expect( + newCache.forProcessor(myProcessor).get('my-new-key'), + ).resolves.toBe('my-new-value'); + }); +}); + +describe('ScopedProcessorCache', () => { + const myProcessor = new MyProcessor(); + + it('should forward existing state and collect new state', async () => { + const cache = new ProcessorCacheManager({ + 'my-processor': { 'scope-1': { 'my-key': 'my-value' } }, + }); + + const scopedCache1 = cache.forProcessor(myProcessor, 'scope-1'); + const scopedCache2 = cache.forProcessor(myProcessor, 'scope-2'); + + // Should be empty to begin with + expect(cache.collect()).toEqual({ + 'my-processor': { 'scope-1': { 'my-key': 'my-value' } }, + }); + + await scopedCache2.set('my-new-key-2', 'my-new-value-2'); + + expect(cache.collect()).toEqual({ + 'my-processor': { + 'scope-1': { 'my-key': 'my-value' }, + 'scope-2': { 'my-new-key-2': 'my-new-value-2' }, + }, + }); + + await scopedCache1.set('my-new-key', 'my-new-value'); + + await expect(scopedCache1.get('my-key')).resolves.toBe('my-value'); + await expect( + scopedCache1.get('my-new-key'), + ).resolves.toBeUndefined(); + + expect(cache.collect()).toEqual({ + 'my-processor': { + 'scope-1': { 'my-new-key': 'my-new-value' }, + 'scope-2': { 'my-new-key-2': 'my-new-value-2' }, + }, + }); + }); +}); diff --git a/plugins/catalog-backend/src/next/processing/ProcessorCacheManager.ts b/plugins/catalog-backend/src/next/processing/ProcessorCacheManager.ts new file mode 100644 index 0000000000..16945b7cc9 --- /dev/null +++ b/plugins/catalog-backend/src/next/processing/ProcessorCacheManager.ts @@ -0,0 +1,130 @@ +/* + * Copyright 2021 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 { JsonObject, JsonValue } from '@backstage/config'; +import { CatalogProcessor } from '../../ingestion/processors'; +import { CatalogProcessorCache } from '../../ingestion/processors/types'; +import { isObject } from './util'; + +class SingleProcessorSubCache implements CatalogProcessorCache { + private newState?: JsonObject; + + constructor(private readonly existingState?: JsonObject) {} + + async get( + key: string, + ): Promise { + return this.existingState?.[key] as ItemType | undefined; + } + + async set( + key: string, + value: ItemType, + ): Promise { + if (!this.newState) { + this.newState = {}; + } + + this.newState[key] = value; + } + + collect(): JsonObject | undefined { + return this.newState ?? this.existingState; + } +} + +class SingleProcessorCache implements CatalogProcessorCache { + private newState?: JsonObject; + private subCaches: Map = new Map(); + + constructor(private readonly existingState?: JsonObject) {} + + async get( + key: string, + ): Promise { + return this.existingState?.[key] as ItemType | undefined; + } + + async set( + key: string, + value: ItemType, + ): Promise { + if (!this.newState) { + this.newState = {}; + } + + this.newState[key] = value; + } + + withKey(key: string) { + const existingSubCache = this.subCaches.get(key); + if (existingSubCache) { + return existingSubCache; + } + const existing = this.existingState?.[key]; + const subCache = new SingleProcessorSubCache( + isObject(existing) ? existing : undefined, + ); + this.subCaches.set(key, subCache); + return subCache; + } + + collect(): JsonObject | undefined { + let obj = this.newState ?? this.existingState; + for (const [key, subCache] of this.subCaches) { + const subCacheValue = subCache.collect(); + if (subCacheValue) { + obj = { ...obj, [key]: subCacheValue }; + } + } + return obj; + } +} + +export class ProcessorCacheManager { + private caches = new Map(); + + constructor(private readonly existingState: JsonObject) {} + + forProcessor( + processor: CatalogProcessor, + key?: string, + ): CatalogProcessorCache { + // constructor name will be deprecated in the future when we make `getProcessorName` required in the implementation + const name = processor.getProcessorName?.() ?? processor.constructor.name; + const cache = this.caches.get(name); + if (cache) { + return key ? cache.withKey(key) : cache; + } + + const existing = this.existingState[name]; + + const newCache = new SingleProcessorCache( + isObject(existing) ? existing : undefined, + ); + this.caches.set(name, newCache); + return key ? newCache.withKey(key) : newCache; + } + + collect(): JsonObject { + const result: JsonObject = {}; + for (const [key, value] of this.caches.entries()) { + result[key] = value.collect(); + } + + return result; + } +} diff --git a/plugins/catalog-backend/src/next/processing/types.ts b/plugins/catalog-backend/src/next/processing/types.ts index 9e30d07404..8b533714ce 100644 --- a/plugins/catalog-backend/src/next/processing/types.ts +++ b/plugins/catalog-backend/src/next/processing/types.ts @@ -19,13 +19,13 @@ import { JsonObject } from '@backstage/config'; export type EntityProcessingRequest = { entity: Entity; - state: Map; // Versions for multiple deployments etc + state?: JsonObject; // Versions for multiple deployments etc }; export type EntityProcessingResult = | { ok: true; - state: Map; + state: JsonObject; completedEntity: Entity; deferredEntities: DeferredEntity[]; relations: EntityRelationSpec[]; diff --git a/plugins/catalog-backend/src/next/processing/util.ts b/plugins/catalog-backend/src/next/processing/util.ts index 8b06a76d16..ad8fe8be60 100644 --- a/plugins/catalog-backend/src/next/processing/util.ts +++ b/plugins/catalog-backend/src/next/processing/util.ts @@ -24,6 +24,7 @@ import { ORIGIN_LOCATION_ANNOTATION, stringifyEntityRef, } from '@backstage/catalog-model'; +import { JsonObject, JsonValue } from '@backstage/config'; import { InputError } from '@backstage/errors'; import { ScmIntegrationRegistry } from '@backstage/integration'; import path from 'path'; @@ -76,6 +77,10 @@ export function toAbsoluteUrl( } } +export function isObject(value: JsonValue | undefined): value is JsonObject { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + export const validateEntity = entitySchemaValidator(); export const validateEntityEnvelope = entityEnvelopeSchemaValidator();