Merge pull request #10062 from backstage/freben/freben/task-cron-source
Add cron support to `@backstage/backend-tasks`
This commit is contained in:
@@ -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 * * *'`).
|
||||
@@ -61,6 +61,7 @@ const
|
||||
cookiecutter
|
||||
cron
|
||||
cronjobs
|
||||
crontab
|
||||
css
|
||||
Datadog
|
||||
dataflow
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -36,7 +36,11 @@ export interface TaskRunner {
|
||||
|
||||
// @public
|
||||
export interface TaskScheduleDefinition {
|
||||
frequency: Duration;
|
||||
frequency:
|
||||
| {
|
||||
cron: string;
|
||||
}
|
||||
| Duration;
|
||||
initialDelay?: Duration;
|
||||
timeout: Duration;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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==
|
||||
|
||||
Reference in New Issue
Block a user