finishing touches

Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
Fredrik Adelöw
2021-10-14 17:04:24 +02:00
parent d4f412fcd3
commit 751317dbf3
10 changed files with 260 additions and 40 deletions
+3
View File
@@ -375,6 +375,9 @@ export class GitlabUrlReader implements UrlReader {
export { isChildPath };
// @public
export function isDatabaseConflictError(e: unknown): boolean;
// @public
export function loadBackendConfig(options: {
logger: Logger_2;
-1
View File
@@ -63,7 +63,6 @@
"minimatch": "^3.0.4",
"minimist": "^1.2.5",
"morgan": "^1.10.0",
"node-abort-controller": "^3.0.0",
"raw-body": "^2.4.1",
"selfsigned": "^1.10.7",
"stoppable": "^1.1.0",
@@ -28,3 +28,4 @@ export {
} from './connection';
export type { PluginDatabaseManager } from './types';
export { isDatabaseConflictError } from './util';
@@ -16,6 +16,7 @@
import { Knex } from 'knex';
import { resolvePackagePath } from '../paths';
import { DB_MIGRATIONS_TABLE } from './tables';
const migrationsDir = resolvePackagePath(
'@backstage/backend-common',
@@ -25,6 +26,6 @@ const migrationsDir = resolvePackagePath(
export async function migrateBackendCommon(knex: Knex): Promise<void> {
await knex.migrate.latest({
directory: migrationsDir,
tableName: 'knex_migrations_backstage_backend_common',
tableName: DB_MIGRATIONS_TABLE,
});
}
@@ -14,6 +14,7 @@
* limitations under the License.
*/
export const DB_MIGRATIONS_TABLE = 'backstage_backend_common__knex_migrations';
export const DB_MUTEXES_TABLE = 'backstage_backend_common__mutexes';
export const DB_TASKS_TABLE = 'backstage_backend_common__tasks';
@@ -0,0 +1,33 @@
/*
* 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.
*/
/**
* Tries to deduce whether a thrown error is a database conflict.
*
* @public
* @param e - A thrown error
* @returns True if the error looks like it was a conflict error thrown by a
* known database engine
*/
export function isDatabaseConflictError(e: unknown) {
const message = (e as any)?.message;
return (
typeof message === 'string' &&
(/SQLITE_CONSTRAINT: UNIQUE/.test(message) ||
/unique constraint/.test(message))
);
}
@@ -14,8 +14,9 @@
* limitations under the License.
*/
import { TestDatabases } from '@backstage/backend-test-utils';
import { TestDatabaseId, TestDatabases } from '@backstage/backend-test-utils';
import { Duration } from 'luxon';
import waitForExpect from 'wait-for-expect';
import { migrateBackendCommon } from '../database/migrateBackendCommon';
import { getVoidLogger } from '../logging';
import { PluginTaskManagerImpl } from './PluginTaskManagerImpl';
@@ -25,17 +26,21 @@ describe('PluginTaskManagerImpl', () => {
ids: ['POSTGRES_13', 'POSTGRES_9', 'SQLITE_3'],
});
describe('locking', () => {
async function init(databaseId: TestDatabaseId) {
const knex = await databases.init(databaseId);
await migrateBackendCommon(knex);
const manager = new PluginTaskManagerImpl(
async () => knex,
getVoidLogger(),
);
return { knex, manager };
}
describe('acquireLock', () => {
it.each(databases.eachSupportedId())(
'can run the happy path, %p',
async databaseId => {
const knex = await databases.init(databaseId);
await migrateBackendCommon(knex);
const manager = new PluginTaskManagerImpl(
async () => knex,
getVoidLogger(),
);
const { manager } = await init(databaseId);
const lock1 = await manager.acquireLock('lock1', {
timeout: Duration.fromMillis(5000),
@@ -63,5 +68,71 @@ describe('PluginTaskManagerImpl', () => {
await (lock1Again as any).release();
},
);
it.each(databases.eachSupportedId())(
'rejects double lock attempts, %p',
async databaseId => {
const { manager } = await init(databaseId);
const lock1 = await manager.acquireLock('lock1', {
timeout: Duration.fromMillis(5000),
});
const lock2 = await manager.acquireLock('lock1', {
timeout: Duration.fromMillis(5000),
});
expect(lock1.acquired).toBe(true);
expect(lock2.acquired).toBe(false);
await (lock1 as any).release();
const lock1Again = await manager.acquireLock('lock1', {
timeout: Duration.fromMillis(5000),
});
expect(lock1Again.acquired).toBe(true);
await (lock1Again as any).release();
},
);
it.each(databases.eachSupportedId())(
'times out locks, %p',
async databaseId => {
const { manager } = await init(databaseId);
const lock1 = await manager.acquireLock('lock1', {
timeout: Duration.fromMillis(200),
});
expect(lock1.acquired).toBe(true);
await new Promise(resolve => setTimeout(resolve, 1000));
const lock2 = await manager.acquireLock('lock1', {
timeout: Duration.fromMillis(5000),
});
expect(lock2.acquired).toBe(true);
await (lock2 as any).release();
},
);
});
// This is just to test the wrapper code; most of the actual tests are in
// TaskWorker.test.ts
describe('scheduleTask', () => {
it.each(databases.eachSupportedId())(
'can run the happy path, %p',
async databaseId => {
const { manager } = await init(databaseId);
const fn = jest.fn();
const { unschedule } = await manager.scheduleTask('task1', {}, fn);
await waitForExpect(() => {
expect(fn).toBeCalled();
});
await unschedule();
},
);
});
});
@@ -18,8 +18,8 @@ import { Knex } from 'knex';
import { Duration } from 'luxon';
import { v4 as uuid } from 'uuid';
import { Logger } from 'winston';
import { isDatabaseConflictError } from '../database';
import { DbMutexesRow, DB_MUTEXES_TABLE } from '../database/tables';
import { PluginTaskManagerJanitor } from './PluginTaskManagerJanitor';
import { TaskWorker } from './TaskWorker';
import { PluginTaskManager } from './types';
import { nowPlus, validateId } from './util';
@@ -28,8 +28,6 @@ import { nowPlus, validateId } from './util';
* Implements the actual task management.
*/
export class PluginTaskManagerImpl implements PluginTaskManager {
private janitor: PluginTaskManagerJanitor | undefined;
constructor(
private readonly databaseFactory: () => Promise<Knex>,
private readonly logger: Logger,
@@ -47,8 +45,6 @@ export class PluginTaskManagerImpl implements PluginTaskManager {
validateId(id);
const knex = await this.databaseFactory();
await this.ensureJanitor(knex);
const ticket = uuid();
async function release() {
@@ -62,30 +58,32 @@ export class PluginTaskManagerImpl implements PluginTaskManager {
}
}
const record: Knex.DbRecord<DbMutexesRow> = {
current_lock_ticket: ticket,
current_lock_acquired_at: knex.fn.now(),
current_lock_expires_at: options.timeout
? nowPlus(options.timeout, knex)
: knex.raw('null'),
};
// First try to overwrite an existing lock, that has timed out
const stolen = await knex<DbMutexesRow>(DB_MUTEXES_TABLE)
.where('id', '=', id)
.whereNotNull('current_lock_ticket')
.where('current_lock_expires_at', '<', knex.fn.now())
.update({
current_lock_ticket: ticket,
current_lock_acquired_at: knex.fn.now(),
current_lock_expires_at: nowPlus(options.timeout, knex),
});
.update(record);
if (stolen) {
return { acquired: true, release };
}
try {
await knex<DbMutexesRow>(DB_MUTEXES_TABLE).insert({
id,
current_lock_ticket: ticket,
current_lock_acquired_at: knex.fn.now(),
current_lock_expires_at: nowPlus(options.timeout, knex),
});
await knex<DbMutexesRow>(DB_MUTEXES_TABLE).insert({ id, ...record });
return { acquired: true, release };
} catch {
} catch (e) {
if (!isDatabaseConflictError(e)) {
this.logger.warn(`Failed to acquire lock, ${e}`);
}
return { acquired: false };
}
}
@@ -102,7 +100,6 @@ export class PluginTaskManagerImpl implements PluginTaskManager {
validateId(id);
const knex = await this.databaseFactory();
await this.ensureJanitor(knex);
const task = new TaskWorker(id, fn, knex, this.logger);
await task.start({
@@ -118,15 +115,4 @@ export class PluginTaskManagerImpl implements PluginTaskManager {
},
};
}
private async ensureJanitor(knex: Knex) {
if (!this.janitor) {
this.janitor = new PluginTaskManagerJanitor({
knex,
waitBetweenRuns: Duration.fromObject({ minutes: 1 }),
logger: this.logger,
});
this.janitor.start();
}
}
}
@@ -0,0 +1,114 @@
/*
* 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 { Knex } from 'knex';
import { Duration } from 'luxon';
import { Logger } from 'winston';
import {
DbMutexesRow,
DbTasksRow,
DB_MUTEXES_TABLE,
DB_TASKS_TABLE,
} from '../database/tables';
import { CancelToken } from './CancelToken';
/**
* Makes sure to auto-expire and clean up things that time out or for other
* reasons should not be left lingering.
*/
export class PluginTaskManagerJanitor {
private readonly knex: Knex;
private readonly waitBetweenRuns: Duration;
private readonly logger: Logger;
private readonly cancelToken: CancelToken;
constructor(options: {
knex: Knex;
waitBetweenRuns: Duration;
logger: Logger;
}) {
this.knex = options.knex;
this.waitBetweenRuns = options.waitBetweenRuns;
this.logger = options.logger;
this.cancelToken = CancelToken.create();
}
async start() {
while (!this.cancelToken.isCancelled) {
try {
await this.runOnce();
} catch (e) {
this.logger.warn(`Error while performing janitorial tasks, ${e}`);
}
await this.sleep(this.waitBetweenRuns);
}
}
async stop() {
this.cancelToken.cancel();
}
private async runOnce() {
// SQLite currently (Oct 1 2021) returns a number for returning()
// statements, effectively ignoring them and instead returning the outcome
// of the delete() - and knex also emits a warning about that fact, which
// is why we avoid that entirely for the sqlite3 driver.
// https://github.com/knex/knex/issues/4370
// https://github.com/mapbox/node-sqlite3/issues/1453
const mutexesQuery = this.knex<DbMutexesRow>(DB_MUTEXES_TABLE)
.where('current_lock_expires_at', '<', this.knex.fn.now())
.delete();
if (this.knex.client.config.client === 'sqlite3') {
const mutexes = await mutexesQuery;
if (mutexes > 0) {
this.logger.warn(`${mutexes} mutex locks timed out and were lost`);
}
} else {
const mutexes = await mutexesQuery.returning(['id']);
for (const { id } of mutexes) {
this.logger.warn(`Mutex lock timed out and was lost: ${id}`);
}
}
const tasksQuery = this.knex<DbTasksRow>(DB_TASKS_TABLE)
.where('current_run_expires_at', '<', this.knex.fn.now())
.delete();
if (this.knex.client.config.client === 'sqlite3') {
const tasks = await tasksQuery;
this.logger.warn(`${tasks} tasks timed out and were lost`);
} else {
const tasks = await tasksQuery.returning(['id']);
for (const { id } of tasks) {
this.logger.warn(`Task timed out and was lost: ${id}`);
}
}
}
/**
* Sleeps for the given duration, but aborts sooner if the cancel token
* triggers.
*/
private async sleep(duration: Duration) {
await Promise.race([
new Promise(resolve => setTimeout(resolve, duration.as('milliseconds'))),
this.cancelToken.promise,
]);
}
}
@@ -16,11 +16,13 @@
import { Config } from '@backstage/config';
import { memoize } from 'lodash';
import { Duration } from 'luxon';
import { Logger } from 'winston';
import { DatabaseManager } from '../database';
import { migrateBackendCommon } from '../database/migrateBackendCommon';
import { getRootLogger } from '../logging';
import { PluginTaskManagerImpl } from './PluginTaskManagerImpl';
import { PluginTaskManagerJanitor } from './PluginTaskManagerJanitor';
import { PluginTaskManager } from './types';
/**
@@ -52,7 +54,16 @@ export class TaskManager {
forPlugin(pluginId: string): PluginTaskManager {
const databaseFactory = memoize(async () => {
const knex = await this.databaseManager.forPlugin(pluginId).getClient();
await migrateBackendCommon(knex);
const janitor = new PluginTaskManagerJanitor({
knex,
waitBetweenRuns: Duration.fromObject({ minutes: 1 }),
logger: this.logger,
});
janitor.start();
return knex;
});