final cleanup, removing unschedule

Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
Fredrik Adelöw
2021-10-27 22:40:52 +02:00
parent 9bee0b9f71
commit 26cf7f631d
11 changed files with 65 additions and 71 deletions
+1 -1
View File
@@ -19,7 +19,7 @@ import { TaskManager } from '@backstage/backend-tasks';
const manager = TaskManager.fromConfig(rootConfig).forPlugin('my-plugin');
const { unschedule } = await manager.scheduleTask({
await manager.scheduleTask({
id: 'refresh-things',
frequency: Duration.fromObject({ minutes: 10 }),
fn: async () => {
+2 -3
View File
@@ -11,9 +11,7 @@ import { Logger as Logger_2 } from 'winston';
// @public
export interface PluginTaskManager {
scheduleTask(task: TaskDefinition): Promise<{
unschedule: () => Promise<void>;
}>;
scheduleTask(task: TaskDefinition): Promise<void>;
}
// @public
@@ -22,6 +20,7 @@ export interface TaskDefinition {
frequency: Duration;
id: string;
initialDelay?: Duration;
signal?: AbortSignal_2;
timeout: Duration;
}
@@ -36,7 +36,7 @@ exports.up = async function up(knex) {
.comment('JSON serialized object with properties for this task');
table
.dateTime('next_run_start_at')
.nullable()
.notNullable()
.comment('The next time that the task should be started');
table
.text('current_run_ticket')
@@ -20,7 +20,7 @@ export const DB_TASKS_TABLE = 'backstage_backend_tasks__tasks';
export type DbTasksRow = {
id: string;
settings_json: string;
next_run_start_at?: Date | string;
next_run_start_at: Date;
current_run_ticket?: string;
current_run_started_at?: Date | string;
current_run_expires_at?: Date | string;
@@ -45,7 +45,7 @@ describe('PluginTaskManagerImpl', () => {
const { manager } = await init(databaseId);
const fn = jest.fn();
const { unschedule } = await manager.scheduleTask({
await manager.scheduleTask({
id: 'task1',
timeout: Duration.fromMillis(5000),
frequency: Duration.fromMillis(5000),
@@ -55,8 +55,6 @@ describe('PluginTaskManagerImpl', () => {
await waitForExpect(() => {
expect(fn).toBeCalled();
});
await unschedule();
},
);
});
@@ -29,25 +29,22 @@ export class PluginTaskManagerImpl implements PluginTaskManager {
private readonly logger: Logger,
) {}
async scheduleTask(
task: TaskDefinition,
): Promise<{ unschedule: () => Promise<void> }> {
async scheduleTask(task: TaskDefinition): Promise<void> {
validateId(task.id);
const knex = await this.databaseFactory();
const worker = new TaskWorker(task.id, task.fn, knex, this.logger);
await worker.start({
version: 1,
initialDelayDuration: task.initialDelay?.toISO(),
recurringAtMostEveryDuration: task.frequency.toISO(),
timeoutAfterDuration: task.timeout.toISO(),
});
return {
async unschedule() {
await worker.stop();
await worker.start(
{
version: 1,
initialDelayDuration: task.initialDelay?.toISO(),
recurringAtMostEveryDuration: task.frequency.toISO(),
timeoutAfterDuration: task.timeout.toISO(),
},
};
{
signal: task.signal,
},
);
}
}
@@ -16,7 +16,7 @@
import { Knex } from 'knex';
import { Duration } from 'luxon';
import { AbortController, AbortSignal } from 'node-abort-controller';
import { AbortSignal } from 'node-abort-controller';
import { Logger } from 'winston';
import { DbTasksRow, DB_TASKS_TABLE } from '../database/tables';
import { sleep } from './util';
@@ -29,8 +29,6 @@ export class PluginTaskManagerJanitor {
private readonly knex: Knex;
private readonly waitBetweenRuns: Duration;
private readonly logger: Logger;
private readonly abortController: AbortController;
private readonly abortSignal: AbortSignal;
constructor(options: {
knex: Knex;
@@ -40,26 +38,20 @@ export class PluginTaskManagerJanitor {
this.knex = options.knex;
this.waitBetweenRuns = options.waitBetweenRuns;
this.logger = options.logger;
this.abortController = new AbortController();
this.abortSignal = this.abortController.signal;
}
async start() {
while (!this.abortSignal.aborted) {
async start(abortSignal?: AbortSignal) {
while (!abortSignal?.aborted) {
try {
await this.runOnce();
} catch (e) {
this.logger.warn(`Error while performing janitorial tasks, ${e}`);
}
await sleep(this.waitBetweenRuns, this.abortSignal);
await sleep(this.waitBetweenRuns, abortSignal);
}
}
async stop() {
this.abortController.abort();
}
private async runOnce() {
// SQLite currently (Oct 1 2021) returns a number for returning()
// statements, effectively ignoring them and instead returning the outcome
@@ -68,9 +60,15 @@ export class PluginTaskManagerJanitor {
// https://github.com/knex/knex/issues/4370
// https://github.com/mapbox/node-sqlite3/issues/1453
const dbNull = this.knex.raw('null');
const tasksQuery = this.knex<DbTasksRow>(DB_TASKS_TABLE)
.where('current_run_expires_at', '<', this.knex.fn.now())
.delete();
.update({
current_run_ticket: dbNull,
current_run_started_at: dbNull,
current_run_expires_at: dbNull,
});
if (this.knex.client.config.client === 'sqlite3') {
const tasks = await tasksQuery;
@@ -18,6 +18,7 @@ import { DatabaseManager, getVoidLogger } from '@backstage/backend-common';
import { TestDatabaseId, TestDatabases } from '@backstage/backend-test-utils';
import { Duration } from 'luxon';
import { TaskManager } from './TaskManager';
import waitForExpect from 'wait-for-expect';
describe('TaskManager', () => {
const logger = getVoidLogger();
@@ -42,14 +43,18 @@ describe('TaskManager', () => {
async databaseId => {
const database = await createDatabase(databaseId);
const manager = new TaskManager(database, logger).forPlugin('test');
const fn = jest.fn();
const task = await manager.scheduleTask({
await manager.scheduleTask({
id: 'task1',
timeout: Duration.fromMillis(5000),
frequency: Duration.fromMillis(5000),
fn: () => {},
fn,
});
await waitForExpect(() => {
expect(fn).toBeCalled();
});
expect(task.unschedule).toBeDefined();
},
);
});
+9 -15
View File
@@ -16,7 +16,7 @@
import { Knex } from 'knex';
import { Duration } from 'luxon';
import { AbortController } from 'node-abort-controller';
import { AbortSignal } from 'node-abort-controller';
import { v4 as uuid } from 'uuid';
import { Logger } from 'winston';
import { DbTasksRow, DB_TASKS_TABLE } from '../database/tables';
@@ -35,17 +35,15 @@ export class TaskWorker {
private readonly fn: TaskFunction;
private readonly knex: Knex;
private readonly logger: Logger;
private readonly abortController: AbortController;
constructor(taskId: string, fn: TaskFunction, knex: Knex, logger: Logger) {
this.taskId = taskId;
this.fn = fn;
this.knex = knex;
this.logger = logger;
this.abortController = new AbortController();
}
async start(settings: TaskSettingsV1) {
async start(settings: TaskSettingsV1, options?: { signal?: AbortSignal }) {
try {
await this.persistTask(settings);
} catch (e) {
@@ -58,13 +56,13 @@ export class TaskWorker {
(async () => {
try {
while (!this.abortController.signal.aborted) {
const runResult = await this.runOnce();
while (!options?.signal?.aborted) {
const runResult = await this.runOnce(options?.signal);
if (runResult.result === 'abort') {
break;
}
await sleep(WORK_CHECK_FREQUENCY, this.abortController.signal);
await sleep(WORK_CHECK_FREQUENCY, options?.signal);
}
this.logger.info(`Task worker finished: ${this.taskId}`);
} catch (e) {
@@ -73,16 +71,14 @@ export class TaskWorker {
})();
}
stop() {
this.abortController.abort();
}
/**
* Makes a single attempt at running the task to completion, if ready.
*
* @returns The outcome of the attempt
*/
async runOnce(): Promise<
async runOnce(
signal?: AbortSignal,
): Promise<
| { result: 'not-ready-yet' }
| { result: 'abort' }
| { result: 'failed' }
@@ -106,9 +102,7 @@ export class TaskWorker {
// Abort the task execution either if the worker is stopped, or if the
// task timeout is hit
const taskAbortController = delegateAbortController(
this.abortController.signal,
);
const taskAbortController = delegateAbortController(signal);
const timeoutHandle = setTimeout(() => {
taskAbortController.abort();
}, Duration.fromISO(taskSettings.timeoutAfterDuration).as('milliseconds'));
+7 -6
View File
@@ -38,6 +38,12 @@ export interface TaskDefinition {
*/
fn: TaskFunction;
/**
* An abort signal that, when triggered, will stop the recurring execution of
* the task.
*/
signal?: AbortSignal;
/**
* The maximum amount of time that a single task invocation can take, before
* it's considered timed out and gets "released" such that a new invocation
@@ -93,13 +99,8 @@ export interface PluginTaskManager {
* continue from there.
*
* @param definition - The task definition
* @returns An `unschedule` function that can be used to stop the task
* invocations later on. This removes the task entirely from storage
* and stops its invocations across all workers.
*/
scheduleTask(
task: TaskDefinition,
): Promise<{ unschedule: () => Promise<void> }>;
scheduleTask(task: TaskDefinition): Promise<void>;
}
function isValidOptionalDurationString(d: string | undefined): boolean {
+13 -11
View File
@@ -83,22 +83,24 @@ export async function sleep(
*
* @param parent - The "parent" signal that can trigger the delegate
*/
export function delegateAbortController(parent: AbortSignal): AbortController {
export function delegateAbortController(parent?: AbortSignal): AbortController {
const delegate = new AbortController();
if (parent.aborted) {
delegate.abort();
} else {
const onParentAborted = () => {
if (parent) {
if (parent.aborted) {
delegate.abort();
};
} else {
const onParentAborted = () => {
delegate.abort();
};
const onChildAborted = () => {
parent.removeEventListener('abort', onParentAborted);
};
const onChildAborted = () => {
parent.removeEventListener('abort', onParentAborted);
};
parent.addEventListener('abort', onParentAborted, { once: true });
delegate.signal.addEventListener('abort', onChildAborted, { once: true });
parent.addEventListener('abort', onParentAborted, { once: true });
delegate.signal.addEventListener('abort', onChildAborted, { once: true });
}
}
return delegate;