Add processing type

Co-authored-by: Patrik Oldsberg <poldsberg@gmail.com>
Signed-off-by: Johan Haals <johan.haals@gmail.com>
This commit is contained in:
Johan Haals
2021-04-16 09:29:05 +02:00
parent e4e6ae88b1
commit dbbc5af968
6 changed files with 129 additions and 81 deletions
@@ -24,11 +24,7 @@ import {
} from './types';
import { Logger } from 'winston';
import {
Entity,
stringifyEntityRef,
EntityRelationSpec,
} from '@backstage/catalog-model';
import { stringifyEntityRef } from '@backstage/catalog-model';
import { Stitcher } from './Stitcher';
export class CatalogProcessingEngineImpl implements CatalogProcessingEngine {
@@ -45,9 +41,10 @@ export class CatalogProcessingEngineImpl implements CatalogProcessingEngine {
async start() {
for (const provider of this.entityProviders) {
const id = 'databaseProvider';
const subscription = provider
.entityChange$()
.subscribe({ next: m => this.onNext(m) });
.subscribe({ next: m => this.onNext(id, m) });
this.subscriptions.push(subscription);
}
@@ -73,14 +70,14 @@ export class CatalogProcessingEngineImpl implements CatalogProcessingEngine {
return;
}
result.completedEntity.metadata.uid = id;
await this.stateManager.setProcessingItemResult({
id,
entity: result.completedEntity,
state: result.state,
errors: result.errors,
});
await this.stateManager.addProcessingItems({
entities: result.deferredEntites,
relations: result.relations,
deferredEntities: result.deferredEntites,
});
const setOfThingsToStitch = new Set<string>([
@@ -101,16 +98,20 @@ export class CatalogProcessingEngineImpl implements CatalogProcessingEngine {
}
}
private async onNext(message: EntityMessage) {
private async onNext(id: string, message: EntityMessage) {
if ('all' in message) {
// TODO unhandled rejection
await this.stateManager.addProcessingItems({
id,
type: 'provider',
entities: message.all,
});
}
if ('added' in message) {
await this.stateManager.addProcessingItems({
id,
type: 'provider',
entities: message.added,
});
@@ -101,7 +101,6 @@ export class CatalogProcessingOrchestratorImpl
const result = await this.processSingleEntity(entity);
console.log('result', JSON.stringify(result, undefined, 2));
return result;
}
@@ -32,15 +32,15 @@ export class ProcessingStateManagerImpl implements ProcessingStateManager {
processedEntity: result.entity,
errors: JSON.stringify(result.errors),
state: result.state,
relations: result.relations,
deferedEntities: result.deferredEntities,
});
});
}
async addProcessingItems(request: AddProcessingItemRequest) {
return this.db.transaction(async tx => {
await this.db.addUnprocessedEntities(tx, {
unprocessedEntities: request.entities,
});
await this.db.addUnprocessedEntities(tx, request);
});
}
@@ -14,30 +14,25 @@
* limitations under the License.
*/
import { ConflictError, NotFoundError, InputError } from '@backstage/errors';
import { ConflictError, NotFoundError } from '@backstage/errors';
import { Knex } from 'knex';
import { Transaction } from '../../database';
import { DbEntitiesRow } from '../../database/types';
import lodash from 'lodash';
import {
ProcessingDatabase,
AddUnprocessedEntitiesOptions,
UpdateProcessedEntityOptions,
GetProcessableEntitiesResult,
UpdateFinalEntityOptions,
} from './types';
import type { Logger } from 'winston';
import { Entity, stringifyEntityRef } from '@backstage/catalog-model';
import { createHash } from 'crypto';
import stringify from 'fast-json-stable-stringify';
import stableStringify from 'fast-json-stable-stringify';
import { v4 as uuid } from 'uuid';
export type DbRefreshStateRequest = {
entity: Entity;
nextRefresh: string; // TODO dateTime/ Date?
};
export type DbRefreshStateRow = {
id: string;
entity_id: string;
entity_ref: string;
unprocessed_entity: string;
processed_entity: string;
@@ -47,71 +42,101 @@ export type DbRefreshStateRow = {
errors: string;
};
export type DbRelationsRow = {
originating_entity_id: string;
source_entity_ref: string;
target_entity_ref: string;
type: string;
};
export type DbRefreshStateReferences = {
source_special_key?: string;
source_entity_id?: string;
target_entity_id: string;
};
function generateEntityEtag(entity: Entity) {
return createHash('sha1')
.update(stringify({ ...entity }))
.update(stableStringify({ ...entity }))
.digest('hex');
}
// The number of items that are sent per batch to the database layer, when
// doing .batchInsert calls to knex. This needs to be low enough to not cause
// errors in the underlying engine due to exceeding query limits, but large
// enough to get the speed benefits.
const BATCH_SIZE = 50;
export class ProcessingDatabaseImpl implements ProcessingDatabase {
constructor(
private readonly database: Knex,
private readonly logger: Logger,
) {}
async updateFinalEntity(
txOpaque: Transaction,
options: UpdateFinalEntityOptions,
): Promise<void> {
const tx = txOpaque as Knex.Transaction;
const { finalEntity } = options;
const { relations } = finalEntity;
const { uid, etag, generation } = finalEntity.metadata;
if (uid === undefined || etag === undefined || generation === undefined) {
throw new InputError(
'One of the metadata fields "uid", "etag", or "generation" was missing',
);
} else if (relations === undefined) {
throw new InputError('The field "relations" was missing');
}
// TODO(freben): state/errors?
const fullName = stringifyEntityRef(finalEntity);
const result = await tx<DbEntitiesRow>('entities')
.insert({
id: uid,
location_id: null,
etag,
generation,
full_name: fullName,
data: JSON.stringify(finalEntity),
})
.onConflict('full_name')
.merge(['etag', 'generation', 'data']);
}
async updateProcessedEntity(
txOpaque: Transaction,
options: UpdateProcessedEntityOptions,
): Promise<void> {
const tx = txOpaque as Knex.Transaction;
const { id, processedEntity, state, errors } = options;
const result = await tx<DbRefreshStateRow>('refresh_state')
const {
id,
processedEntity,
state,
errors,
relations,
deferedEntities,
} = options;
const refreshResult = await tx<DbRefreshStateRow>('refresh_state')
.update({
processed_entity: JSON.stringify(processedEntity),
cache: JSON.stringify(state),
errors,
})
.where('id', id);
if (result === 0) {
.where('entity_id', id);
if (refreshResult === 0) {
throw new NotFoundError(`Processing state not found for ${id}`);
}
// Update refs table
// Schedule all deferred entities for future processing.
await this.addUnprocessedEntities(tx, {
entities: deferedEntities,
id,
type: 'entity',
});
// Update fragments
// Update relations
const entityRef = stringifyEntityRef(processedEntity);
// Delete old relations
await tx<DbRelationsRow>('relations')
.where({ originating_entity_id: id })
.delete();
// Batch insert new relations
const relationRows: DbRelationsRow[] = relations.map(
({ source, target, type }) => ({
originating_entity_id: id,
source_entity_ref: stringifyEntityRef(source),
target_entity_ref: stringifyEntityRef(target),
type,
}),
);
await tx.batchInsert(
'relations',
this.deduplicateRelations(relationRows),
BATCH_SIZE,
);
}
private deduplicateRelations(rows: DbRelationsRow[]): DbRelationsRow[] {
return lodash.uniqBy(
rows,
r => `${r.source_entity_ref}:${r.target_entity_ref}:${r.type}`,
);
}
async addUnprocessedEntities(
@@ -119,12 +144,14 @@ export class ProcessingDatabaseImpl implements ProcessingDatabase {
options: AddUnprocessedEntitiesOptions,
): Promise<void> {
const tx = txOpaque as Knex.Transaction;
const entityIds = new Array<string>();
for (const entity of options.unprocessedEntities) {
for (const entity of options.entities) {
const entityRef = stringifyEntityRef(entity);
await tx<DbRefreshStateRow>('refresh_state')
.insert({
id: uuid(),
entity_ref: stringifyEntityRef(entity),
entity_id: uuid(),
entity_ref: entityRef,
unprocessed_entity: JSON.stringify(entity),
errors: '',
next_update_at: tx.fn.now(),
@@ -132,7 +159,29 @@ export class ProcessingDatabaseImpl implements ProcessingDatabase {
})
.onConflict('entity_ref')
.merge(['unprocessed_entity', 'last_discovery_at']);
const [{ entity_id: entityId }] = await tx<DbRefreshStateRow>(
'refresh_state',
).where({ entity_ref: entityRef });
entityIds.push(entityId);
}
const key =
options.type === 'provider'
? { source_special_key: options.id }
: { source_entity_id: options.id };
// copied from update refs
await tx<DbRefreshStateReferences>('refresh_state_references')
.where(key)
.delete();
const referenceRows: DbRefreshStateReferences[] = entityIds.map(
entityId => ({
...key,
target_entity_id: entityId,
}),
);
await tx.batchInsert('refresh_state_references', referenceRows, BATCH_SIZE);
}
async getProcessableEntities(
@@ -161,7 +210,7 @@ export class ProcessingDatabaseImpl implements ProcessingDatabase {
return {
items: items.map(i => ({
id: i.id,
id: i.entity_id,
entityRef: i.entity_ref,
unprocessedEntity: JSON.parse(i.unprocessed_entity) as Entity,
processedEntity: JSON.parse(i.processed_entity) as Entity,
@@ -14,21 +14,25 @@
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { Entity, EntityRelationSpec } from '@backstage/catalog-model';
import { JsonObject } from '@backstage/config';
import { Transaction } from '../../database/types';
export type AddUnprocessedEntitiesOptions = {
unprocessedEntities: Entity[];
type: 'entity' | 'provider';
id: string;
entities: Entity[];
};
export type AddUnprocessedEntitiesResult = {};
export type UpdateProcessedEntityOptions = {
id: string;
processedEntity?: Entity;
processedEntity: Entity;
state?: Map<string, JsonObject>;
errors?: string;
relations: EntityRelationSpec[];
deferedEntities: Entity[];
};
export type RefreshStateItem = {
@@ -46,19 +50,9 @@ export type GetProcessableEntitiesResult = {
items: RefreshStateItem[];
};
export type UpdateFinalEntityOptions = {
finalEntity: Entity;
// TODO(freben): search
};
export interface ProcessingDatabase {
transaction<T>(fn: (tx: Transaction) => Promise<T>): Promise<T>;
updateFinalEntity(
txOpaque: Transaction,
options: UpdateFinalEntityOptions,
): Promise<void>;
addUnprocessedEntities(
tx: Transaction,
options: AddUnprocessedEntitiesOptions,
@@ -99,9 +99,13 @@ export type ProcessingItemResult = {
entity: Entity;
state: Map<string, JsonObject>;
errors: Error[];
relations: EntityRelationSpec[];
deferredEntities: Entity[];
};
export type AddProcessingItemRequest = {
type: 'entity' | 'provider';
id: string;
entities: Entity[];
};
@@ -110,6 +114,7 @@ export type ProccessingItem = {
entity: Entity;
state: Map<string, JsonObject>;
};
export interface ProcessingStateManager {
setProcessingItemResult(result: ProcessingItemResult): Promise<void>;
getNextProcessingItem(): Promise<ProccessingItem>;