catalog-backend: implement caching in the processing orchestrator
Co-authored-by: Johan Haals <johan.haals@gmail.com> Co-authored-by: Fredrik Adelöw <freben@gmail.com> Co-authored-by: blam <ben@blam.sh> Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
committed by
Johan Haals
parent
ec2cc1ec56
commit
33333c9416
@@ -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 ${
|
||||
|
||||
@@ -44,6 +44,10 @@ type CacheItem = {
|
||||
export class UrlReaderProcessor implements CatalogProcessor {
|
||||
constructor(private readonly options: Options) {}
|
||||
|
||||
getProcessorName() {
|
||||
return 'url-reader';
|
||||
}
|
||||
|
||||
async readLocation(
|
||||
location: LocationSpec,
|
||||
optional: boolean,
|
||||
@@ -74,13 +78,11 @@ export class UrlReaderProcessor implements CatalogProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
const isOnlyEntities = parseResults.every(
|
||||
(r): r is CatalogProcessorEntityResult => r.type === 'entity',
|
||||
);
|
||||
const isOnlyEntities = parseResults.every(r => r.type === 'entity');
|
||||
if (newEtag && isOnlyEntities) {
|
||||
await cache.set<CacheItem>(CACHE_KEY, {
|
||||
etag: newEtag,
|
||||
value: parseResults,
|
||||
value: parseResults as CatalogProcessorEntityResult[],
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -121,7 +123,7 @@ export class UrlReaderProcessor implements CatalogProcessor {
|
||||
|
||||
// Otherwise do a plain read, prioritizing readUrl if available
|
||||
if (this.options.reader.readUrl) {
|
||||
const data = await this.options.reader.readUrl(location);
|
||||
const data = await this.options.reader.readUrl(location, { etag });
|
||||
return [[{ url: location, data: await data.buffer() }], data.etag];
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,12 @@ import {
|
||||
import { JsonValue } from '@backstage/config';
|
||||
|
||||
export type CatalogProcessor = {
|
||||
/**
|
||||
* A unique identifier for the Catalog Processor.
|
||||
* It's strongly recommended implement getProcessorName as this method will be required in the future.
|
||||
*/
|
||||
getProcessorName?(): string;
|
||||
|
||||
/**
|
||||
* Reads the contents of a location.
|
||||
*
|
||||
|
||||
@@ -55,7 +55,7 @@ describe('DefaultCatalogProcessingEngine', () => {
|
||||
relations: [],
|
||||
errors: [],
|
||||
deferredEntities: [],
|
||||
state: new Map(),
|
||||
state: {},
|
||||
});
|
||||
const engine = new DefaultCatalogProcessingEngine(
|
||||
getVoidLogger(),
|
||||
@@ -84,7 +84,7 @@ describe('DefaultCatalogProcessingEngine', () => {
|
||||
metadata: { name: 'test' },
|
||||
},
|
||||
resultHash: '',
|
||||
state: new Map(),
|
||||
state: {},
|
||||
nextUpdateAt: DateTime.now(),
|
||||
lastDiscoveryAt: DateTime.now(),
|
||||
},
|
||||
@@ -117,7 +117,7 @@ describe('DefaultCatalogProcessingEngine', () => {
|
||||
relations: [],
|
||||
errors: [],
|
||||
deferredEntities: [],
|
||||
state: new Map(),
|
||||
state: {},
|
||||
});
|
||||
const engine = new DefaultCatalogProcessingEngine(
|
||||
getVoidLogger(),
|
||||
@@ -147,7 +147,7 @@ describe('DefaultCatalogProcessingEngine', () => {
|
||||
metadata: { name: 'test' },
|
||||
},
|
||||
resultHash: '',
|
||||
state: new Map(),
|
||||
state: {},
|
||||
nextUpdateAt: DateTime.now(),
|
||||
lastDiscoveryAt: DateTime.now(),
|
||||
},
|
||||
@@ -181,7 +181,7 @@ describe('DefaultCatalogProcessingEngine', () => {
|
||||
entityRef: '',
|
||||
unprocessedEntity: entity,
|
||||
resultHash: 'the matching hash',
|
||||
state: new Map(),
|
||||
state: {},
|
||||
nextUpdateAt: DateTime.now(),
|
||||
lastDiscoveryAt: DateTime.now(),
|
||||
};
|
||||
@@ -194,7 +194,7 @@ describe('DefaultCatalogProcessingEngine', () => {
|
||||
relations: [],
|
||||
errors: [],
|
||||
deferredEntities: [],
|
||||
state: new Map(),
|
||||
state: {},
|
||||
});
|
||||
|
||||
const engine = new DefaultCatalogProcessingEngine(
|
||||
|
||||
@@ -167,8 +167,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');
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -138,7 +138,7 @@ describe('Refresh integration', () => {
|
||||
relations: [],
|
||||
errors: [],
|
||||
deferredEntities,
|
||||
state: new Map(),
|
||||
state: {},
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,7 @@ describe('Default Processing Database', () => {
|
||||
id,
|
||||
processedEntity,
|
||||
resultHash: '',
|
||||
state: new Map<string, JsonObject>(),
|
||||
state: {},
|
||||
relations: [],
|
||||
deferredEntities: [],
|
||||
}),
|
||||
@@ -232,7 +231,7 @@ describe('Default Processing Database', () => {
|
||||
id,
|
||||
processedEntity,
|
||||
resultHash: '',
|
||||
state: new Map<string, JsonObject>(),
|
||||
state: {},
|
||||
relations: [],
|
||||
deferredEntities: [],
|
||||
locationKey: 'key',
|
||||
@@ -285,8 +284,7 @@ describe('Default Processing Database', () => {
|
||||
last_discovery_at: '2021-04-01 13:37:00',
|
||||
});
|
||||
|
||||
const state = new Map<string, JsonObject>();
|
||||
state.set('hello', { t: 'something' });
|
||||
const state = { hello: { t: 'something' } };
|
||||
|
||||
await db.transaction(tx =>
|
||||
db.updateProcessedEntity(tx, {
|
||||
@@ -308,9 +306,7 @@ 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].cache).toEqual(JSON.stringify(state));
|
||||
expect(entities[0].errors).toEqual("['something broke']");
|
||||
expect(entities[0].location_key).toEqual('key');
|
||||
},
|
||||
@@ -352,7 +348,7 @@ describe('Default Processing Database', () => {
|
||||
id,
|
||||
processedEntity,
|
||||
resultHash: '',
|
||||
state: new Map<string, JsonObject>(),
|
||||
state: {},
|
||||
relations: relations,
|
||||
deferredEntities: [],
|
||||
}),
|
||||
@@ -404,7 +400,7 @@ describe('Default Processing Database', () => {
|
||||
id,
|
||||
processedEntity,
|
||||
resultHash: '',
|
||||
state: new Map<string, JsonObject>(),
|
||||
state: {},
|
||||
relations: [],
|
||||
deferredEntities,
|
||||
}),
|
||||
|
||||
@@ -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';
|
||||
@@ -80,7 +79,7 @@ export class DefaultProcessingDatabase implements ProcessingDatabase {
|
||||
.update({
|
||||
processed_entity: JSON.stringify(processedEntity),
|
||||
result_hash: resultHash,
|
||||
cache: JSON.stringify(Object.fromEntries(state || [])),
|
||||
cache: JSON.stringify(state),
|
||||
errors,
|
||||
location_key: locationKey,
|
||||
})
|
||||
@@ -508,9 +507,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<string, JsonObject>(),
|
||||
state: i.cache ? JSON.parse(i.cache) : undefined,
|
||||
errors: i.errors,
|
||||
locationKey: i.location_key,
|
||||
} as RefreshStateItem),
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import { Entity, EntityRelationSpec } from '@backstage/catalog-model';
|
||||
import { JsonObject } from '@backstage/config';
|
||||
import { JsonValue } from '@backstage/config';
|
||||
import { DateTime } from 'luxon';
|
||||
import { Transaction } from '../../database/types';
|
||||
import { DeferredEntity } from '../processing/types';
|
||||
@@ -36,7 +36,7 @@ export type UpdateProcessedEntityOptions = {
|
||||
id: string;
|
||||
processedEntity: Entity;
|
||||
resultHash: string;
|
||||
state?: Map<string, JsonObject>;
|
||||
state?: JsonValue;
|
||||
errors?: string;
|
||||
relations: EntityRelationSpec[];
|
||||
deferredEntities: DeferredEntity[];
|
||||
@@ -57,7 +57,7 @@ export type RefreshStateItem = {
|
||||
resultHash: string;
|
||||
nextUpdateAt: DateTime;
|
||||
lastDiscoveryAt: DateTime; // remove?
|
||||
state: Map<string, JsonObject>;
|
||||
state?: JsonValue;
|
||||
errors?: string;
|
||||
locationKey?: string;
|
||||
};
|
||||
|
||||
+76
-2
@@ -24,6 +24,7 @@ import {
|
||||
stringifyLocationReference,
|
||||
} from '@backstage/catalog-model';
|
||||
import { ConflictError, InputError, NotAllowedError } from '@backstage/errors';
|
||||
import { JsonObject, JsonValue } from '@backstage/config';
|
||||
import { ScmIntegrationRegistry } from '@backstage/integration';
|
||||
import path from 'path';
|
||||
import { Logger } from 'winston';
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
CatalogProcessorParser,
|
||||
} from '../../ingestion/processors';
|
||||
import * as results from '../../ingestion/processors/results';
|
||||
import { CatalogProcessorCache } from '../../ingestion/processors/types';
|
||||
import {
|
||||
CatalogProcessingOrchestrator,
|
||||
EntityProcessingRequest,
|
||||
@@ -53,8 +55,70 @@ type Context = {
|
||||
location: LocationSpec;
|
||||
originLocation: LocationSpec;
|
||||
collector: ProcessorOutputCollector;
|
||||
cache: ProcessorCache;
|
||||
};
|
||||
|
||||
function isObject(value: JsonValue | undefined): value is JsonObject {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
class DefaultCatalogProcessorCache implements CatalogProcessorCache {
|
||||
private newState?: JsonObject;
|
||||
constructor(private readonly existingState: JsonObject) {}
|
||||
|
||||
async get<ItemType extends JsonValue>(
|
||||
key: string,
|
||||
): Promise<ItemType | undefined> {
|
||||
return this.existingState[key] as ItemType | undefined;
|
||||
}
|
||||
|
||||
async set<ItemType extends JsonValue>(
|
||||
key: string,
|
||||
value: ItemType,
|
||||
): Promise<void> {
|
||||
if (!this.newState) {
|
||||
this.newState = {};
|
||||
}
|
||||
|
||||
this.newState[key] = value;
|
||||
}
|
||||
|
||||
collect(): JsonObject | undefined {
|
||||
return this.newState;
|
||||
}
|
||||
}
|
||||
|
||||
class ProcessorCache {
|
||||
private caches = new Map<string, DefaultCatalogProcessorCache>();
|
||||
|
||||
constructor(private readonly existingState: JsonObject) {}
|
||||
forProcessor(processor: CatalogProcessor): 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 cache;
|
||||
}
|
||||
|
||||
const existing = this.existingState[name];
|
||||
|
||||
const newCache = new DefaultCatalogProcessorCache(
|
||||
isObject(existing) ? existing : {},
|
||||
);
|
||||
this.caches.set(name, newCache);
|
||||
return newCache;
|
||||
}
|
||||
|
||||
collect(): JsonObject {
|
||||
const result: JsonObject = {};
|
||||
for (const [key, value] of this.caches.entries()) {
|
||||
result[key] = value.collect();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
export class DefaultCatalogProcessingOrchestrator
|
||||
implements CatalogProcessingOrchestrator
|
||||
{
|
||||
@@ -72,17 +136,23 @@ export class DefaultCatalogProcessingOrchestrator
|
||||
async process(
|
||||
request: EntityProcessingRequest,
|
||||
): Promise<EntityProcessingResult> {
|
||||
return this.processSingleEntity(request.entity);
|
||||
return this.processSingleEntity(request.entity, request.state);
|
||||
}
|
||||
|
||||
private async processSingleEntity(
|
||||
unprocessedEntity: Entity,
|
||||
state: JsonValue | undefined,
|
||||
): Promise<EntityProcessingResult> {
|
||||
const collector = new ProcessorOutputCollector(
|
||||
this.options.logger,
|
||||
unprocessedEntity,
|
||||
);
|
||||
|
||||
// Cache that is scoped to the entity and processor
|
||||
const cache = new ProcessorCache(
|
||||
isObject(state) && isObject(state.cache) ? state.cache : {},
|
||||
);
|
||||
|
||||
try {
|
||||
// This will be checked and mutated step by step below
|
||||
let entity: Entity = unprocessedEntity;
|
||||
@@ -108,6 +178,7 @@ export class DefaultCatalogProcessingOrchestrator
|
||||
originLocation: parseLocationReference(
|
||||
getEntityOriginLocationRef(entity),
|
||||
),
|
||||
cache,
|
||||
collector,
|
||||
};
|
||||
|
||||
@@ -145,7 +216,7 @@ export class DefaultCatalogProcessingOrchestrator
|
||||
return {
|
||||
...collectorResults,
|
||||
completedEntity: entity,
|
||||
state: new Map(),
|
||||
state: { cache: cache.collect() },
|
||||
ok: true,
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -173,6 +244,7 @@ export class DefaultCatalogProcessingOrchestrator
|
||||
context.location,
|
||||
context.collector.onEmit,
|
||||
context.originLocation,
|
||||
context.cache.forProcessor(processor),
|
||||
);
|
||||
} catch (e) {
|
||||
throw new InputError(
|
||||
@@ -306,6 +378,7 @@ export class DefaultCatalogProcessingOrchestrator
|
||||
false,
|
||||
context.collector.onEmit,
|
||||
this.options.parser,
|
||||
context.cache.forProcessor(processor),
|
||||
);
|
||||
if (read) {
|
||||
didRead = true;
|
||||
@@ -343,6 +416,7 @@ export class DefaultCatalogProcessingOrchestrator
|
||||
result,
|
||||
context.location,
|
||||
context.collector.onEmit,
|
||||
context.cache.forProcessor(processor),
|
||||
);
|
||||
} catch (e) {
|
||||
throw new InputError(
|
||||
|
||||
@@ -15,17 +15,17 @@
|
||||
*/
|
||||
|
||||
import { Entity, EntityRelationSpec } from '@backstage/catalog-model';
|
||||
import { JsonObject } from '@backstage/config';
|
||||
import { JsonValue } from '@backstage/config';
|
||||
|
||||
export type EntityProcessingRequest = {
|
||||
entity: Entity;
|
||||
state: Map<string, JsonObject>; // Versions for multiple deployments etc
|
||||
state?: JsonValue; // Versions for multiple deployments etc
|
||||
};
|
||||
|
||||
export type EntityProcessingResult =
|
||||
| {
|
||||
ok: true;
|
||||
state: Map<string, JsonObject>;
|
||||
state: JsonValue;
|
||||
completedEntity: Entity;
|
||||
deferredEntities: DeferredEntity[];
|
||||
relations: EntityRelationSpec[];
|
||||
|
||||
Reference in New Issue
Block a user