Merge pull request #6901 from backstage/freben/db-datetime

use DateTime all the way from the database
This commit is contained in:
Fredrik Adelöw
2021-08-20 10:52:12 +02:00
committed by GitHub
8 changed files with 222 additions and 55 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend': patch
---
Properly handle Date objects being returned for timestamps in the database driver
@@ -85,8 +85,8 @@ describe('DefaultCatalogProcessingEngine', () => {
},
resultHash: '',
state: new Map(),
nextUpdateAt: DateTime.now().toSQL(),
lastDiscoveryAt: '',
nextUpdateAt: DateTime.now(),
lastDiscoveryAt: DateTime.now(),
},
],
});
@@ -148,8 +148,8 @@ describe('DefaultCatalogProcessingEngine', () => {
},
resultHash: '',
state: new Map(),
nextUpdateAt: DateTime.now().toSQL(),
lastDiscoveryAt: '',
nextUpdateAt: DateTime.now(),
lastDiscoveryAt: DateTime.now(),
},
],
});
@@ -182,8 +182,8 @@ describe('DefaultCatalogProcessingEngine', () => {
unprocessedEntity: entity,
resultHash: 'the matching hash',
state: new Map(),
nextUpdateAt: DateTime.now().toSQL(),
lastDiscoveryAt: '',
nextUpdateAt: DateTime.now(),
lastDiscoveryAt: DateTime.now(),
};
hash.digest.mockReturnValue('the matching hash');
@@ -22,11 +22,13 @@ import {
import { serializeError } from '@backstage/errors';
import { Hash } from 'crypto';
import stableStringify from 'fast-json-stable-stringify';
import { DateTime } from 'luxon';
import { Logger } from 'winston';
import { ProcessingDatabase, RefreshStateItem } from './database/types';
import { createCounterMetric, createSummaryMetric } from './metrics';
import { CatalogProcessingOrchestrator } from './processing/types';
import {
CatalogProcessingOrchestrator,
EntityProcessingResult,
} from './processing/types';
import { Stitcher } from './stitching/Stitcher';
import { startTaskPipeline } from './TaskPipeline';
import {
@@ -85,21 +87,8 @@ class Connection implements EntityProviderConnection {
}
export class DefaultCatalogProcessingEngine implements CatalogProcessingEngine {
private readonly tracker = progressTracker();
private stopFunc?: () => void;
private readonly metrics = {
processedEntities: createCounterMetric({
name: 'catalog_processed_entities_count',
help: 'Amount of entities processed',
}),
processingDuration: createSummaryMetric({
name: 'catalog_processing_duration_seconds',
help: 'Processing duration',
}),
processingQueueDelay: createSummaryMetric({
name: 'catalog_processing_queue_delay_seconds',
help: 'The amount of delay between being scheduled for processing, and the start of actually being processed',
}),
};
constructor(
private readonly logger: Logger,
@@ -143,16 +132,9 @@ export class DefaultCatalogProcessingEngine implements CatalogProcessingEngine {
}
},
processTask: async item => {
let endTimer;
try {
this.metrics.processedEntities.inc(1);
this.metrics.processingQueueDelay.observe(
-DateTime.fromSQL(item.nextUpdateAt, { zone: 'UTC' })
.diffNow()
.as('seconds'),
);
endTimer = this.metrics.processingDuration.startTimer();
const track = this.tracker.processStart(item, this.logger);
try {
const {
id,
state,
@@ -166,6 +148,8 @@ export class DefaultCatalogProcessingEngine implements CatalogProcessingEngine {
state,
});
track.markProcessorsCompleted(result);
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
@@ -191,6 +175,7 @@ export class DefaultCatalogProcessingEngine implements CatalogProcessingEngine {
// If nothing changed in our produced outputs, we cannot have any
// significant effect on our surroundings; therefore, we just abort
// without any updates / stitching.
track.markSuccessfulWithNoChanges();
return;
}
@@ -212,6 +197,7 @@ export class DefaultCatalogProcessingEngine implements CatalogProcessingEngine {
await this.stitcher.stitch(
new Set([stringifyEntityRef(unprocessedEntity)]),
);
track.markSuccessfulWithErrors();
return;
}
@@ -236,10 +222,10 @@ export class DefaultCatalogProcessingEngine implements CatalogProcessingEngine {
),
]);
await this.stitcher.stitch(setOfThingsToStitch);
track.markSuccessfulWithChanges(setOfThingsToStitch.size);
} catch (error) {
this.logger.warn('Processing failed with:', error);
} finally {
endTimer?.();
track.markFailed(error);
}
},
});
@@ -252,3 +238,76 @@ export class DefaultCatalogProcessingEngine implements CatalogProcessingEngine {
}
}
}
// Helps wrap the timing and logging behaviors
function progressTracker() {
const stitchedEntities = createCounterMetric({
name: 'catalog_stitched_entities_count',
help: 'Amount of entities stitched',
});
const processedEntities = createCounterMetric({
name: 'catalog_processed_entities_count',
help: 'Amount of entities processed',
labelNames: ['result'],
});
const processingDuration = createSummaryMetric({
name: 'catalog_processing_duration_seconds',
help: 'Time spent executing the full processing flow',
labelNames: ['result'],
});
const processorsDuration = createSummaryMetric({
name: 'catalog_processors_duration_seconds',
help: 'Time spent executing catalog processors',
labelNames: ['result'],
});
const processingQueueDelay = createSummaryMetric({
name: 'catalog_processing_queue_delay_seconds',
help: 'The amount of delay between being scheduled for processing, and the start of actually being processed',
});
function processStart(item: RefreshStateItem, logger: Logger) {
logger.debug(`Processing ${item.entityRef}`);
if (item.nextUpdateAt) {
processingQueueDelay.observe(-item.nextUpdateAt.diffNow().as('seconds'));
}
const endOverallTimer = processingDuration.startTimer();
const endProcessorsTimer = processorsDuration.startTimer();
function markProcessorsCompleted(result: EntityProcessingResult) {
endProcessorsTimer({ result: result.ok ? 'ok' : 'failed' });
}
function markSuccessfulWithNoChanges() {
endOverallTimer({ result: 'unchanged' });
processedEntities.inc({ result: 'unchanged' }, 1);
}
function markSuccessfulWithErrors() {
endOverallTimer({ result: 'errors' });
processedEntities.inc({ result: 'errors' }, 1);
}
function markSuccessfulWithChanges(stitchedCount: number) {
endOverallTimer({ result: 'changed' });
stitchedEntities.inc(stitchedCount);
processedEntities.inc({ result: 'changed' }, 1);
}
function markFailed(error: Error) {
processedEntities.inc({ result: 'failed' }, 1);
logger.warn(`Processing of ${item.entityRef} failed`, error);
}
return {
markProcessorsCompleted,
markSuccessfulWithNoChanges,
markSuccessfulWithErrors,
markSuccessfulWithChanges,
markFailed,
};
}
return { processStart };
}
@@ -24,6 +24,7 @@ import type { Logger } from 'winston';
import { Transaction } from '../../database';
import { DeferredEntity } from '../processing/types';
import { RefreshIntervalFunction } from '../refresh';
import { rethrowError, timestampToDateTime } from './conversion';
import { initDatabaseMetrics } from './metrics';
import {
DbRefreshStateReferencesRow,
@@ -471,6 +472,7 @@ export class DefaultProcessingDatabase implements ProcessingDatabase {
.limit(request.processBatchSize)
.orderBy('next_update_at', 'asc');
const interval = this.options.refreshInterval();
await tx<DbRefreshStateRow>('refresh_state')
.whereIn(
'entity_ref',
@@ -479,12 +481,8 @@ export class DefaultProcessingDatabase implements ProcessingDatabase {
.update({
next_update_at:
tx.client.config.client === 'sqlite3'
? tx.raw(`datetime('now', ?)`, [
`${this.options.refreshInterval()} seconds`,
])
: tx.raw(
`now() + interval '${this.options.refreshInterval()} seconds'`,
),
? tx.raw(`datetime('now', ?)`, [`${interval} seconds`])
: tx.raw(`now() + interval '${interval} seconds'`),
});
return {
@@ -498,8 +496,8 @@ export class DefaultProcessingDatabase implements ProcessingDatabase {
? (JSON.parse(i.processed_entity) as Entity)
: undefined,
resultHash: i.result_hash || '',
nextUpdateAt: i.next_update_at,
lastDiscoveryAt: i.last_discovery_at,
nextUpdateAt: timestampToDateTime(i.next_update_at),
lastDiscoveryAt: timestampToDateTime(i.last_discovery_at),
state: i.cache
? JSON.parse(i.cache)
: new Map<string, JsonObject>(),
@@ -529,15 +527,7 @@ export class DefaultProcessingDatabase implements ProcessingDatabase {
return result!;
} catch (e) {
this.options.logger.debug(`Error during transaction, ${e}`);
if (
/SQLITE_CONSTRAINT: UNIQUE/.test(e.message) ||
/unique constraint/.test(e.message)
) {
throw new ConflictError(`Rejected due to a conflicting entity`, e);
}
throw e;
throw rethrowError(e);
}
}
}
@@ -0,0 +1,54 @@
/*
* 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 { timestampToDateTime, rethrowError } from './conversion';
describe('timestampToDateTime', () => {
it('converts all known types', () => {
const js = new Date(Date.UTC(2021, 7, 20, 10, 11, 12));
const sql = '2021-08-20 10:11:12';
const iso = '2021-08-20T10:11:12Z';
expect(timestampToDateTime(js).toISO()).toBe('2021-08-20T10:11:12.000Z');
expect(timestampToDateTime(sql).toISO()).toBe('2021-08-20T10:11:12.000Z');
expect(timestampToDateTime(iso).toISO()).toBe('2021-08-20T10:11:12.000Z');
});
});
describe('rethrowError', () => {
it('leaves regular errors untouched', () => {
const e = new Error('nothing special here');
expect(() => rethrowError(e)).toThrow(e);
});
it('translates to conflict error when appropriate', () => {
const sqliteUnique = new Error('SQLITE_CONSTRAINT: UNIQUE blah');
const postgresUnique = new Error('unique constraint foo');
expect(() => rethrowError(sqliteUnique)).toThrow(
expect.objectContaining({
name: 'ConflictError',
message: expect.stringContaining('SQLITE_CONSTRAINT: UNIQUE blah'),
}),
);
expect(() => rethrowError(postgresUnique)).toThrow(
expect.objectContaining({
name: 'ConflictError',
message: expect.stringContaining('unique constraint foo'),
}),
);
});
});
@@ -0,0 +1,58 @@
/*
* 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 { ConflictError, InputError } from '@backstage/errors';
import { DateTime } from 'luxon';
/**
* Takes a TIMESTAMP type column and converts it to a DateTime.
*
* Some engines return the SQL string form (e.g. 'YYYY-MM-DD hh:mm:ss'), some
* return ISO string form (e.g. 'YYYY-MM-DDThh:mm:ss.SSSZ'), some return a js
* Date object.
*/
export function timestampToDateTime(input: Date | string): DateTime {
try {
if (typeof input === 'object') {
return DateTime.fromJSDate(input).toUTC();
}
const result = input.includes(' ')
? DateTime.fromSQL(input, { zone: 'utc' })
: DateTime.fromISO(input, { zone: 'utc' });
if (!result.isValid) {
throw new TypeError('Not valid');
}
return result;
} catch (e) {
throw new InputError(`Failed to parse database timestamp ${input}`, e);
}
}
/**
* Rethrows an error, possibly translating it to a more precise error type.
*/
export function rethrowError(e: any): never {
if (
/SQLITE_CONSTRAINT: UNIQUE/.test(e.message) ||
/unique constraint/.test(e.message)
) {
throw new ConflictError(`Rejected due to a conflicting entity`, e);
}
throw e;
}
@@ -27,8 +27,8 @@ export type DbRefreshStateRow = {
processed_entity?: string;
result_hash?: string;
cache?: string;
next_update_at: string;
last_discovery_at: string; // remove?
next_update_at: string | Date;
last_discovery_at: string | Date; // remove?
errors?: string;
location_key?: string;
};
@@ -16,6 +16,7 @@
import { Entity, EntityRelationSpec } from '@backstage/catalog-model';
import { JsonObject } from '@backstage/config';
import { DateTime } from 'luxon';
import { Transaction } from '../../database/types';
import { DeferredEntity } from '../processing/types';
@@ -54,8 +55,8 @@ export type RefreshStateItem = {
unprocessedEntity: Entity;
processedEntity?: Entity;
resultHash: string;
nextUpdateAt: string;
lastDiscoveryAt: string; // remove?
nextUpdateAt: DateTime;
lastDiscoveryAt: DateTime; // remove?
state: Map<string, JsonObject>;
errors?: string;
locationKey?: string;