Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
Fredrik Adelöw
2021-08-13 21:53:23 +02:00
parent 61aa6526f7
commit 73f3216bc0
8 changed files with 109 additions and 30 deletions
@@ -22,7 +22,7 @@
exports.up = async function up(knex) {
await knex.schema.alterTable('refresh_state', table => {
table
.text('hash')
.text('result_hash')
.nullable()
.comment(
'A hash of the processed contents, used to avoid duplicate work',
@@ -35,6 +35,6 @@ exports.up = async function up(knex) {
*/
exports.down = async function down(knex) {
await knex.schema.alterTable('refresh_state', table => {
table.dropColumn('hash');
table.dropColumn('result_hash');
});
};
@@ -15,6 +15,7 @@
*/
import { getVoidLogger } from '@backstage/backend-common';
import { Hash } from 'crypto';
import waitForExpect from 'wait-for-expect';
import { DefaultProcessingDatabase } from './database/DefaultProcessingDatabase';
import { DefaultCatalogProcessingEngine } from './DefaultCatalogProcessingEngine';
@@ -33,6 +34,10 @@ describe('DefaultCatalogProcessingEngine', () => {
const stitcher = {
stitch: jest.fn(),
} as unknown as jest.Mocked<Stitcher>;
const hash = {
update: () => hash,
digest: jest.fn(),
} as unknown as jest.Mocked<Hash>;
beforeEach(() => {
jest.resetAllMocks();
@@ -57,6 +62,7 @@ describe('DefaultCatalogProcessingEngine', () => {
db,
orchestrator,
stitcher,
() => hash,
);
db.transaction.mockImplementation(cb => cb((() => {}) as any));
@@ -76,7 +82,7 @@ describe('DefaultCatalogProcessingEngine', () => {
kind: 'Location',
metadata: { name: 'test' },
},
hash: '',
resultHash: '',
state: new Map(),
nextUpdateAt: '',
lastDiscoveryAt: '',
@@ -118,6 +124,7 @@ describe('DefaultCatalogProcessingEngine', () => {
db,
orchestrator,
stitcher,
() => hash,
);
db.transaction.mockImplementation(cb => cb((() => {}) as any));
@@ -138,7 +145,7 @@ describe('DefaultCatalogProcessingEngine', () => {
kind: 'Location',
metadata: { name: 'test' },
},
hash: '',
resultHash: '',
state: new Map(),
nextUpdateAt: '',
lastDiscoveryAt: '',
@@ -160,4 +167,71 @@ describe('DefaultCatalogProcessingEngine', () => {
});
await engine.stop();
});
it('runs fully when hash mismatches, early-outs when hash matches', async () => {
const entity = {
apiVersion: '1',
kind: 'Location',
metadata: { name: 'test' },
};
const refreshState = {
id: '',
entityRef: '',
unprocessedEntity: entity,
resultHash: 'the matching hash',
state: new Map(),
nextUpdateAt: '',
lastDiscoveryAt: '',
};
hash.digest.mockReturnValue('the matching hash');
orchestrator.process.mockResolvedValue({
ok: true,
completedEntity: entity,
relations: [],
errors: [],
deferredEntities: [],
state: new Map(),
});
const engine = new DefaultCatalogProcessingEngine(
getVoidLogger(),
[],
db,
orchestrator,
stitcher,
() => hash,
);
db.transaction.mockImplementation(cb => cb((() => {}) as any));
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);
});
db.getProcessableEntities
.mockReset()
.mockResolvedValueOnce({ items: [refreshState] })
.mockResolvedValue({ items: [] });
await waitForExpect(() => {
expect(orchestrator.process).toBeCalledTimes(2);
expect(hash.digest).toBeCalledTimes(2);
expect(db.updateProcessedEntity).toBeCalledTimes(1);
});
await engine.stop();
});
});
@@ -20,7 +20,7 @@ import {
stringifyEntityRef,
} from '@backstage/catalog-model';
import { serializeError } from '@backstage/errors';
import { createHash } from 'crypto';
import { Hash } from 'crypto';
import stableStringify from 'fast-json-stable-stringify';
import { Logger } from 'winston';
import { ProcessingDatabase, RefreshStateItem } from './database/types';
@@ -91,6 +91,7 @@ export class DefaultCatalogProcessingEngine implements CatalogProcessingEngine {
private readonly processingDatabase: ProcessingDatabase,
private readonly orchestrator: CatalogProcessingOrchestrator,
private readonly stitcher: Stitcher,
private readonly createHash: () => Hash,
) {}
async start() {
@@ -133,7 +134,7 @@ export class DefaultCatalogProcessingEngine implements CatalogProcessingEngine {
unprocessedEntity,
entityRef,
locationKey,
hash: previousHash,
resultHash: previousResultHash,
} = item;
const result = await this.orchestrator.process({
entity: unprocessedEntity,
@@ -151,17 +152,17 @@ export class DefaultCatalogProcessingEngine implements CatalogProcessingEngine {
result.errors.map(e => serializeError(e)),
);
let hashBuilder = createHash('sha1').update(errorsString);
let hashBuilder = this.createHash().update(errorsString);
if (result.ok) {
hashBuilder = hashBuilder
.update(stableStringify({ ...result.completedEntity }))
.update(stableStringify([...result.deferredEntities]))
.update(stableStringify([...result.relations]))
.update(stableStringify({ ...result.state }));
.update(stableStringify(Object.fromEntries(result.state)));
}
const hash = hashBuilder.digest('hex');
if (hash === previousHash) {
const resultHash = hashBuilder.digest('hex');
if (resultHash === previousResultHash) {
// If nothing changed in our produced outputs, we cannot have any
// significant effect on our surroundings; therefore, we just abort
// without any updates / stitching.
@@ -180,7 +181,7 @@ export class DefaultCatalogProcessingEngine implements CatalogProcessingEngine {
await this.processingDatabase.updateProcessedEntityErrors(tx, {
id,
errors: errorsString,
hash,
resultHash,
});
});
await this.stitcher.stitch(
@@ -194,7 +195,7 @@ export class DefaultCatalogProcessingEngine implements CatalogProcessingEngine {
await this.processingDatabase.updateProcessedEntity(tx, {
id,
processedEntity: result.completedEntity,
hash,
resultHash,
state: result.state,
errors: errorsString,
relations: result.relations,
@@ -31,6 +31,7 @@ import {
} from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import { ScmIntegrations } from '@backstage/integration';
import { createHash } from 'crypto';
import lodash from 'lodash';
import { Logger } from 'winston';
import {
@@ -310,6 +311,7 @@ export class NextCatalogBuilder {
processingDatabase,
orchestrator,
stitcher,
() => createHash('sha1'),
);
const locationsCatalog = new DatabaseLocationsCatalog(db);
@@ -19,8 +19,8 @@ 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 { Logger } from 'winston';
import * as uuid from 'uuid';
import { Logger } from 'winston';
import { DatabaseManager } from './DatabaseManager';
import { DefaultProcessingDatabase } from './DefaultProcessingDatabase';
import {
@@ -206,7 +206,7 @@ describe('Default Processing Database', () => {
db.updateProcessedEntity(tx, {
id,
processedEntity,
hash: '',
resultHash: '',
state: new Map<string, JsonObject>(),
relations: [],
deferredEntities: [],
@@ -225,7 +225,7 @@ describe('Default Processing Database', () => {
const options = {
id,
processedEntity,
hash: '',
resultHash: '',
state: new Map<string, JsonObject>(),
relations: [],
deferredEntities: [],
@@ -254,7 +254,7 @@ describe('Default Processing Database', () => {
expect(
db.updateProcessedEntity(tx, {
...options,
hash: '',
resultHash: '',
locationKey: 'fail',
}),
).rejects.toThrow(
@@ -286,7 +286,7 @@ describe('Default Processing Database', () => {
db.updateProcessedEntity(tx, {
id,
processedEntity,
hash: '',
resultHash: '',
state,
relations: [],
deferredEntities: [],
@@ -302,7 +302,9 @@ describe('Default Processing Database', () => {
expect(entities[0].processed_entity).toEqual(
JSON.stringify(processedEntity),
);
expect(entities[0].cache).toEqual(JSON.stringify(state));
expect(entities[0].cache).toEqual(
JSON.stringify(Object.fromEntries(state)),
);
expect(entities[0].errors).toEqual("['something broke']");
expect(entities[0].location_key).toEqual('key');
},
@@ -343,7 +345,7 @@ describe('Default Processing Database', () => {
db.updateProcessedEntity(tx, {
id,
processedEntity,
hash: '',
resultHash: '',
state: new Map<string, JsonObject>(),
relations: relations,
deferredEntities: [],
@@ -395,7 +397,7 @@ describe('Default Processing Database', () => {
db.updateProcessedEntity(tx, {
id,
processedEntity,
hash: '',
resultHash: '',
state: new Map<string, JsonObject>(),
relations: [],
deferredEntities,
@@ -60,7 +60,7 @@ export class DefaultProcessingDatabase implements ProcessingDatabase {
const {
id,
processedEntity,
hash,
resultHash,
state,
errors,
relations,
@@ -70,8 +70,8 @@ export class DefaultProcessingDatabase implements ProcessingDatabase {
const refreshResult = await tx<DbRefreshStateRow>('refresh_state')
.update({
processed_entity: JSON.stringify(processedEntity),
hash,
cache: JSON.stringify(state),
result_hash: resultHash,
cache: JSON.stringify(Object.fromEntries(state || [])),
errors,
location_key: locationKey,
})
@@ -124,12 +124,12 @@ export class DefaultProcessingDatabase implements ProcessingDatabase {
options: UpdateProcessedEntityOptions,
): Promise<void> {
const tx = txOpaque as Knex.Transaction;
const { id, errors, hash } = options;
const { id, errors, resultHash } = options;
await tx<DbRefreshStateRow>('refresh_state')
.update({
errors,
hash,
result_hash: resultHash,
})
.where('entity_id', id);
}
@@ -495,7 +495,7 @@ export class DefaultProcessingDatabase implements ProcessingDatabase {
processedEntity: i.processed_entity
? (JSON.parse(i.processed_entity) as Entity)
: undefined,
hash: i.hash || '',
resultHash: i.result_hash || '',
nextUpdateAt: i.next_update_at,
lastDiscoveryAt: i.last_discovery_at,
state: i.cache
@@ -25,7 +25,7 @@ export type DbRefreshStateRow = {
entity_ref: string;
unprocessed_entity: string;
processed_entity?: string;
hash?: string;
result_hash?: string;
cache?: string;
next_update_at: string;
last_discovery_at: string; // remove?
@@ -34,7 +34,7 @@ export type AddUnprocessedEntitiesResult = {};
export type UpdateProcessedEntityOptions = {
id: string;
processedEntity: Entity;
hash: string;
resultHash: string;
state?: Map<string, JsonObject>;
errors?: string;
relations: EntityRelationSpec[];
@@ -45,7 +45,7 @@ export type UpdateProcessedEntityOptions = {
export type UpdateProcessedEntityErrorsOptions = {
id: string;
errors?: string;
hash: string;
resultHash: string;
};
export type RefreshStateItem = {
@@ -53,7 +53,7 @@ export type RefreshStateItem = {
entityRef: string;
unprocessedEntity: Entity;
processedEntity?: Entity;
hash: string;
resultHash: string;
nextUpdateAt: string;
lastDiscoveryAt: string; // remove?
state: Map<string, JsonObject>;