Get most of the task worker code into place

Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
Fredrik Adelöw
2021-09-30 09:35:49 +02:00
parent d088c7f0a3
commit d4f412fcd3
15 changed files with 996 additions and 179 deletions
+47
View File
@@ -12,6 +12,7 @@ import { BitbucketIntegration } from '@backstage/integration';
import { Config } from '@backstage/config';
import cors from 'cors';
import Docker from 'dockerode';
import { Duration } from 'luxon';
import { ErrorRequestHandler } from 'express';
import express from 'express';
import { GithubCredentialsProvider } from '@backstage/integration';
@@ -399,6 +400,37 @@ export type PluginEndpointDiscovery = {
getExternalBaseUrl(pluginId: string): Promise<string>;
};
// @public
export interface PluginTaskManager {
// (undocumented)
acquireLock(
id: string,
options: {
timeout: Duration;
},
): Promise<
| {
acquired: false;
}
| {
acquired: true;
release: () => void | Promise<void>;
}
>;
// (undocumented)
scheduleTask(
id: string,
options: {
timeout: Duration;
frequency: Duration;
initialDelay?: Duration;
},
fn: () => Promise<void>,
): Promise<{
unschedule: () => Promise<void>;
}>;
}
// @public
export type ReaderFactory = (options: {
config: Config;
@@ -576,6 +608,21 @@ export interface StatusCheckHandlerOptions {
statusCheck?: StatusCheck;
}
// @public
export class TaskManager {
constructor(databaseManager: DatabaseManager, logger: Logger_2);
// (undocumented)
forPlugin(pluginId: string): PluginTaskManager;
// (undocumented)
static fromConfig(
config: Config,
options?: {
databaseManager?: DatabaseManager;
logger?: Logger_2;
},
): TaskManager;
}
// @public
export type UrlReader = {
read(url: string): Promise<Buffer>;
@@ -21,32 +21,29 @@
*/
exports.up = async function up(knex) {
//
// locking
// mutexes
//
await knex.schema.createTable(
'backstage_backend_common__task_locks',
table => {
table.comment('Locks used for mutual exclusion among multiple workers');
table
.text('id')
.primary()
.notNullable()
.comment('The unique id of this particular lock');
table
.text('acquired_ticket')
.nullable()
.comment('A unique ticket for the current lock acquiral, if any');
table
.dateTime('acquired_at')
.nullable()
.comment('The time when the lock was acquired, if locked');
table
.dateTime('expires_at')
.nullable()
.comment('The time when an acquired lock will time out and expire');
table.index('id', 'task_locks_id_idx');
},
);
await knex.schema.createTable('backstage_backend_common__mutexes', table => {
table.comment('Locks used for mutual exclusion among multiple workers');
table
.text('id')
.primary()
.notNullable()
.comment('The unique ID of this particular mutex');
table
.text('current_lock_ticket')
.nullable()
.comment('A unique ticket for the current mutex lock');
table
.dateTime('current_lock_acquired_at')
.nullable()
.comment('The time when the mutex was locked');
table
.dateTime('current_lock_expires_at')
.nullable()
.comment('The time when a locked mutex will time out and auto-release');
table.index(['id'], 'backstage_backend_common__mutexes__id_idx');
});
//
// tasks
//
@@ -56,7 +53,28 @@ exports.up = async function up(knex) {
.text('id')
.primary()
.notNullable()
.comment('The unique id of this particular task');
.comment('The unique ID of this particular task');
table
.text('settings_json')
.notNullable()
.comment('JSON serialized object with properties for this task');
table
.dateTime('next_run_start_at')
.nullable()
.comment('The next time that the task should be started');
table
.text('current_run_ticket')
.nullable()
.comment('A unique ticket for the current task run');
table
.dateTime('current_run_started_at')
.nullable()
.comment('The time that the current task run started');
table
.dateTime('current_run_expires_at')
.nullable()
.comment('The time that the current task run will time out');
table.index(['id'], 'backstage_backend_common__tasks__id_idx');
});
};
@@ -64,8 +82,21 @@ exports.up = async function up(knex) {
* @param {import('knex').Knex} knex
*/
exports.down = async function down(knex) {
await knex.schema.alterTable('task_locks', table => {
table.dropIndex([], 'task_locks_id_idx');
//
// tasks
//
await knex.schema.alterTable('backstage_backend_common__tasks', table => {
table.dropIndex([], 'backstage_backend_common__tasks__id_idx');
});
await knex.schema.dropTable('task_locks');
await knex.schema.dropTable('backstage_backend_common__tasks');
//
// locks
//
await knex.schema.alterTable(
'backstage_backend_common__task_locks',
table => {
table.dropIndex([], 'backstage_backend_common__task_locks__id_idx');
},
);
await knex.schema.dropTable('backstage_backend_common__task_locks');
};
+4 -2
View File
@@ -71,7 +71,8 @@
"unzipper": "^0.10.11",
"uuid": "^8.0.0",
"winston": "^3.2.1",
"yn": "^4.0.0"
"yn": "^4.0.0",
"zod": "^3.9.5"
},
"peerDependencies": {
"pg-connection-string": "^2.3.0"
@@ -107,7 +108,8 @@
"msw": "^0.35.0",
"mysql2": "^2.2.5",
"recursive-readdir": "^2.2.2",
"supertest": "^6.1.3"
"supertest": "^6.1.3",
"wait-for-expect": "^3.0.2"
},
"files": [
"dist",
+16 -4
View File
@@ -14,9 +14,21 @@
* limitations under the License.
*/
export type DbTaskLocksRow = {
export const DB_MUTEXES_TABLE = 'backstage_backend_common__mutexes';
export const DB_TASKS_TABLE = 'backstage_backend_common__tasks';
export type DbMutexesRow = {
id: string;
acquired_ticket?: string;
acquired_at?: Date;
expires_at?: Date;
current_lock_ticket?: string;
current_lock_acquired_at?: Date | string;
current_lock_expires_at?: Date | string;
};
export type DbTasksRow = {
id: string;
settings_json: string;
next_run_start_at?: Date | string;
current_run_ticket?: string;
current_run_started_at?: Date | string;
current_run_expires_at?: Date | string;
};
@@ -0,0 +1,48 @@
/*
* 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.
*/
export class CancelToken {
// @ts-ignore: is actually assigned by the Promise constructor
#cancel: () => void;
#isCancelled: boolean;
#cancelPromise: Promise<void>;
static create(): CancelToken {
return new CancelToken();
}
private constructor() {
this.#isCancelled = false;
this.#cancelPromise = new Promise(resolve => {
this.#cancel = () => {
this.#isCancelled = true;
resolve();
};
});
}
cancel(): void {
this.#cancel();
}
get isCancelled(): boolean {
return this.#isCancelled;
}
get promise(): Promise<void> {
return this.#cancelPromise;
}
}
@@ -0,0 +1,67 @@
/*
* 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 { TestDatabases } from '@backstage/backend-test-utils';
import { Duration } from 'luxon';
import { migrateBackendCommon } from '../database/migrateBackendCommon';
import { getVoidLogger } from '../logging';
import { PluginTaskManagerImpl } from './PluginTaskManagerImpl';
describe('PluginTaskManagerImpl', () => {
const databases = TestDatabases.create({
ids: ['POSTGRES_13', 'POSTGRES_9', 'SQLITE_3'],
});
describe('locking', () => {
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 lock1 = await manager.acquireLock('lock1', {
timeout: Duration.fromMillis(5000),
});
const lock2 = await manager.acquireLock('lock2', {
timeout: Duration.fromMillis(5000),
});
expect(lock1.acquired).toBe(true);
expect(lock2.acquired).toBe(true);
await expect(
manager.acquireLock('lock1', {
timeout: Duration.fromMillis(5000),
}),
).resolves.toEqual({ acquired: false });
await (lock1 as any).release();
await (lock2 as any).release();
const lock1Again = await manager.acquireLock('lock1', {
timeout: Duration.fromMillis(5000),
});
expect(lock1Again.acquired).toBe(true);
await (lock1Again as any).release();
},
);
});
});
@@ -0,0 +1,132 @@
/*
* 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 { v4 as uuid } from 'uuid';
import { Logger } from 'winston';
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';
/**
* Implements the actual task management.
*/
export class PluginTaskManagerImpl implements PluginTaskManager {
private janitor: PluginTaskManagerJanitor | undefined;
constructor(
private readonly databaseFactory: () => Promise<Knex>,
private readonly logger: Logger,
) {}
async acquireLock(
id: string,
options: {
timeout: Duration;
},
): Promise<
| { acquired: false }
| { acquired: true; release: () => void | Promise<void> }
> {
validateId(id);
const knex = await this.databaseFactory();
await this.ensureJanitor(knex);
const ticket = uuid();
async function release() {
try {
await knex<DbMutexesRow>(DB_MUTEXES_TABLE)
.where('id', '=', id)
.where('current_lock_ticket', '=', ticket)
.delete();
} catch {
// fail silently
}
}
// 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),
});
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),
});
return { acquired: true, release };
} catch {
return { acquired: false };
}
}
async scheduleTask(
id: string,
options: {
timeout?: Duration;
frequency?: Duration;
initialDelay?: Duration;
},
fn: () => void | Promise<void>,
): Promise<{ unschedule: () => Promise<void> }> {
validateId(id);
const knex = await this.databaseFactory();
await this.ensureJanitor(knex);
const task = new TaskWorker(id, fn, knex, this.logger);
await task.start({
version: 1,
initialDelayDuration: options.initialDelay?.toISO(),
recurringAtMostEveryDuration: options.frequency?.toISO(),
timeoutAfterDuration: options.timeout?.toISO(),
});
return {
async unschedule() {
await task.stop();
},
};
}
private async ensureJanitor(knex: Knex) {
if (!this.janitor) {
this.janitor = new PluginTaskManagerJanitor({
knex,
waitBetweenRuns: Duration.fromObject({ minutes: 1 }),
logger: this.logger,
});
this.janitor.start();
}
}
}
@@ -17,9 +17,11 @@
import { TestDatabaseId, TestDatabases } from '@backstage/backend-test-utils';
import { Duration } from 'luxon';
import { DatabaseManager } from '../database';
import { getVoidLogger } from '../logging';
import { TaskManager } from './TaskManager';
describe('TaskManager', () => {
const logger = getVoidLogger();
const databases = TestDatabases.create({
ids: ['POSTGRES_13', 'POSTGRES_9', 'SQLITE_3'],
});
@@ -36,38 +38,16 @@ describe('TaskManager', () => {
return databaseManager as DatabaseManager;
}
describe('locking', () => {
it.each(databases.eachSupportedId())(
'can run the happy path, %p',
async databaseId => {
const database = await createDatabase(databaseId);
const manager = new TaskManager(database).forPlugin('test');
it.each(databases.eachSupportedId())(
'can return a working plugin impl, %p',
async databaseId => {
const database = await createDatabase(databaseId);
const manager = new TaskManager(database, logger).forPlugin('test');
const lock1 = await manager.acquireLock('lock1', {
timeout: Duration.fromMillis(5000),
});
const lock2 = await manager.acquireLock('lock2', {
timeout: Duration.fromMillis(5000),
});
expect(lock1.acquired).toBe(true);
expect(lock2.acquired).toBe(true);
await expect(
manager.acquireLock('lock1', {
timeout: Duration.fromMillis(5000),
}),
).resolves.toEqual({ acquired: false });
await (lock1 as any).release();
await (lock2 as any).release();
const lock1Again = await manager.acquireLock('lock1', {
timeout: Duration.fromMillis(5000),
});
expect(lock1Again.acquired).toBe(true);
await (lock1Again as any).release();
},
);
});
const lock = await manager.acquireLock('lock1', {
timeout: Duration.fromMillis(5000),
});
expect(lock.acquired).toBe(true);
},
);
});
+23 -103
View File
@@ -15,104 +15,13 @@
*/
import { Config } from '@backstage/config';
import { Knex } from 'knex';
import { memoize } from 'lodash';
import { Duration } from 'luxon';
import { v4 as uuid } from 'uuid';
import { Logger } from 'winston';
import { DatabaseManager } from '../database';
import { migrateBackendCommon } from '../database/migrateBackendCommon';
import { DbTaskLocksRow } from '../database/tables';
import { getRootLogger } from '../logging';
import { PluginTaskManagerImpl } from './PluginTaskManagerImpl';
import { PluginTaskManager } from './types';
import { validateId } from './util';
export class PluginTaskManagerImpl implements PluginTaskManager {
constructor(
private readonly pluginId: string,
private readonly databaseFactory: () => Promise<Knex>,
) {}
async acquireLock(
idWithoutPrefix: string,
options: {
timeout: Duration;
},
): Promise<
| { acquired: false }
| { acquired: true; release: () => void | Promise<void> }
> {
validateId(idWithoutPrefix);
const knex = await this.databaseFactory();
const id = `plugin:${this.pluginId}:${idWithoutPrefix}`;
const ticket = uuid();
const timeout = options.timeout.as('seconds');
const release = async () => {
try {
await knex<DbTaskLocksRow>('backstage_backend_common__task_locks')
.where('id', '=', id)
.where('acquired_ticket', '=', ticket)
.delete();
} catch {
// fail silently
}
};
// First try to overwrite an existing lock, that has timed out
const stolen = await knex<DbTaskLocksRow>(
'backstage_backend_common__task_locks',
)
.where('id', '=', id)
.whereNotNull('acquired_ticket')
.where('expires_at', '<', knex.fn.now())
.update({
acquired_ticket: ticket,
acquired_at: knex.fn.now(),
expires_at:
knex.client.config.client === 'sqlite3'
? knex.raw(`datetime('now', ?)`, [`${timeout} seconds`])
: knex.raw(`now() + interval '${timeout} seconds'`),
});
if (stolen) {
return { acquired: true, release };
}
try {
await knex<DbTaskLocksRow>('backstage_backend_common__task_locks').insert(
{
id,
acquired_ticket: ticket,
acquired_at: knex.fn.now(),
expires_at:
knex.client.config.client === 'sqlite3'
? knex.raw(`datetime('now', ?)`, [`${timeout} seconds`])
: knex.raw(`now() + interval '${timeout} seconds'`),
},
);
return { acquired: true, release };
} catch {
return { acquired: false };
}
}
async scheduleTask(
idWithoutPrefix: string,
options: {
timeout: Duration;
frequency: Duration;
initialDelay?: Duration;
},
fn: () => Promise<void>,
): Promise<{ release: () => Promise<void> }> {
validateId(idWithoutPrefix);
const knex = await this.databaseFactory();
const id = `plugin:${this.pluginId}:${idWithoutPrefix}`;
return {};
}
}
/**
* Deals with management and locking related to distributed tasks.
@@ -122,23 +31,34 @@ export class PluginTaskManagerImpl implements PluginTaskManager {
export class TaskManager {
static fromConfig(
config: Config,
options?: { databaseManager?: DatabaseManager },
options?: {
databaseManager?: DatabaseManager;
logger?: Logger;
},
): TaskManager {
const databaseManager =
options?.databaseManager ?? DatabaseManager.fromConfig(config);
return new TaskManager(databaseManager);
const logger = (options?.logger || getRootLogger()).child({
type: 'taskManager',
});
return new TaskManager(databaseManager, logger);
}
constructor(private readonly databaseManager: DatabaseManager) {}
constructor(
private readonly databaseManager: DatabaseManager,
private readonly logger: Logger,
) {}
forPlugin(pluginId: string): PluginTaskManager {
const databaseFactory = memoize(async () => {
const knex = await this.databaseManager.forPlugin(pluginId).getClient();
await migrateBackendCommon(knex);
return knex;
});
return new PluginTaskManagerImpl(
pluginId,
memoize(async () => {
const knex = await this.databaseManager.forPlugin(pluginId).getClient();
await migrateBackendCommon(knex);
return knex;
}),
databaseFactory,
this.logger.child({ plugin: pluginId }),
);
}
}
@@ -0,0 +1,257 @@
/*
* 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 { TestDatabases } from '@backstage/backend-test-utils';
import { Duration } from 'luxon';
import waitForExpect from 'wait-for-expect';
import { migrateBackendCommon } from '../database/migrateBackendCommon';
import { DbTasksRow, DB_TASKS_TABLE } from '../database/tables';
import { getVoidLogger } from '../logging';
import { TaskWorker } from './TaskWorker';
import { TaskSettingsV1 } from './types';
describe('TaskWorker', () => {
const logger = getVoidLogger();
const databases = TestDatabases.create({
ids: ['POSTGRES_13', 'POSTGRES_9', 'SQLITE_3'],
});
beforeEach(() => {
jest.resetAllMocks();
});
it.each(databases.eachSupportedId())(
'can run a single task to completion, %p',
async databaseId => {
const knex = await databases.init(databaseId);
await migrateBackendCommon(knex);
const fn = jest.fn(
async () => new Promise<void>(resolve => setTimeout(resolve, 50)),
);
const settings: TaskSettingsV1 = {
version: 1,
};
const worker = new TaskWorker('task1', fn, knex, logger);
await worker.start(settings);
waitForExpect(() => {
expect(fn).toBeCalledTimes(1);
});
},
60_000,
);
it.each(databases.eachSupportedId())(
'goes through the expected states for a single run, %p',
async databaseId => {
const knex = await databases.init(databaseId);
await migrateBackendCommon(knex);
const fn = jest.fn(
async () => new Promise<void>(resolve => setTimeout(resolve, 50)),
);
const settings: TaskSettingsV1 = {
version: 1,
initialDelayDuration: Duration.fromObject({ seconds: 1 }).toISO(),
recurringAtMostEveryDuration: undefined,
timeoutAfterDuration: undefined,
};
const worker = new TaskWorker('task1', fn, knex, logger);
await worker.persistTask(settings);
let row = (await knex<DbTasksRow>(DB_TASKS_TABLE))[0];
expect(row).toEqual(
expect.objectContaining({
id: 'task1',
current_run_ticket: null,
current_run_started_at: null,
current_run_expires_at: null,
}),
);
expect(JSON.parse(row.settings_json)).toEqual({
version: 1,
initialDelayDuration: 'PT1S',
});
await expect(worker.findReadyTask()).resolves.toEqual({
result: 'not ready yet',
});
waitForExpect(async () => {
await expect(worker.findReadyTask()).resolves.toEqual({
result: 'ready',
});
});
row = (await knex<DbTasksRow>(DB_TASKS_TABLE))[0];
expect(row).toEqual(
expect.objectContaining({
id: 'task1',
current_run_ticket: null,
current_run_started_at: null,
current_run_expires_at: null,
}),
);
await expect(worker.tryClaimTask('ticket', settings)).resolves.toBe(true);
row = (await knex<DbTasksRow>(DB_TASKS_TABLE))[0];
expect(row).toEqual(
expect.objectContaining({
id: 'task1',
current_run_ticket: 'ticket',
current_run_started_at: expect.anything(),
current_run_expires_at: null,
}),
);
await expect(worker.tryReleaseTask('ticket', settings)).resolves.toBe(
true,
);
row = (await knex<DbTasksRow>(DB_TASKS_TABLE))[0];
expect(row).toBeUndefined();
},
60_000,
);
it.each(databases.eachSupportedId())(
'runs tasks more than once even when the task throws, %p',
async databaseId => {
const knex = await databases.init(databaseId);
await migrateBackendCommon(knex);
const fn = jest.fn().mockRejectedValue(new Error('failed'));
const settings: TaskSettingsV1 = {
version: 1,
initialDelayDuration: undefined,
recurringAtMostEveryDuration: Duration.fromMillis(0).toISO(),
timeoutAfterDuration: undefined,
};
const worker = new TaskWorker('task1', fn, knex, logger);
worker.start(settings);
waitForExpect(() => {
expect(fn).toBeCalledTimes(3);
});
},
60_000,
);
it.each(databases.eachSupportedId())(
'does not clobber ticket lock when stolen, %p',
async databaseId => {
const knex = await databases.init(databaseId);
await migrateBackendCommon(knex);
const fn = jest.fn(
async () => new Promise<void>(resolve => setTimeout(resolve, 50)),
);
const settings: TaskSettingsV1 = {
version: 1,
recurringAtMostEveryDuration: Duration.fromMillis(0).toISO(),
};
const worker = new TaskWorker('task1', fn, knex, logger);
await worker.persistTask(settings);
await expect(worker.findReadyTask()).resolves.toEqual({
result: 'ready',
settings,
});
await expect(worker.tryClaimTask('ticket', settings)).resolves.toBe(true);
let row = (await knex<DbTasksRow>(DB_TASKS_TABLE))[0];
expect(row).toEqual(
expect.objectContaining({
id: 'task1',
current_run_ticket: 'ticket',
current_run_started_at: expect.anything(),
current_run_expires_at: null,
}),
);
await knex<DbTasksRow>(DB_TASKS_TABLE)
.where('id', '=', 'task1')
.update({ current_run_ticket: 'stolen' });
await expect(worker.tryReleaseTask('ticket', settings)).resolves.toBe(
false,
);
row = (await knex<DbTasksRow>(DB_TASKS_TABLE))[0];
expect(row).toEqual(
expect.objectContaining({
id: 'task1',
current_run_ticket: 'stolen',
current_run_started_at: expect.anything(),
current_run_expires_at: null,
}),
);
},
60_000,
);
it.each(databases.eachSupportedId())(
'gracefully handles a disappeared task row, %p',
async databaseId => {
const knex = await databases.init(databaseId);
await migrateBackendCommon(knex);
const fn = jest.fn(async () => {});
const settings: TaskSettingsV1 = {
version: 1,
recurringAtMostEveryDuration: Duration.fromMillis(0).toISO(),
};
const worker1 = new TaskWorker('task1', fn, knex, logger);
await worker1.persistTask(settings);
await knex<DbTasksRow>(DB_TASKS_TABLE).where('id', '=', 'task1').delete();
await expect(worker1.findReadyTask()).resolves.toEqual({
result: 'abort',
});
const worker2 = new TaskWorker('task2', fn, knex, logger);
await worker2.persistTask(settings);
await expect(worker2.findReadyTask()).resolves.toEqual({
result: 'ready',
settings,
});
await knex<DbTasksRow>(DB_TASKS_TABLE).where('id', '=', 'task2').delete();
await expect(worker2.tryClaimTask('ticket', settings)).resolves.toBe(
false,
);
const worker3 = new TaskWorker('task3', fn, knex, logger);
await worker3.persistTask(settings);
await expect(worker3.findReadyTask()).resolves.toEqual({
result: 'ready',
settings,
});
await expect(worker3.tryClaimTask('ticket', settings)).resolves.toBe(
true,
);
await knex<DbTasksRow>(DB_TASKS_TABLE).where('id', '=', 'task3').delete();
await expect(worker3.tryReleaseTask('ticket', settings)).resolves.toBe(
false,
);
},
60_000,
);
});
@@ -0,0 +1,277 @@
/*
* 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 { v4 as uuid } from 'uuid';
import { Logger } from 'winston';
import { DbTasksRow, DB_TASKS_TABLE } from '../database/tables';
import { CancelToken } from './CancelToken';
import { TaskSettingsV1, taskSettingsV1Schema } from './types';
import { nowPlus } from './util';
const WORK_CHECK_FREQUENCY = Duration.fromObject({ seconds: 5 });
/**
* Performs the actual work of a task.
*
* @private
*/
export class TaskWorker {
private readonly taskId: string;
private readonly fn: () => void | Promise<void>;
private readonly knex: Knex;
private readonly logger: Logger;
private readonly cancelToken: CancelToken;
constructor(
taskId: string,
fn: () => void | Promise<void>,
knex: Knex,
logger: Logger,
) {
this.taskId = taskId;
this.fn = fn;
this.knex = knex;
this.logger = logger;
this.cancelToken = CancelToken.create();
}
async start(settings: TaskSettingsV1) {
try {
await this.persistTask(settings);
} catch (e) {
throw new Error(`Failed to persist task, ${e}`);
}
this.logger.info(
`Task worker starting: ${this.taskId}, ${JSON.stringify(settings)}`,
);
(async () => {
try {
while (!this.cancelToken.isCancelled) {
const runResult = await this.runOnce();
if (runResult.result === 'abort') {
break;
}
if (!settings.recurringAtMostEveryDuration) {
break;
}
await this.sleep(WORK_CHECK_FREQUENCY);
}
this.logger.info(`Task worker finished: ${this.taskId}`);
} catch (e) {
this.logger.warn(`Task worker failed unexpectedly, ${e}`);
}
})();
}
stop() {
this.cancelToken.cancel();
}
/**
* Makes a single attempt at running the task to completion, if ready.
*
* @returns The outcome of the attempt
*/
async runOnce(): Promise<
| { result: 'not ready yet' }
| { result: 'abort' }
| { result: 'failed' }
| { result: 'completed' }
> {
const findResult = await this.findReadyTask();
if (
findResult.result === 'not ready yet' ||
findResult.result === 'abort'
) {
return findResult;
}
const taskSettings = findResult.settings;
const ticket = uuid();
const claimed = await this.tryClaimTask(ticket, taskSettings);
if (!claimed) {
return { result: 'not ready yet' };
}
try {
await this.fn();
} catch (e) {
await this.tryReleaseTask(ticket, taskSettings);
return { result: 'failed' };
}
await this.tryReleaseTask(ticket, taskSettings);
return { result: 'completed' };
}
/**
* Sleep for the given duration, but abort sooner if the cancel token
* triggers.
*
* @param duration - The amount of time to sleep, at most
*/
private async sleep(duration: Duration): Promise<void> {
await Promise.race([
new Promise(resolve => setTimeout(resolve, duration.as('milliseconds'))),
this.cancelToken.promise,
]);
}
/**
* Perform the initial store of the task info
*/
async persistTask(settings: TaskSettingsV1) {
// Perform an initial parse to ensure that we will definitely be able to
// read it back again.
taskSettingsV1Schema.parse(settings);
const settingsJson = JSON.stringify(settings);
const startAt = settings.initialDelayDuration
? nowPlus(Duration.fromISO(settings.initialDelayDuration), this.knex)
: this.knex.fn.now();
// It's OK if the task already exists; if it does, just replace its
// settings with the new value and start the loop as usual.
await this.knex<DbTasksRow>(DB_TASKS_TABLE)
.insert({
id: this.taskId,
settings_json: settingsJson,
next_run_start_at: startAt,
})
.onConflict('id')
.merge(['settings_json']);
}
/**
* Check if the task is ready to run
*/
async findReadyTask(): Promise<
| { result: 'not ready yet' }
| { result: 'abort' }
| { result: 'ready'; settings: TaskSettingsV1 }
> {
const [row] = await this.knex<DbTasksRow>(DB_TASKS_TABLE)
.where('id', '=', this.taskId)
.select({
settingsJson: 'settings_json',
ready: this.knex.raw(
`
CASE
WHEN next_run_start_at <= ? AND current_run_ticket IS NULL THEN TRUE
ELSE FALSE
END`,
[this.knex.fn.now()],
),
});
if (!row) {
this.logger.info(
'No longer able to find task; aborting and assuming that it has been unregistered or expired',
);
return { result: 'abort' };
} else if (!row.ready) {
return { result: 'not ready yet' };
}
try {
const settings = taskSettingsV1Schema.parse(JSON.parse(row.settingsJson));
return { result: 'ready', settings };
} catch (e) {
this.logger.info(
'No longer able to parse task settings; aborting and assuming that a ' +
'newer version of the task has been issued and being handled by ' +
`other workers, ${e}`,
);
return { result: 'abort' };
}
}
/**
* Attempts to claim a task that's ready for execution, on this worker's
* behalf. We should not attempt to perform the work unless the claim really
* goes through.
*
* @param ticket - A globally unique string that changes for each invocation
* @param settings - The settings of the task to claim
* @returns True if it was successfully claimed
*/
async tryClaimTask(
ticket: string,
settings: TaskSettingsV1,
): Promise<boolean> {
const startedAt = this.knex.fn.now();
const expiresAt = settings.timeoutAfterDuration
? nowPlus(Duration.fromISO(settings.timeoutAfterDuration), this.knex)
: this.knex.raw('null');
const rows = await this.knex<DbTasksRow>(DB_TASKS_TABLE)
.where('id', '=', this.taskId)
.whereNull('current_run_ticket')
.update({
current_run_ticket: ticket,
current_run_started_at: startedAt,
current_run_expires_at: expiresAt,
});
return rows === 1;
}
async tryReleaseTask(
ticket: string,
settings: TaskSettingsV1,
): Promise<boolean> {
const { recurringAtMostEveryDuration } = settings;
// If this is not a recurring task, and we still have the current run
// ticket, delete it from the table
if (recurringAtMostEveryDuration === undefined) {
const rows = await this.knex<DbTasksRow>(DB_TASKS_TABLE)
.where('id', '=', this.taskId)
.where('current_run_ticket', '=', ticket)
.delete();
return rows === 1;
}
// We make an effort to keep the datetime calculations in the database
// layer, making sure to not have to perform conversions back and forth and
// leaning on the database as a central clock source
const dbNull = this.knex.raw('null');
const dt = Duration.fromISO(recurringAtMostEveryDuration).as('seconds');
const nextRun =
this.knex.client.config.client === 'sqlite3'
? this.knex.raw('datetime(next_run_start_at, ?)', [`+${dt} seconds`])
: this.knex.raw(`next_run_start_at + interval '${dt} seconds'`);
const rows = await this.knex<DbTasksRow>(DB_TASKS_TABLE)
.where('id', '=', this.taskId)
.where('current_run_ticket', '=', ticket)
.update({
next_run_start_at: nextRun,
current_run_ticket: dbNull,
current_run_started_at: dbNull,
current_run_expires_at: dbNull,
});
return rows === 1;
}
}
+31 -1
View File
@@ -15,6 +15,7 @@
*/
import { Duration } from 'luxon';
import { z } from 'zod';
/**
* Deals with management and locking related to distributed tasks, for a given
@@ -41,5 +42,34 @@ export interface PluginTaskManager {
initialDelay?: Duration;
},
fn: () => Promise<void>,
): Promise<{ release: () => Promise<void> }>;
): Promise<{ unschedule: () => Promise<void> }>;
}
function isValidOptionalDurationString(d: string | undefined): boolean {
try {
return !d || Duration.fromISO(d).isValid === true;
} catch {
return false;
}
}
export const taskSettingsV1Schema = z.object({
version: z.literal(1),
initialDelayDuration: z
.string()
.optional()
.refine(isValidOptionalDurationString, { message: 'Invalid duration' }),
recurringAtMostEveryDuration: z
.string()
.optional()
.refine(isValidOptionalDurationString, { message: 'Invalid duration' }),
timeoutAfterDuration: z
.string()
.optional()
.refine(isValidOptionalDurationString, { message: 'Invalid duration' }),
});
/**
* The properties that control a scheduled task (version 1).
*/
export type TaskSettingsV1 = z.infer<typeof taskSettingsV1Schema>;
+19
View File
@@ -15,6 +15,8 @@
*/
import { InputError } from '@backstage/errors';
import { Knex } from 'knex';
import { DateTime, Duration } from 'luxon';
// Keep the IDs compatible with e.g. Prometheus
export function validateId(id: string) {
@@ -24,3 +26,20 @@ export function validateId(id: string) {
);
}
}
export function dbTime(t: Date | string): DateTime {
if (typeof t === 'string') {
return DateTime.fromSQL(t);
}
return DateTime.fromJSDate(t);
}
export function nowPlus(duration: Duration | undefined, knex: Knex) {
const seconds = duration?.as('seconds') ?? 0;
if (!seconds) {
return knex.fn.now();
}
return knex.client.config.client === 'sqlite3'
? knex.raw(`datetime('now', ?)`, [`${seconds} seconds`])
: knex.raw(`now() + interval '${seconds} seconds'`);
}
+2 -2
View File
@@ -57,7 +57,7 @@ import techInsights from './plugins/techInsights';
import todo from './plugins/todo';
import { PluginEnvironment } from './types';
async function makeCreateEnv(config: Config) {
function makeCreateEnv(config: Config) {
const root = getRootLogger();
const reader = UrlReaders.default({ logger: root, config });
const discovery = SingleHostDiscovery.fromConfig(config);
@@ -90,7 +90,7 @@ async function main() {
argv: process.argv,
logger,
});
const createEnv = await makeCreateEnv(config);
const createEnv = makeCreateEnv(config);
const healthcheckEnv = useHotMemoize(module, () => createEnv('healthcheck'));
const catalogEnv = useHotMemoize(module, () => createEnv('catalog'));
-5
View File
@@ -20895,11 +20895,6 @@ node-abi@^2.21.0:
dependencies:
semver "^5.4.1"
node-abort-controller@^3.0.0:
version "3.0.0"
resolved "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.0.0.tgz#e7b1f2b72f4c5a74b5594cc23fa8fe3b62010ab5"
integrity sha512-IqMCPbihDpbHV4bNws015hU0svIBGyzPjJearwXMGJyungWdblbBcboNojTz9bWOrrJD3zIwmcr5w5c+NH+2+A==
node-addon-api@^3.0.0:
version "3.2.1"
resolved "https://registry.npmjs.org/node-addon-api/-/node-addon-api-3.2.1.tgz#81325e0a2117789c0128dab65e7e38f07ceba161"