Merge pull request #10062 from backstage/freben/freben/task-cron-source

Add cron support to `@backstage/backend-tasks`
This commit is contained in:
Fredrik Adelöw
2022-03-10 11:16:04 +01:00
committed by GitHub
13 changed files with 264 additions and 89 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-tasks': patch
---
Add support for cron syntax to configure task frequency - `TaskScheduleDefinition.frequency` can now be both a `Duration` and an object on the form `{ cron: string }`, where the latter is expected to be on standard crontab format (e.g. `'0 */2 * * *'`).
+1
View File
@@ -61,6 +61,7 @@ const
cookiecutter
cron
cronjobs
crontab
css
Datadog
dataflow
+1 -1
View File
@@ -22,7 +22,7 @@ const scheduler = TaskScheduler.fromConfig(rootConfig).forPlugin('my-plugin');
await scheduler.scheduleTask({
id: 'refresh_things',
frequency: Duration.fromObject({ minutes: 10 }),
frequency: { cron: '*/5 * * * *' }, // every 5 minutes, also supports Duration
timeout: Duration.fromObject({ minutes: 15 }),
fn: async () => {
await entityProvider.run();
+5 -1
View File
@@ -36,7 +36,11 @@ export interface TaskRunner {
// @public
export interface TaskScheduleDefinition {
frequency: Duration;
frequency:
| {
cron: string;
}
| Duration;
initialDelay?: Duration;
timeout: Duration;
}
+2
View File
@@ -38,6 +38,7 @@
"@backstage/errors": "^0.2.2",
"@backstage/types": "^0.1.3",
"@types/luxon": "^2.0.4",
"cron": "^1.8.2",
"knex": "^1.0.2",
"lodash": "^4.17.21",
"luxon": "^2.0.2",
@@ -49,6 +50,7 @@
"devDependencies": {
"@backstage/backend-test-utils": "^0.1.21-next.0",
"@backstage/cli": "^0.15.2-next.0",
"@types/cron": "^1.7.3",
"jest": "^26.0.1",
"wait-for-expect": "^3.0.2"
},
@@ -40,7 +40,7 @@ describe('PluginTaskManagerImpl', () => {
// TaskWorker.test.ts
describe('scheduleTask', () => {
it.each(databases.eachSupportedId())(
'can run the happy path, %p',
'can run the v1 happy path, %p',
async databaseId => {
const { manager } = await init(databaseId);
@@ -58,6 +58,26 @@ describe('PluginTaskManagerImpl', () => {
},
60_000,
);
it.each(databases.eachSupportedId())(
'can run the v2 happy path, %p',
async databaseId => {
const { manager } = await init(databaseId);
const fn = jest.fn();
await manager.scheduleTask({
id: 'task2',
timeout: Duration.fromMillis(5000),
frequency: { cron: '* * * * * *' },
fn,
});
await waitForExpect(() => {
expect(fn).toBeCalled();
});
},
60_000,
);
});
// This is just to test the wrapper code; most of the actual tests are in
@@ -40,13 +40,16 @@ export class PluginTaskSchedulerImpl implements PluginTaskScheduler {
validateId(task.id);
const knex = await this.databaseFactory();
const worker = new TaskWorker(task.id, task.fn, knex, this.logger);
await worker.start(
{
version: 1,
version: 2,
cadence:
'cron' in task.frequency
? task.frequency.cron
: task.frequency.toISO(),
initialDelayDuration: task.initialDelay?.toISO(),
recurringAtMostEveryDuration: task.frequency.toISO(),
timeoutAfterDuration: task.timeout.toISO(),
},
{
@@ -39,7 +39,7 @@ describe('TaskScheduler', () => {
}
it.each(databases.eachSupportedId())(
'can return a working plugin impl, %p',
'can return a working v1 plugin impl, %p',
async databaseId => {
const database = await createDatabase(databaseId);
const manager = new TaskScheduler(database, logger).forPlugin('test');
@@ -58,4 +58,25 @@ describe('TaskScheduler', () => {
},
60_000,
);
it.each(databases.eachSupportedId())(
'can return a working v2 plugin impl, %p',
async databaseId => {
const database = await createDatabase(databaseId);
const manager = new TaskScheduler(database, logger).forPlugin('test');
const fn = jest.fn();
await manager.scheduleTask({
id: 'task2',
timeout: Duration.fromMillis(5000),
frequency: { cron: '* * * * * *' },
fn,
});
await waitForExpect(() => {
expect(fn).toBeCalled();
});
},
60_000,
);
});
@@ -21,7 +21,7 @@ import waitForExpect from 'wait-for-expect';
import { migrateBackendTasks } from '../database/migrateBackendTasks';
import { DbTasksRow, DB_TASKS_TABLE } from '../database/tables';
import { TaskWorker } from './TaskWorker';
import { TaskSettingsV1 } from './types';
import { TaskSettingsV2 } from './types';
describe('TaskWorker', () => {
const logger = getVoidLogger();
@@ -42,11 +42,11 @@ describe('TaskWorker', () => {
const fn = jest.fn(
async () => new Promise<void>(resolve => setTimeout(resolve, 50)),
);
const settings: TaskSettingsV1 = {
version: 1,
initialDelayDuration: Duration.fromMillis(1000).toISO(),
recurringAtMostEveryDuration: Duration.fromMillis(2000).toISO(),
timeoutAfterDuration: Duration.fromMillis(60000).toISO(),
const settings: TaskSettingsV2 = {
version: 2,
cadence: '*/2 * * * * *',
initialDelayDuration: Duration.fromObject({ seconds: 1 }).toISO(),
timeoutAfterDuration: Duration.fromObject({ minutes: 1 }).toISO(),
};
const worker = new TaskWorker('task1', fn, knex, logger);
@@ -62,10 +62,10 @@ describe('TaskWorker', () => {
}),
);
expect(JSON.parse(row.settings_json)).toEqual({
version: 1,
version: 2,
cadence: '*/2 * * * * *',
initialDelayDuration: 'PT1S',
recurringAtMostEveryDuration: 'PT2S',
timeoutAfterDuration: 'PT60S',
timeoutAfterDuration: 'PT1M',
});
await expect(worker.findReadyTask()).resolves.toEqual({
@@ -125,10 +125,10 @@ describe('TaskWorker', () => {
await migrateBackendTasks(knex);
const fn = jest.fn().mockRejectedValue(new Error('failed'));
const settings: TaskSettingsV1 = {
version: 1,
const settings: TaskSettingsV2 = {
version: 2,
initialDelayDuration: undefined,
recurringAtMostEveryDuration: Duration.fromMillis(0).toISO(),
cadence: '* * * * * *',
timeoutAfterDuration: Duration.fromMillis(60000).toISO(),
};
@@ -151,18 +151,23 @@ describe('TaskWorker', () => {
const fn = jest.fn(
async () => new Promise<void>(resolve => setTimeout(resolve, 50)),
);
const settings: TaskSettingsV1 = {
version: 1,
recurringAtMostEveryDuration: Duration.fromMillis(0).toISO(),
const settings: TaskSettingsV2 = {
version: 2,
initialDelayDuration: undefined,
cadence: '* * * * * *',
timeoutAfterDuration: Duration.fromMillis(60000).toISO(),
};
const worker = new TaskWorker('task1', fn, knex, logger);
await worker.persistTask(settings);
await expect(worker.findReadyTask()).resolves.toEqual({
result: 'ready',
settings,
await waitForExpect(async () => {
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];
@@ -203,9 +208,10 @@ describe('TaskWorker', () => {
await migrateBackendTasks(knex);
const fn = jest.fn(async () => {});
const settings: TaskSettingsV1 = {
version: 1,
recurringAtMostEveryDuration: Duration.fromMillis(0).toISO(),
const settings: TaskSettingsV2 = {
version: 2,
initialDelayDuration: undefined,
cadence: '* * * * * *',
timeoutAfterDuration: Duration.fromMillis(60000).toISO(),
};
@@ -218,10 +224,14 @@ describe('TaskWorker', () => {
const worker2 = new TaskWorker('task2', fn, knex, logger);
await worker2.persistTask(settings);
await expect(worker2.findReadyTask()).resolves.toEqual({
result: 'ready',
settings,
await waitForExpect(async () => {
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,
@@ -229,10 +239,14 @@ describe('TaskWorker', () => {
const worker3 = new TaskWorker('task3', fn, knex, logger);
await worker3.persistTask(settings);
await expect(worker3.findReadyTask()).resolves.toEqual({
result: 'ready',
settings,
await waitForExpect(async () => {
await expect(worker3.findReadyTask()).resolves.toEqual({
result: 'ready',
settings,
});
});
await expect(worker3.tryClaimTask('ticket', settings)).resolves.toBe(
true,
);
+56 -28
View File
@@ -15,13 +15,14 @@
*/
import { Knex } from 'knex';
import { Duration } from 'luxon';
import { DateTime, Duration } from 'luxon';
import { AbortSignal } from 'node-abort-controller';
import { v4 as uuid } from 'uuid';
import { Logger } from 'winston';
import { DbTasksRow, DB_TASKS_TABLE } from '../database/tables';
import { TaskFunction, TaskSettingsV1, taskSettingsV1Schema } from './types';
import { TaskFunction, TaskSettingsV2, taskSettingsV2Schema } from './types';
import { delegateAbortController, nowPlus, sleep } from './util';
import { CronTime } from 'cron';
const WORK_CHECK_FREQUENCY = Duration.fromObject({ seconds: 5 });
@@ -43,7 +44,7 @@ export class TaskWorker {
this.logger = logger;
}
async start(settings: TaskSettingsV1, options?: { signal?: AbortSignal }) {
async start(settings: TaskSettingsV2, options?: { signal?: AbortSignal }) {
try {
await this.persistTask(settings);
} catch (e) {
@@ -123,22 +124,39 @@ export class TaskWorker {
/**
* Perform the initial store of the task info
*/
async persistTask(settings: TaskSettingsV1) {
async persistTask(settings: TaskSettingsV2) {
// Perform an initial parse to ensure that we will definitely be able to
// read it back again.
taskSettingsV1Schema.parse(settings);
taskSettingsV2Schema.parse(settings);
const settingsJson = JSON.stringify(settings);
const startAt = settings.initialDelayDuration
? nowPlus(Duration.fromISO(settings.initialDelayDuration), this.knex)
: this.knex.fn.now();
const isCron = !settings?.cadence.startsWith('P');
let startAt: Knex.Raw;
if (settings.initialDelayDuration) {
startAt = nowPlus(
Duration.fromISO(settings.initialDelayDuration),
this.knex,
);
} else if (isCron) {
const time = new CronTime(settings.cadence)
.sendAt()
.add({ seconds: -1 }) // immediately, if "* * * * * *"
.toISOString();
startAt = this.knex.client.config.client.includes('sqlite3')
? this.knex.raw('datetime(?)', [time])
: this.knex.raw(`?`, [time]);
} else {
startAt = this.knex.fn.now();
}
this.logger.debug(`task: ${this.taskId} configured to run at: ${startAt}`);
// 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,
settings_json: JSON.stringify(settings),
next_run_start_at: startAt,
})
.onConflict('id')
@@ -151,15 +169,14 @@ export class TaskWorker {
async findReadyTask(): Promise<
| { result: 'not-ready-yet' }
| { result: 'abort' }
| { result: 'ready'; settings: TaskSettingsV1 }
| { result: 'ready'; settings: TaskSettingsV2 }
> {
const [row] = await this.knex<DbTasksRow>(DB_TASKS_TABLE)
.where('id', '=', this.taskId)
.select({
settingsJson: 'settings_json',
ready: this.knex.raw(
`
CASE
`CASE
WHEN next_run_start_at <= ? AND current_run_ticket IS NULL THEN TRUE
ELSE FALSE
END`,
@@ -177,7 +194,8 @@ export class TaskWorker {
}
try {
const settings = taskSettingsV1Schema.parse(JSON.parse(row.settingsJson));
const obj = JSON.parse(row.settingsJson);
const settings = taskSettingsV2Schema.parse(obj);
return { result: 'ready', settings };
} catch (e) {
this.logger.info(
@@ -199,7 +217,7 @@ export class TaskWorker {
*/
async tryClaimTask(
ticket: string,
settings: TaskSettingsV1,
settings: TaskSettingsV2,
): Promise<boolean> {
const startedAt = this.knex.fn.now();
const expiresAt = settings.timeoutAfterDuration
@@ -220,27 +238,37 @@ export class TaskWorker {
async tryReleaseTask(
ticket: string,
settings: TaskSettingsV1,
settings: TaskSettingsV2,
): Promise<boolean> {
const { recurringAtMostEveryDuration } = settings;
const isCron = !settings?.cadence.startsWith('P');
// 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.includes('sqlite3')
? this.knex.raw('datetime(next_run_start_at, ?)', [`+${dt} seconds`])
: this.knex.raw(`next_run_start_at + interval '${dt} seconds'`);
let nextRun: Knex.Raw;
if (isCron) {
const time = new CronTime(settings.cadence).sendAt().toISOString();
this.logger.debug(`task: ${this.taskId} will next occur around ${time}`);
nextRun = this.knex.client.config.client.includes('sqlite3')
? this.knex.raw('datetime(?)', [time])
: this.knex.raw(`?`, [time]);
} else {
const dt = Duration.fromISO(settings.cadence).as('seconds');
this.logger.debug(
`task: ${this.taskId} will next occur around ${DateTime.now().plus({
seconds: dt,
})}`,
);
nextRun = this.knex.client.config.client.includes('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,
current_run_ticket: this.knex.raw('null'),
current_run_started_at: this.knex.raw('null'),
current_run_expires_at: this.knex.raw('null'),
});
return rows === 1;
+83 -21
View File
@@ -17,6 +17,7 @@
import { Duration } from 'luxon';
import { AbortSignal } from 'node-abort-controller';
import { z } from 'zod';
import { CronTime } from 'cron';
/**
* A function that can be called as a scheduled task.
@@ -37,17 +38,8 @@ export type TaskFunction =
*/
export interface TaskScheduleDefinition {
/**
* 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
* is permitted to take place (possibly, then, on a different worker).
*
* This is a required field.
*/
timeout: Duration;
/**
* The amount of time that should pass between task invocation starts.
* Essentially, this equals roughly how often you want the task to run.
* How often you want the task to run. The system does its best to avoid
* overlapping invocations.
*
* This is a best effort value; under some circumstances there can be
* deviations. For example, if the task runtime is longer than the frequency
@@ -55,11 +47,37 @@ export interface TaskScheduleDefinition {
* invocation of this task will be delayed until after the previous one
* finishes.
*
* The system does its best to avoid overlapping invocations.
*
* This is a required field.
*/
frequency: Duration;
frequency:
| {
/**
* A crontab style string.
*
* Overview:
*
* ```
* ┌────────────── second (optional)
* │ ┌──────────── minute
* │ │ ┌────────── hour
* │ │ │ ┌──────── day of month
* │ │ │ │ ┌────── month
* │ │ │ │ │ ┌──── day of week
* │ │ │ │ │ │
* │ │ │ │ │ │
* * * * * * *
* ```
*/
cron: string;
}
| Duration;
/**
* 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
* is permitted to take place (possibly, then, on a different worker).
*/
timeout: Duration;
/**
* The amount of time that should pass before the first invocation happens.
@@ -68,7 +86,7 @@ export interface TaskScheduleDefinition {
* compute jobs.
*
* If no value is given for this field then the first invocation will happen
* as soon as possible.
* as soon as possible according to the cadence.
*/
initialDelay?: Duration;
}
@@ -150,7 +168,21 @@ export interface PluginTaskScheduler {
function isValidOptionalDurationString(d: string | undefined): boolean {
try {
return !d || Duration.fromISO(d).isValid === true;
return !d || Duration.fromISO(d).isValid;
} catch {
return false;
}
}
function isValidCronFormat(c: string | undefined): boolean {
try {
if (!c) {
return false;
}
// parse cron format to ensure it's a valid format.
// eslint-disable-next-line no-new
new CronTime(c);
return true;
} catch {
return false;
}
@@ -161,16 +193,46 @@ export const taskSettingsV1Schema = z.object({
initialDelayDuration: z
.string()
.optional()
.refine(isValidOptionalDurationString, { message: 'Invalid duration' }),
.refine(isValidOptionalDurationString, {
message: 'Invalid duration, expecting ISO Period',
}),
recurringAtMostEveryDuration: z
.string()
.refine(isValidOptionalDurationString, { message: 'Invalid duration' }),
timeoutAfterDuration: z
.string()
.refine(isValidOptionalDurationString, { message: 'Invalid duration' }),
.refine(isValidOptionalDurationString, {
message: 'Invalid duration, expecting ISO Period',
}),
timeoutAfterDuration: z.string().refine(isValidOptionalDurationString, {
message: 'Invalid duration, expecting ISO Period',
}),
});
/**
* The properties that control a scheduled task (version 1).
*/
export type TaskSettingsV1 = z.infer<typeof taskSettingsV1Schema>;
export const taskSettingsV2Schema = z.object({
version: z.literal(2),
cadence: z
.string()
.refine(isValidCronFormat, { message: 'Invalid cron' })
.or(
z.string().refine(isValidOptionalDurationString, {
message: 'Invalid duration, expecting ISO Period',
}),
),
timeoutAfterDuration: z.string().refine(isValidOptionalDurationString, {
message: 'Invalid duration, expecting ISO Period',
}),
initialDelayDuration: z
.string()
.optional()
.refine(isValidOptionalDurationString, {
message: 'Invalid duration, expecting ISO Period',
}),
});
/**
* The properties that control a scheduled task (version 2).
*/
export type TaskSettingsV2 = z.infer<typeof taskSettingsV2Schema>;
@@ -45,7 +45,7 @@ describe('EntityAirbrakeWidget', () => {
expect(exampleData.groups.length).toBeGreaterThan(0);
for (const group of exampleData.groups) {
expect(
await widget.getByText(group.errors[0].message),
await widget.findByText(group.errors[0].message),
).toBeInTheDocument();
}
});
+20 -5
View File
@@ -5653,6 +5653,14 @@
resolved "https://registry.npmjs.org/@types/cors/-/cors-2.8.12.tgz#6b2c510a7ad7039e98e7b8d3d6598f4359e5c080"
integrity sha512-vt+kDhq/M2ayberEtJcIN/hxXy1Pk+59g2FV/ZQceeaTyCtCucjL2Q7FXlFjtWn4n15KCr1NE2lNNFhp0lEThw==
"@types/cron@^1.7.3":
version "1.7.3"
resolved "https://registry.npmjs.org/@types/cron/-/cron-1.7.3.tgz#993db7d54646f61128c851607b64ba4495deae93"
integrity sha512-iPmUXyIJG1Js+ldPYhOQcYU3kCAQ2FWrSkm1FJPoii2eYSn6wEW6onPukNTT0bfiflexNSRPl6KWmAIqS+36YA==
dependencies:
"@types/node" "*"
moment ">=2.14.0"
"@types/d3-color@*":
version "3.0.2"
resolved "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.0.2.tgz#53f2d6325f66ee79afd707c05ac849e8ae0edbb0"
@@ -9850,6 +9858,13 @@ create-require@^1.1.0:
resolved "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333"
integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==
cron@^1.8.2:
version "1.8.2"
resolved "https://registry.npmjs.org/cron/-/cron-1.8.2.tgz#4ac5e3c55ba8c163d84f3407bde94632da8370ce"
integrity sha512-Gk2c4y6xKEO8FSAUTklqtfSr7oTq0CiPQeLBG5Fl0qoXpZyMcj1SG59YL+hqq04bu6/IuEA7lMkYDAplQNKkyg==
dependencies:
moment-timezone "^0.5.x"
cronstrue@^1.122.0:
version "1.125.0"
resolved "https://registry.npmjs.org/cronstrue/-/cronstrue-1.125.0.tgz#8030816d033d00caade9b2a9f9b71e69175bcf42"
@@ -18041,14 +18056,14 @@ modify-values@^1.0.0:
resolved "https://registry.npmjs.org/modify-values/-/modify-values-1.0.1.tgz#b3939fa605546474e3e3e3c63d64bd43b4ee6022"
integrity sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw==
moment-timezone@^0.5.31:
version "0.5.33"
resolved "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.33.tgz#b252fd6bb57f341c9b59a5ab61a8e51a73bbd22c"
integrity sha512-PTc2vcT8K9J5/9rDEPe5czSIKgLoGsH8UNpA4qZTVw0Vd/Uz19geE9abbIOQKaAQFcnQ3v5YEXrbSc5BpshH+w==
moment-timezone@^0.5.31, moment-timezone@^0.5.x:
version "0.5.34"
resolved "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.34.tgz#a75938f7476b88f155d3504a9343f7519d9a405c"
integrity sha512-3zAEHh2hKUs3EXLESx/wsgw6IQdusOT8Bxm3D9UrHPQR7zlMmzwybC8zHEM1tQ4LJwP7fcxrWr8tuBg05fFCbg==
dependencies:
moment ">= 2.9.0"
"moment@>= 2.9.0", moment@^2.27.0, moment@^2.29.1:
"moment@>= 2.9.0", moment@>=2.14.0, moment@^2.27.0, moment@^2.29.1:
version "2.29.1"
resolved "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3"
integrity sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==