refactor(plugin-workflow): refactor schedule trigger implementation (#3562)
* refactor(plugin-workflow): refactor schedule trigger implementation * fix(plugin-workflow): fix test case * fix(plugin-workflow): fix cleanup on app stop
This commit is contained in:
parent
e6719763f6
commit
88b281277c
@ -20,6 +20,7 @@ export function EndsByField({ value, onChange }) {
|
|||||||
onChange={(t) => {
|
onChange={(t) => {
|
||||||
onChange(t ? (t === 'field' ? {} : new Date()) : null);
|
onChange(t ? (t === 'field' ? {} : new Date()) : null);
|
||||||
}}
|
}}
|
||||||
|
className="auto-width"
|
||||||
>
|
>
|
||||||
<Select.Option value={null}>{t('No end')}</Select.Option>
|
<Select.Option value={null}>{t('No end')}</Select.Option>
|
||||||
<Select.Option value={'field'}>{t('By field')}</Select.Option>
|
<Select.Option value={'field'}>{t('By field')}</Select.Option>
|
||||||
|
@ -26,6 +26,7 @@ export function OnField({ value, onChange }) {
|
|||||||
onChange={(field) => onChange({ ...value, field })}
|
onChange={(field) => onChange({ ...value, field })}
|
||||||
filter={dateFieldFilter}
|
filter={dateFieldFilter}
|
||||||
placeholder={t('Select field')}
|
placeholder={t('Select field')}
|
||||||
|
className="auto-width"
|
||||||
/>
|
/>
|
||||||
{value.field ? (
|
{value.field ? (
|
||||||
<Select
|
<Select
|
||||||
@ -39,6 +40,7 @@ export function OnField({ value, onChange }) {
|
|||||||
{ value: -1, label: t('Before') },
|
{ value: -1, label: t('Before') },
|
||||||
{ value: 1, label: t('After') },
|
{ value: 1, label: t('After') },
|
||||||
]}
|
]}
|
||||||
|
className="auto-width"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{dir ? (
|
{dir ? (
|
||||||
@ -56,6 +58,7 @@ export function OnField({ value, onChange }) {
|
|||||||
{ value: 60000, label: lang('Minutes') },
|
{ value: 60000, label: lang('Minutes') },
|
||||||
{ value: 1000, label: lang('Seconds') },
|
{ value: 1000, label: lang('Seconds') },
|
||||||
]}
|
]}
|
||||||
|
className="auto-width"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
@ -65,7 +65,7 @@ const ModeFieldsets = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
[SCHEDULE_MODE.COLLECTION_FIELD]: {
|
[SCHEDULE_MODE.DATE_FIELD]: {
|
||||||
collection: {
|
collection: {
|
||||||
...collection,
|
...collection,
|
||||||
'x-reactions': [
|
'x-reactions': [
|
||||||
@ -145,7 +145,7 @@ const ModeFieldsets = {
|
|||||||
dependencies: ['mode', 'collection'],
|
dependencies: ['mode', 'collection'],
|
||||||
fulfill: {
|
fulfill: {
|
||||||
state: {
|
state: {
|
||||||
visible: `{{$deps[0] === ${SCHEDULE_MODE.COLLECTION_FIELD} && $deps[1]}}`,
|
visible: `{{$deps[0] === ${SCHEDULE_MODE.DATE_FIELD} && $deps[1]}}`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -157,7 +157,7 @@ const ModeFieldsets = {
|
|||||||
const scheduleModeOptions = [
|
const scheduleModeOptions = [
|
||||||
{ value: SCHEDULE_MODE.STATIC, label: `{{t("Based on certain date", { ns: "${NAMESPACE}" })}}` },
|
{ value: SCHEDULE_MODE.STATIC, label: `{{t("Based on certain date", { ns: "${NAMESPACE}" })}}` },
|
||||||
{
|
{
|
||||||
value: SCHEDULE_MODE.COLLECTION_FIELD,
|
value: SCHEDULE_MODE.DATE_FIELD,
|
||||||
label: `{{t("Based on date field of collection", { ns: "${NAMESPACE}" })}}`,
|
label: `{{t("Based on date field of collection", { ns: "${NAMESPACE}" })}}`,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
export const SCHEDULE_MODE = {
|
export const SCHEDULE_MODE = {
|
||||||
STATIC: 0,
|
STATIC: 0,
|
||||||
COLLECTION_FIELD: 1,
|
DATE_FIELD: 1,
|
||||||
};
|
};
|
||||||
|
@ -38,7 +38,7 @@ export default class extends Trigger {
|
|||||||
// ? config.appends.reduce((max, item) => Math.max(max, item.split('.').length), 1) + 1
|
// ? config.appends.reduce((max, item) => Math.max(max, item.split('.').length), 1) + 1
|
||||||
// : 1;
|
// : 1;
|
||||||
|
|
||||||
if (config.mode === SCHEDULE_MODE.COLLECTION_FIELD) {
|
if (config.mode === SCHEDULE_MODE.DATE_FIELD) {
|
||||||
const [fieldOption] = getCollectionFieldOptions({
|
const [fieldOption] = getCollectionFieldOptions({
|
||||||
// depth,
|
// depth,
|
||||||
appends: ['data', ...(config.appends?.map((item) => `data.${item}`) || [])],
|
appends: ['data', ...(config.appends?.map((item) => `data.${item}`) || [])],
|
||||||
|
@ -1,369 +0,0 @@
|
|||||||
import { Application } from '@nocobase/server';
|
|
||||||
import Database from '@nocobase/database';
|
|
||||||
import { getApp, sleep } from '@nocobase/plugin-workflow-test';
|
|
||||||
|
|
||||||
describe.skip('workflow > triggers > schedule', () => {
|
|
||||||
let app: Application;
|
|
||||||
let db: Database;
|
|
||||||
let PostRepo;
|
|
||||||
let CategoryRepo;
|
|
||||||
let WorkflowModel;
|
|
||||||
let WorkflowRepo;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
app = await getApp();
|
|
||||||
|
|
||||||
db = app.db;
|
|
||||||
const workflow = db.getCollection('workflows');
|
|
||||||
WorkflowModel = workflow.model;
|
|
||||||
WorkflowRepo = workflow.repository;
|
|
||||||
PostRepo = db.getCollection('posts').repository;
|
|
||||||
CategoryRepo = db.getCollection('categories').repository;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => app.destroy());
|
|
||||||
|
|
||||||
describe('constant mode', () => {
|
|
||||||
it('neither startsOn nor repeat configurated', async () => {
|
|
||||||
const workflow = await WorkflowModel.create({
|
|
||||||
enabled: true,
|
|
||||||
type: 'schedule',
|
|
||||||
config: {
|
|
||||||
mode: 0,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await sleep(3000);
|
|
||||||
|
|
||||||
const executions = await workflow.getExecutions();
|
|
||||||
expect(executions.length).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('on every 2 seconds', async () => {
|
|
||||||
const now = new Date();
|
|
||||||
// NOTE: align to even(0, 2, ...) + 0.5 seconds to start
|
|
||||||
await sleep((2.5 - (now.getSeconds() % 2)) * 1000 - now.getMilliseconds());
|
|
||||||
|
|
||||||
const workflow = await WorkflowModel.create({
|
|
||||||
enabled: true,
|
|
||||||
type: 'schedule',
|
|
||||||
config: {
|
|
||||||
mode: 0,
|
|
||||||
startsOn: now.toISOString(),
|
|
||||||
repeat: '*/2 * * * * *',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await sleep(4000);
|
|
||||||
// sleep 1.5s at 2s trigger 1st time
|
|
||||||
// sleep 3.5s at 4s trigger 2nd time
|
|
||||||
|
|
||||||
const executions = await workflow.getExecutions();
|
|
||||||
expect(executions.length).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('on every even seconds and limit 1', async () => {
|
|
||||||
const now = new Date();
|
|
||||||
// NOTE: align to even(0, 2, ...) + 0.5 seconds to start
|
|
||||||
await sleep((2.5 - (now.getSeconds() % 2)) * 1000 - now.getMilliseconds());
|
|
||||||
|
|
||||||
const workflow = await WorkflowModel.create({
|
|
||||||
enabled: true,
|
|
||||||
type: 'schedule',
|
|
||||||
config: {
|
|
||||||
mode: 0,
|
|
||||||
startsOn: now.toISOString(),
|
|
||||||
repeat: '*/2 * * * * *',
|
|
||||||
limit: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await sleep(5000);
|
|
||||||
|
|
||||||
const executions = await workflow.getExecutions();
|
|
||||||
expect(executions.length).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('on every 2 seconds after created and limit 1', async () => {
|
|
||||||
const now = new Date();
|
|
||||||
// NOTE: align to even(0, 2, ...) + 0.5 seconds to start
|
|
||||||
await sleep((2.5 - (now.getSeconds() % 2)) * 1000 - now.getMilliseconds());
|
|
||||||
|
|
||||||
const workflow = await WorkflowModel.create({
|
|
||||||
enabled: true,
|
|
||||||
type: 'schedule',
|
|
||||||
config: {
|
|
||||||
mode: 0,
|
|
||||||
startsOn: now.toISOString(),
|
|
||||||
repeat: 2000,
|
|
||||||
limit: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await sleep(5000);
|
|
||||||
|
|
||||||
const executions = await workflow.getExecutions();
|
|
||||||
expect(executions.length).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('on certain second', async () => {
|
|
||||||
const now = new Date();
|
|
||||||
const startsOn = now.toISOString();
|
|
||||||
now.setSeconds(now.getSeconds() + 3);
|
|
||||||
now.setMilliseconds(0);
|
|
||||||
|
|
||||||
const workflow = await WorkflowModel.create({
|
|
||||||
enabled: true,
|
|
||||||
type: 'schedule',
|
|
||||||
config: {
|
|
||||||
mode: 0,
|
|
||||||
startsOn,
|
|
||||||
repeat: `${now.getSeconds()} * * * * *`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await sleep(5000);
|
|
||||||
|
|
||||||
const executions = await workflow.getExecutions();
|
|
||||||
expect(executions.length).toBe(1);
|
|
||||||
expect(executions[0].context.date).toBe(now.toISOString());
|
|
||||||
});
|
|
||||||
|
|
||||||
it('multiple workflows trigger at same time', async () => {
|
|
||||||
const now = new Date();
|
|
||||||
const startsOn = now.toISOString();
|
|
||||||
now.setSeconds(now.getSeconds() + 2);
|
|
||||||
now.setMilliseconds(0);
|
|
||||||
|
|
||||||
let w1, w2;
|
|
||||||
await db.sequelize.transaction(async (transaction) => {
|
|
||||||
w1 = await WorkflowRepo.create({
|
|
||||||
values: {
|
|
||||||
enabled: true,
|
|
||||||
type: 'schedule',
|
|
||||||
config: {
|
|
||||||
mode: 0,
|
|
||||||
startsOn,
|
|
||||||
repeat: `${now.getSeconds()} * * * * *`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await db.sequelize.transaction(async (transaction) => {
|
|
||||||
w2 = await WorkflowRepo.create({
|
|
||||||
values: {
|
|
||||||
enabled: true,
|
|
||||||
type: 'schedule',
|
|
||||||
config: {
|
|
||||||
mode: 0,
|
|
||||||
startsOn,
|
|
||||||
repeat: `${now.getSeconds()} * * * * *`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await sleep(3000);
|
|
||||||
await WorkflowModel.update({ enabled: false }, { where: { enabled: true } });
|
|
||||||
|
|
||||||
const [e1] = await w1.getExecutions();
|
|
||||||
expect(e1).toBeDefined();
|
|
||||||
expect(e1.context.date).toBe(now.toISOString());
|
|
||||||
|
|
||||||
const [e2] = await w2.getExecutions();
|
|
||||||
expect(e2).toBeDefined();
|
|
||||||
expect(e2.context.date).toBe(now.toISOString());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('collection field mode', () => {
|
|
||||||
it('starts on post.createdAt with offset', async () => {
|
|
||||||
const workflow = await WorkflowModel.create({
|
|
||||||
enabled: true,
|
|
||||||
type: 'schedule',
|
|
||||||
config: {
|
|
||||||
mode: 1,
|
|
||||||
collection: 'posts',
|
|
||||||
startsOn: {
|
|
||||||
field: 'createdAt',
|
|
||||||
offset: 2,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
await sleep((2.5 - (now.getSeconds() % 2)) * 1000 - now.getMilliseconds());
|
|
||||||
|
|
||||||
const post = await PostRepo.create({ values: { title: 't1' } });
|
|
||||||
|
|
||||||
await sleep(1000);
|
|
||||||
const executions = await workflow.getExecutions();
|
|
||||||
expect(executions.length).toBe(0);
|
|
||||||
|
|
||||||
await sleep(2000);
|
|
||||||
const [execution] = await workflow.getExecutions();
|
|
||||||
expect(execution).toBeDefined();
|
|
||||||
expect(execution.context.data.id).toBe(post.id);
|
|
||||||
|
|
||||||
const triggerTime = new Date(post.createdAt.getTime() + 2000);
|
|
||||||
triggerTime.setMilliseconds(0);
|
|
||||||
expect(execution.context.date).toBe(triggerTime.toISOString());
|
|
||||||
});
|
|
||||||
|
|
||||||
it('starts on post.createdAt and repeat by cron', async () => {
|
|
||||||
const workflow = await WorkflowModel.create({
|
|
||||||
enabled: true,
|
|
||||||
type: 'schedule',
|
|
||||||
config: {
|
|
||||||
mode: 1,
|
|
||||||
collection: 'posts',
|
|
||||||
startsOn: {
|
|
||||||
field: 'createdAt',
|
|
||||||
},
|
|
||||||
repeat: '*/2 * * * * *',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
await sleep((2.5 - (now.getSeconds() % 2)) * 1000 - now.getMilliseconds());
|
|
||||||
const startTime = new Date();
|
|
||||||
startTime.setMilliseconds(500);
|
|
||||||
|
|
||||||
const post = await PostRepo.create({ values: { title: 't1' } });
|
|
||||||
|
|
||||||
await sleep(5000);
|
|
||||||
// sleep 1.5s at 2s trigger 1st time
|
|
||||||
// sleep 3.5s at 4s trigger 2nd time
|
|
||||||
|
|
||||||
const executions = await workflow.getExecutions({ order: [['createdAt', 'ASC']] });
|
|
||||||
expect(executions.length).toBe(2);
|
|
||||||
const d1 = Date.parse(executions[0].context.date);
|
|
||||||
expect(d1 - 1500).toBe(startTime.getTime());
|
|
||||||
const d2 = Date.parse(executions[1].context.date);
|
|
||||||
expect(d2 - 3500).toBe(startTime.getTime());
|
|
||||||
});
|
|
||||||
|
|
||||||
it('starts on post.createdAt and repeat with endsOn at certain time', async () => {
|
|
||||||
const now = new Date();
|
|
||||||
await sleep((2.5 - (now.getSeconds() % 2)) * 1000 - now.getMilliseconds());
|
|
||||||
const startTime = new Date();
|
|
||||||
startTime.setMilliseconds(500);
|
|
||||||
|
|
||||||
const workflow = await WorkflowModel.create({
|
|
||||||
enabled: true,
|
|
||||||
type: 'schedule',
|
|
||||||
config: {
|
|
||||||
mode: 1,
|
|
||||||
collection: 'posts',
|
|
||||||
startsOn: {
|
|
||||||
field: 'createdAt',
|
|
||||||
},
|
|
||||||
repeat: '*/2 * * * * *',
|
|
||||||
endsOn: new Date(startTime.getTime() + 2500).toISOString(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const post = await PostRepo.create({ values: { title: 't1' } });
|
|
||||||
|
|
||||||
await sleep(5000);
|
|
||||||
|
|
||||||
const executions = await workflow.getExecutions();
|
|
||||||
expect(executions.length).toBe(1);
|
|
||||||
const d1 = Date.parse(executions[0].context.date);
|
|
||||||
expect(d1 - 1500).toBe(startTime.getTime());
|
|
||||||
});
|
|
||||||
|
|
||||||
it('starts on post.createdAt and repeat with endsOn by offset', async () => {
|
|
||||||
const workflow = await WorkflowModel.create({
|
|
||||||
enabled: true,
|
|
||||||
type: 'schedule',
|
|
||||||
config: {
|
|
||||||
mode: 1,
|
|
||||||
collection: 'posts',
|
|
||||||
startsOn: {
|
|
||||||
field: 'createdAt',
|
|
||||||
},
|
|
||||||
repeat: '*/2 * * * * *',
|
|
||||||
endsOn: {
|
|
||||||
field: 'createdAt',
|
|
||||||
offset: 3,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
await sleep((2.5 - (now.getSeconds() % 2)) * 1000 - now.getMilliseconds());
|
|
||||||
const startTime = new Date();
|
|
||||||
startTime.setMilliseconds(500);
|
|
||||||
|
|
||||||
const post = await PostRepo.create({ values: { title: 't1' } });
|
|
||||||
|
|
||||||
await sleep(5000);
|
|
||||||
const executions = await workflow.getExecutions();
|
|
||||||
expect(executions.length).toBe(1);
|
|
||||||
const d1 = Date.parse(executions[0].context.date);
|
|
||||||
expect(d1 - 1500).toBe(startTime.getTime());
|
|
||||||
});
|
|
||||||
|
|
||||||
it('starts on post.createdAt and repeat by number', async () => {
|
|
||||||
const workflow = await WorkflowModel.create({
|
|
||||||
enabled: true,
|
|
||||||
type: 'schedule',
|
|
||||||
config: {
|
|
||||||
mode: 1,
|
|
||||||
collection: 'posts',
|
|
||||||
startsOn: {
|
|
||||||
field: 'createdAt',
|
|
||||||
},
|
|
||||||
repeat: 2000,
|
|
||||||
endsOn: {
|
|
||||||
field: 'createdAt',
|
|
||||||
offset: 3,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
await sleep((2.5 - (now.getSeconds() % 2)) * 1000 - now.getMilliseconds());
|
|
||||||
const startTime = new Date();
|
|
||||||
startTime.setMilliseconds(500);
|
|
||||||
|
|
||||||
const post = await PostRepo.create({ values: { title: 't1' } });
|
|
||||||
|
|
||||||
await sleep(5000);
|
|
||||||
const executions = await workflow.getExecutions();
|
|
||||||
expect(executions.length).toBe(1);
|
|
||||||
const d1 = Date.parse(executions[0].context.date);
|
|
||||||
expect(d1 - 1500).toBe(startTime.getTime());
|
|
||||||
});
|
|
||||||
|
|
||||||
it('appends', async () => {
|
|
||||||
const category = await CategoryRepo.create({ values: { name: 'c1' } });
|
|
||||||
|
|
||||||
const workflow = await WorkflowModel.create({
|
|
||||||
enabled: true,
|
|
||||||
type: 'schedule',
|
|
||||||
config: {
|
|
||||||
mode: 1,
|
|
||||||
collection: 'posts',
|
|
||||||
startsOn: {
|
|
||||||
field: 'createdAt',
|
|
||||||
offset: 2,
|
|
||||||
},
|
|
||||||
appends: ['category'],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const post = await PostRepo.create({ values: { title: 't1', categoryId: category.id } });
|
|
||||||
|
|
||||||
await sleep(5000);
|
|
||||||
|
|
||||||
const executions = await workflow.getExecutions();
|
|
||||||
expect(executions.length).toBe(1);
|
|
||||||
expect(executions[0].context.data.category.id).toBe(category.id);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
@ -0,0 +1,372 @@
|
|||||||
|
import { MockServer } from '@nocobase/test';
|
||||||
|
import Database from '@nocobase/database';
|
||||||
|
import { getApp, sleep } from '@nocobase/plugin-workflow-test';
|
||||||
|
|
||||||
|
async function sleepToEvenSecond() {
|
||||||
|
const now = new Date();
|
||||||
|
// NOTE: align to even(0, 2, ...) + 0.5 seconds to start
|
||||||
|
await sleep((2.5 - (now.getSeconds() % 2)) * 1000 - now.getMilliseconds());
|
||||||
|
return now;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('workflow > triggers > schedule > date field mode', () => {
|
||||||
|
let app: MockServer;
|
||||||
|
let db: Database;
|
||||||
|
let PostRepo;
|
||||||
|
let CategoryRepo;
|
||||||
|
let WorkflowModel;
|
||||||
|
let WorkflowRepo;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
app = await getApp();
|
||||||
|
|
||||||
|
db = app.db;
|
||||||
|
const workflow = db.getCollection('workflows');
|
||||||
|
WorkflowModel = workflow.model;
|
||||||
|
WorkflowRepo = workflow.repository;
|
||||||
|
PostRepo = db.getCollection('posts').repository;
|
||||||
|
CategoryRepo = db.getCollection('categories').repository;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => app.destroy());
|
||||||
|
|
||||||
|
describe('configuration', () => {
|
||||||
|
it('starts on post.createdAt', async () => {
|
||||||
|
const workflow = await WorkflowModel.create({
|
||||||
|
enabled: true,
|
||||||
|
type: 'schedule',
|
||||||
|
config: {
|
||||||
|
mode: 1,
|
||||||
|
collection: 'posts',
|
||||||
|
startsOn: {
|
||||||
|
field: 'createdAt',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const now = await sleepToEvenSecond();
|
||||||
|
|
||||||
|
const post = await PostRepo.create({ values: { title: 't1' } });
|
||||||
|
|
||||||
|
await sleep(2000);
|
||||||
|
|
||||||
|
const executions = await workflow.getExecutions();
|
||||||
|
expect(executions.length).toBe(1);
|
||||||
|
expect(executions[0].context.data.id).toBe(post.id);
|
||||||
|
const triggerTime = new Date(post.createdAt);
|
||||||
|
triggerTime.setMilliseconds(0);
|
||||||
|
expect(executions[0].context.date).toBe(triggerTime.toISOString());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts on post.createdAt with +offset', async () => {
|
||||||
|
const workflow = await WorkflowModel.create({
|
||||||
|
enabled: true,
|
||||||
|
type: 'schedule',
|
||||||
|
config: {
|
||||||
|
mode: 1,
|
||||||
|
collection: 'posts',
|
||||||
|
startsOn: {
|
||||||
|
field: 'createdAt',
|
||||||
|
offset: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const now = await sleepToEvenSecond();
|
||||||
|
|
||||||
|
const post = await PostRepo.create({ values: { title: 't1' } });
|
||||||
|
|
||||||
|
await sleep(1000);
|
||||||
|
const e1s = await workflow.getExecutions();
|
||||||
|
expect(e1s.length).toBe(0);
|
||||||
|
|
||||||
|
await sleep(2000);
|
||||||
|
const e2s = await workflow.getExecutions();
|
||||||
|
expect(e2s.length).toBe(1);
|
||||||
|
expect(e2s[0].context.data.id).toBe(post.id);
|
||||||
|
|
||||||
|
const triggerTime = new Date(post.createdAt.getTime() + 2000);
|
||||||
|
triggerTime.setMilliseconds(0);
|
||||||
|
expect(e2s[0].context.date).toBe(triggerTime.toISOString());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts on post.createdAt with -offset', async () => {
|
||||||
|
const workflow = await WorkflowModel.create({
|
||||||
|
enabled: true,
|
||||||
|
type: 'schedule',
|
||||||
|
config: {
|
||||||
|
mode: 1,
|
||||||
|
collection: 'posts',
|
||||||
|
startsOn: {
|
||||||
|
field: 'createdAt',
|
||||||
|
offset: -2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const now = await sleepToEvenSecond();
|
||||||
|
|
||||||
|
const post = await PostRepo.create({ values: { title: 't1' } });
|
||||||
|
|
||||||
|
await sleep(3000);
|
||||||
|
const executions = await workflow.getExecutions();
|
||||||
|
expect(executions.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts on post.createdAt and repeat by cron', async () => {
|
||||||
|
const workflow = await WorkflowModel.create({
|
||||||
|
enabled: true,
|
||||||
|
type: 'schedule',
|
||||||
|
config: {
|
||||||
|
mode: 1,
|
||||||
|
collection: 'posts',
|
||||||
|
startsOn: {
|
||||||
|
field: 'createdAt',
|
||||||
|
},
|
||||||
|
repeat: '*/2 * * * * *',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await sleepToEvenSecond();
|
||||||
|
const startTime = new Date();
|
||||||
|
startTime.setMilliseconds(0);
|
||||||
|
|
||||||
|
const post = await PostRepo.create({ values: { title: 't1' } });
|
||||||
|
|
||||||
|
await sleep(5000);
|
||||||
|
// immediately trigger 1st time
|
||||||
|
// sleep 1.5s at 2s trigger 2nd time
|
||||||
|
// sleep 3.5s at 4s trigger 3rd time
|
||||||
|
|
||||||
|
const executions = await workflow.getExecutions({ order: [['createdAt', 'ASC']] });
|
||||||
|
expect(executions.length).toBe(3);
|
||||||
|
const d0 = Date.parse(executions[0].context.date);
|
||||||
|
expect(d0).toBe(startTime.getTime());
|
||||||
|
const d1 = Date.parse(executions[1].context.date);
|
||||||
|
expect(d1 - 2000).toBe(startTime.getTime());
|
||||||
|
const d2 = Date.parse(executions[2].context.date);
|
||||||
|
expect(d2 - 4000).toBe(startTime.getTime());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts on post.createdAt and repeat by cron with endsOn at certain time', async () => {
|
||||||
|
await sleepToEvenSecond();
|
||||||
|
const startTime = new Date();
|
||||||
|
startTime.setMilliseconds(0);
|
||||||
|
|
||||||
|
const workflow = await WorkflowModel.create({
|
||||||
|
enabled: true,
|
||||||
|
type: 'schedule',
|
||||||
|
config: {
|
||||||
|
mode: 1,
|
||||||
|
collection: 'posts',
|
||||||
|
startsOn: {
|
||||||
|
field: 'createdAt',
|
||||||
|
},
|
||||||
|
repeat: '*/2 * * * * *',
|
||||||
|
endsOn: new Date(startTime.getTime() + 3000).toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const post = await PostRepo.create({ values: { title: 't1' } });
|
||||||
|
|
||||||
|
await sleep(5000);
|
||||||
|
|
||||||
|
const executions = await workflow.getExecutions({ order: [['createdAt', 'ASC']] });
|
||||||
|
expect(executions.length).toBe(2);
|
||||||
|
const d0 = Date.parse(executions[0].context.date);
|
||||||
|
expect(d0).toBe(startTime.getTime());
|
||||||
|
const d1 = Date.parse(executions[1].context.date);
|
||||||
|
expect(d1 - 2000).toBe(startTime.getTime());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts on post.createdAt and repeat by cron with endsOn by offset', async () => {
|
||||||
|
const workflow = await WorkflowModel.create({
|
||||||
|
enabled: true,
|
||||||
|
type: 'schedule',
|
||||||
|
config: {
|
||||||
|
mode: 1,
|
||||||
|
collection: 'posts',
|
||||||
|
startsOn: {
|
||||||
|
field: 'createdAt',
|
||||||
|
},
|
||||||
|
repeat: '*/2 * * * * *',
|
||||||
|
endsOn: {
|
||||||
|
field: 'createdAt',
|
||||||
|
offset: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await sleepToEvenSecond();
|
||||||
|
const startTime = new Date();
|
||||||
|
startTime.setMilliseconds(0);
|
||||||
|
|
||||||
|
const post = await PostRepo.create({ values: { title: 't1' } });
|
||||||
|
|
||||||
|
await sleep(5000);
|
||||||
|
|
||||||
|
const executions = await workflow.getExecutions({ order: [['createdAt', 'ASC']] });
|
||||||
|
expect(executions.length).toBe(2);
|
||||||
|
const d0 = Date.parse(executions[0].context.date);
|
||||||
|
expect(d0).toBe(startTime.getTime());
|
||||||
|
const d1 = Date.parse(executions[1].context.date);
|
||||||
|
expect(d1 - 2000).toBe(startTime.getTime());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts on post.createdAt and repeat by cron and limit 1', async () => {
|
||||||
|
const workflow = await WorkflowModel.create({
|
||||||
|
enabled: true,
|
||||||
|
type: 'schedule',
|
||||||
|
config: {
|
||||||
|
mode: 1,
|
||||||
|
collection: 'posts',
|
||||||
|
startsOn: {
|
||||||
|
field: 'createdAt',
|
||||||
|
},
|
||||||
|
repeat: '*/2 * * * * *',
|
||||||
|
limit: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await sleepToEvenSecond();
|
||||||
|
const startTime = new Date();
|
||||||
|
startTime.setMilliseconds(0);
|
||||||
|
|
||||||
|
const post = await PostRepo.create({ values: { title: 't1' } });
|
||||||
|
|
||||||
|
await sleep(5000);
|
||||||
|
|
||||||
|
const executions = await workflow.getExecutions({ order: [['createdAt', 'ASC']] });
|
||||||
|
expect(executions.length).toBe(1);
|
||||||
|
const d0 = Date.parse(executions[0].context.date);
|
||||||
|
expect(d0).toBe(startTime.getTime());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts on post.createdAt and repeat by cron and limit 2', async () => {
|
||||||
|
const workflow = await WorkflowModel.create({
|
||||||
|
enabled: true,
|
||||||
|
type: 'schedule',
|
||||||
|
config: {
|
||||||
|
mode: 1,
|
||||||
|
collection: 'posts',
|
||||||
|
startsOn: {
|
||||||
|
field: 'createdAt',
|
||||||
|
},
|
||||||
|
repeat: '*/2 * * * * *',
|
||||||
|
limit: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await sleepToEvenSecond();
|
||||||
|
const startTime = new Date();
|
||||||
|
startTime.setMilliseconds(0);
|
||||||
|
|
||||||
|
const post = await PostRepo.create({ values: { title: 't1' } });
|
||||||
|
|
||||||
|
await sleep(5000);
|
||||||
|
|
||||||
|
const executions = await workflow.getExecutions({ order: [['createdAt', 'ASC']] });
|
||||||
|
expect(executions.length).toBe(2);
|
||||||
|
const d0 = Date.parse(executions[0].context.date);
|
||||||
|
expect(d0).toBe(startTime.getTime());
|
||||||
|
const d1 = Date.parse(executions[1].context.date);
|
||||||
|
expect(d1 - 2000).toBe(startTime.getTime());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts on post.createdAt and repeat by number', async () => {
|
||||||
|
const workflow = await WorkflowModel.create({
|
||||||
|
enabled: true,
|
||||||
|
type: 'schedule',
|
||||||
|
config: {
|
||||||
|
mode: 1,
|
||||||
|
collection: 'posts',
|
||||||
|
startsOn: {
|
||||||
|
field: 'createdAt',
|
||||||
|
},
|
||||||
|
repeat: 2000,
|
||||||
|
endsOn: {
|
||||||
|
field: 'createdAt',
|
||||||
|
offset: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await sleepToEvenSecond();
|
||||||
|
const startTime = new Date();
|
||||||
|
startTime.setMilliseconds(0);
|
||||||
|
|
||||||
|
const post = await PostRepo.create({ values: { title: 't1' } });
|
||||||
|
|
||||||
|
await sleep(5000);
|
||||||
|
const executions = await workflow.getExecutions({ order: [['createdAt', 'ASC']] });
|
||||||
|
expect(executions.length).toBe(2);
|
||||||
|
const d0 = Date.parse(executions[0].context.date);
|
||||||
|
expect(d0).toBe(startTime.getTime());
|
||||||
|
const d1 = Date.parse(executions[1].context.date);
|
||||||
|
expect(d1 - 2000).toBe(startTime.getTime());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends', async () => {
|
||||||
|
const category = await CategoryRepo.create({ values: { name: 'c1' } });
|
||||||
|
|
||||||
|
const workflow = await WorkflowModel.create({
|
||||||
|
enabled: true,
|
||||||
|
type: 'schedule',
|
||||||
|
config: {
|
||||||
|
mode: 1,
|
||||||
|
collection: 'posts',
|
||||||
|
startsOn: {
|
||||||
|
field: 'createdAt',
|
||||||
|
offset: 2,
|
||||||
|
},
|
||||||
|
appends: ['category'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const post = await PostRepo.create({ values: { title: 't1', categoryId: category.id } });
|
||||||
|
|
||||||
|
await sleep(5000);
|
||||||
|
|
||||||
|
const executions = await workflow.getExecutions();
|
||||||
|
expect(executions.length).toBe(1);
|
||||||
|
expect(executions[0].context.data.category.id).toBe(category.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('on field changed', async () => {
|
||||||
|
const workflow = await WorkflowModel.create({
|
||||||
|
enabled: true,
|
||||||
|
type: 'schedule',
|
||||||
|
config: {
|
||||||
|
mode: 1,
|
||||||
|
collection: 'posts',
|
||||||
|
startsOn: {
|
||||||
|
field: 'createdAt',
|
||||||
|
},
|
||||||
|
repeat: 1000,
|
||||||
|
endsOn: {
|
||||||
|
field: 'createdAt',
|
||||||
|
offset: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await sleepToEvenSecond();
|
||||||
|
|
||||||
|
const post = await PostRepo.create({ values: { title: 't1' } });
|
||||||
|
|
||||||
|
await sleep(1700);
|
||||||
|
|
||||||
|
console.log('check executions');
|
||||||
|
|
||||||
|
const e1c = await workflow.countExecutions();
|
||||||
|
expect(e1c).toBe(2);
|
||||||
|
|
||||||
|
await post.update({ createdAt: new Date(post.createdAt.getTime() - 1000) });
|
||||||
|
|
||||||
|
await sleep(3000);
|
||||||
|
|
||||||
|
const e2c = await workflow.countExecutions();
|
||||||
|
expect(e2c).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,279 @@
|
|||||||
|
import { scryptSync } from 'crypto';
|
||||||
|
import { MockServer } from '@nocobase/test';
|
||||||
|
import Database from '@nocobase/database';
|
||||||
|
import { getApp, sleep } from '@nocobase/plugin-workflow-test';
|
||||||
|
|
||||||
|
async function sleepToEvenSecond() {
|
||||||
|
const now = new Date();
|
||||||
|
// NOTE: align to even(0, 2, ...) + 0.5 seconds to start
|
||||||
|
await sleep((2.5 - (now.getSeconds() % 2)) * 1000 - now.getMilliseconds());
|
||||||
|
return now;
|
||||||
|
}
|
||||||
|
|
||||||
|
function consumeTime(n: number) {
|
||||||
|
console.time('consumeTime');
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
scryptSync(`${i}`, 'salt', 64);
|
||||||
|
}
|
||||||
|
console.timeEnd('consumeTime');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('workflow > triggers > schedule > static mode', () => {
|
||||||
|
let app: MockServer;
|
||||||
|
let db: Database;
|
||||||
|
let PostRepo;
|
||||||
|
let CategoryRepo;
|
||||||
|
let WorkflowModel;
|
||||||
|
let WorkflowRepo;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
app = await getApp();
|
||||||
|
|
||||||
|
db = app.db;
|
||||||
|
const workflow = db.getCollection('workflows');
|
||||||
|
WorkflowModel = workflow.model;
|
||||||
|
WorkflowRepo = workflow.repository;
|
||||||
|
PostRepo = db.getCollection('posts').repository;
|
||||||
|
CategoryRepo = db.getCollection('categories').repository;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => app.destroy());
|
||||||
|
|
||||||
|
describe('configuration', () => {
|
||||||
|
it('neither startsOn nor repeat configurated', async () => {
|
||||||
|
const workflow = await WorkflowModel.create({
|
||||||
|
enabled: true,
|
||||||
|
type: 'schedule',
|
||||||
|
config: {
|
||||||
|
mode: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await sleep(3000);
|
||||||
|
|
||||||
|
const executions = await workflow.getExecutions();
|
||||||
|
expect(executions.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('start on certain time and no repeat', async () => {
|
||||||
|
await sleepToEvenSecond();
|
||||||
|
|
||||||
|
const start = new Date();
|
||||||
|
start.setMilliseconds(0);
|
||||||
|
start.setSeconds(start.getSeconds() + 2);
|
||||||
|
|
||||||
|
const workflow = await WorkflowModel.create({
|
||||||
|
enabled: true,
|
||||||
|
type: 'schedule',
|
||||||
|
config: {
|
||||||
|
mode: 0,
|
||||||
|
startsOn: start.toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await sleep(3000);
|
||||||
|
|
||||||
|
const executions = await workflow.getExecutions();
|
||||||
|
expect(executions.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('on every 2 seconds', async () => {
|
||||||
|
const start = await sleepToEvenSecond();
|
||||||
|
|
||||||
|
const workflow = await WorkflowModel.create({
|
||||||
|
enabled: true,
|
||||||
|
type: 'schedule',
|
||||||
|
config: {
|
||||||
|
mode: 0,
|
||||||
|
startsOn: start.toISOString(),
|
||||||
|
repeat: '*/2 * * * * *',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await sleep(4000);
|
||||||
|
// sleep 1.5s at 2s trigger 1st time
|
||||||
|
// sleep 3.5s at 4s trigger 2nd time
|
||||||
|
|
||||||
|
const executions = await workflow.getExecutions();
|
||||||
|
expect(executions.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('on every even seconds and limit 1', async () => {
|
||||||
|
const start = await sleepToEvenSecond();
|
||||||
|
|
||||||
|
const workflow = await WorkflowModel.create({
|
||||||
|
enabled: true,
|
||||||
|
type: 'schedule',
|
||||||
|
config: {
|
||||||
|
mode: 0,
|
||||||
|
startsOn: start.toISOString(),
|
||||||
|
repeat: '*/2 * * * * *',
|
||||||
|
limit: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await sleep(5000);
|
||||||
|
|
||||||
|
const executions = await workflow.getExecutions();
|
||||||
|
expect(executions.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('start before now and repeat every 2 seconds after created and limit 1', async () => {
|
||||||
|
const start = await sleepToEvenSecond();
|
||||||
|
start.setMilliseconds(0);
|
||||||
|
|
||||||
|
const workflow = await WorkflowModel.create({
|
||||||
|
enabled: true,
|
||||||
|
type: 'schedule',
|
||||||
|
config: {
|
||||||
|
mode: 0,
|
||||||
|
startsOn: start.toISOString(),
|
||||||
|
repeat: 2000,
|
||||||
|
limit: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await sleep(5000);
|
||||||
|
|
||||||
|
const executions = await workflow.getExecutions();
|
||||||
|
expect(executions.length).toBe(1);
|
||||||
|
expect(new Date(executions[0].context.date).getTime()).toBe(start.getTime() + 2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('repeat on cron certain second', async () => {
|
||||||
|
const now = new Date();
|
||||||
|
now.setMilliseconds(0);
|
||||||
|
const startsOn = now.toISOString();
|
||||||
|
now.setSeconds(now.getSeconds() + 3);
|
||||||
|
|
||||||
|
await sleep(1500);
|
||||||
|
|
||||||
|
const workflow = await WorkflowModel.create({
|
||||||
|
enabled: true,
|
||||||
|
type: 'schedule',
|
||||||
|
config: {
|
||||||
|
mode: 0,
|
||||||
|
startsOn,
|
||||||
|
repeat: `${now.getSeconds()} * * * * *`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await sleep(4000);
|
||||||
|
|
||||||
|
const executions = await workflow.getExecutions();
|
||||||
|
expect(executions.length).toBe(1);
|
||||||
|
const date = new Date(executions[0].context.date);
|
||||||
|
expect(date.getTime()).toBe(now.getTime());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('multiple workflows trigger at same time', async () => {
|
||||||
|
const now = new Date();
|
||||||
|
const startsOn = now.toISOString();
|
||||||
|
now.setSeconds(now.getSeconds() + 2);
|
||||||
|
now.setMilliseconds(0);
|
||||||
|
|
||||||
|
let w1, w2;
|
||||||
|
await db.sequelize.transaction(async (transaction) => {
|
||||||
|
w1 = await WorkflowRepo.create({
|
||||||
|
values: {
|
||||||
|
enabled: true,
|
||||||
|
type: 'schedule',
|
||||||
|
config: {
|
||||||
|
mode: 0,
|
||||||
|
startsOn,
|
||||||
|
repeat: `${now.getSeconds()} * * * * *`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
w2 = await WorkflowRepo.create({
|
||||||
|
values: {
|
||||||
|
enabled: true,
|
||||||
|
type: 'schedule',
|
||||||
|
config: {
|
||||||
|
mode: 0,
|
||||||
|
startsOn,
|
||||||
|
repeat: `${now.getSeconds()} * * * * *`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await sleep(3000);
|
||||||
|
await WorkflowModel.update({ enabled: false }, { where: { enabled: true } });
|
||||||
|
|
||||||
|
const [e1] = await w1.getExecutions();
|
||||||
|
expect(e1).toBeDefined();
|
||||||
|
const d1 = new Date(e1.context.date);
|
||||||
|
d1.setMilliseconds(0);
|
||||||
|
expect(d1.getTime()).toBe(now.getTime());
|
||||||
|
|
||||||
|
const [e2] = await w2.getExecutions();
|
||||||
|
expect(e2).toBeDefined();
|
||||||
|
const d2 = new Date(e1.context.date);
|
||||||
|
d2.setMilliseconds(0);
|
||||||
|
expect(d2.getTime()).toBe(now.getTime());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('dispatch', () => {
|
||||||
|
it('missed non-repeated scheduled time should not be triggered', async () => {
|
||||||
|
await sleepToEvenSecond();
|
||||||
|
|
||||||
|
const start = new Date();
|
||||||
|
start.setMilliseconds(0);
|
||||||
|
start.setSeconds(start.getSeconds() + 2);
|
||||||
|
|
||||||
|
const workflow = await WorkflowModel.create({
|
||||||
|
enabled: true,
|
||||||
|
type: 'schedule',
|
||||||
|
config: {
|
||||||
|
mode: 0,
|
||||||
|
startsOn: start.toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.stop();
|
||||||
|
|
||||||
|
await sleep(3000);
|
||||||
|
|
||||||
|
await app.start();
|
||||||
|
|
||||||
|
await sleep(1000);
|
||||||
|
|
||||||
|
const c1 = await workflow.countExecutions();
|
||||||
|
expect(c1).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scheduled time on CPU heavy load should be triggered', async () => {
|
||||||
|
await sleepToEvenSecond();
|
||||||
|
|
||||||
|
const start = new Date();
|
||||||
|
start.setMilliseconds(0);
|
||||||
|
start.setSeconds(start.getSeconds() + 2);
|
||||||
|
|
||||||
|
const workflow = await WorkflowModel.create({
|
||||||
|
enabled: true,
|
||||||
|
type: 'schedule',
|
||||||
|
config: {
|
||||||
|
mode: 0,
|
||||||
|
startsOn: start.toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await sleep(1000);
|
||||||
|
|
||||||
|
const c1 = await workflow.countExecutions();
|
||||||
|
expect(c1).toBe(0);
|
||||||
|
|
||||||
|
consumeTime(100); // on AMD 5600G takes about 2.7s
|
||||||
|
|
||||||
|
await sleep(2000);
|
||||||
|
|
||||||
|
const c2 = await workflow.countExecutions();
|
||||||
|
expect(c2).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -1,572 +0,0 @@
|
|||||||
import { fn, literal, Op, where } from '@nocobase/database';
|
|
||||||
import parser from 'cron-parser';
|
|
||||||
import type Plugin from '../Plugin';
|
|
||||||
import Trigger from '.';
|
|
||||||
import type { WorkflowModel } from '../types';
|
|
||||||
|
|
||||||
export type ScheduleOnField =
|
|
||||||
| string
|
|
||||||
| {
|
|
||||||
field: string;
|
|
||||||
// in seconds
|
|
||||||
offset?: number;
|
|
||||||
unit?: 1000 | 60000 | 3600000 | 86400000;
|
|
||||||
};
|
|
||||||
export interface ScheduleTriggerConfig {
|
|
||||||
// trigger mode
|
|
||||||
mode: number;
|
|
||||||
// how to repeat
|
|
||||||
repeat?: string | number | null;
|
|
||||||
// limit of repeat times
|
|
||||||
limit?: number;
|
|
||||||
|
|
||||||
startsOn?: ScheduleOnField;
|
|
||||||
endsOn?: ScheduleOnField;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SCHEDULE_MODE = {
|
|
||||||
CONSTANT: 0,
|
|
||||||
COLLECTION_FIELD: 1,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
interface ScheduleMode {
|
|
||||||
on?(this: ScheduleTrigger, workflow: WorkflowModel): void;
|
|
||||||
off?(this: ScheduleTrigger, workflow: WorkflowModel): void;
|
|
||||||
shouldCache(this: ScheduleTrigger, workflow: WorkflowModel, now: Date): Promise<boolean> | boolean;
|
|
||||||
trigger(this: ScheduleTrigger, workflow: WorkflowModel, now: Date): Promise<number> | number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ScheduleModes = new Map<number, ScheduleMode>();
|
|
||||||
|
|
||||||
function parseDateWithoutMs(date: string) {
|
|
||||||
return Math.floor(Date.parse(date) / 1000) * 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
ScheduleModes.set(SCHEDULE_MODE.CONSTANT, {
|
|
||||||
shouldCache(workflow, now) {
|
|
||||||
const { startsOn, endsOn, repeat } = workflow.config;
|
|
||||||
const timestamp = now.getTime();
|
|
||||||
|
|
||||||
// NOTE: align to second start
|
|
||||||
const startTime = parseDateWithoutMs(startsOn);
|
|
||||||
if (!startTime || startTime > timestamp + this.cacheCycle) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (repeat) {
|
|
||||||
if (typeof repeat === 'number') {
|
|
||||||
const next = timestamp - ((timestamp - startTime) % repeat) + repeat;
|
|
||||||
if (next <= timestamp || next > timestamp + this.cacheCycle) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (endsOn) {
|
|
||||||
const endTime = parseDateWithoutMs(endsOn);
|
|
||||||
if (!endTime || endTime <= timestamp) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (startTime <= timestamp) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
trigger(workflow, now) {
|
|
||||||
const { startsOn, endsOn, repeat } = workflow.config;
|
|
||||||
const timestamp = now.getTime();
|
|
||||||
// NOTE: align to second start
|
|
||||||
const startTime = parseDateWithoutMs(startsOn);
|
|
||||||
if (!startTime || startTime > timestamp) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (repeat) {
|
|
||||||
if (typeof repeat === 'number') {
|
|
||||||
if (Math.round(timestamp - startTime) % repeat) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (endsOn) {
|
|
||||||
const endTime = parseDateWithoutMs(endsOn);
|
|
||||||
if (!endTime || endTime < timestamp) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (startTime !== timestamp) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.workflow.trigger(workflow, { date: now });
|
|
||||||
|
|
||||||
return 1;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function getOnTimestampWithOffset(on, now: Date) {
|
|
||||||
switch (typeof on) {
|
|
||||||
case 'string':
|
|
||||||
return parseDateWithoutMs(on);
|
|
||||||
case 'object': {
|
|
||||||
const { field, offset = 0, unit = 1000 } = on;
|
|
||||||
if (!field) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const timestamp = now.getTime();
|
|
||||||
// onDate + offset > now
|
|
||||||
// onDate > now - offset
|
|
||||||
return timestamp - offset * unit;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDataOptionTime(data, on, dir = 1) {
|
|
||||||
if (!on) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
switch (typeof on) {
|
|
||||||
case 'string': {
|
|
||||||
const time = parseDateWithoutMs(on);
|
|
||||||
return time ? time : null;
|
|
||||||
}
|
|
||||||
case 'object': {
|
|
||||||
const { field, offset = 0, unit = 1000 } = on;
|
|
||||||
return data.get(field) ? data.get(field).getTime() - offset * unit * dir : null;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getHookId(workflow, type: string) {
|
|
||||||
return `${type}#${workflow.id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DialectTimestampFnMap: { [key: string]: (col: string) => string } = {
|
|
||||||
postgres(col) {
|
|
||||||
return `CAST(FLOOR(extract(epoch from "${col}")) AS INTEGER)`;
|
|
||||||
},
|
|
||||||
mysql(col) {
|
|
||||||
return `CAST(FLOOR(UNIX_TIMESTAMP(\`${col}\`)) AS SIGNED INTEGER)`;
|
|
||||||
},
|
|
||||||
sqlite(col) {
|
|
||||||
return `CAST(FLOOR(unixepoch(${col})) AS INTEGER)`;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
DialectTimestampFnMap.mariadb = DialectTimestampFnMap.mysql;
|
|
||||||
|
|
||||||
ScheduleModes.set(SCHEDULE_MODE.COLLECTION_FIELD, {
|
|
||||||
on(workflow) {
|
|
||||||
const { collection, startsOn, endsOn, repeat } = workflow.config;
|
|
||||||
const event = `${collection}.afterSave`;
|
|
||||||
const name = getHookId(workflow, event);
|
|
||||||
if (this.events.has(name)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// NOTE: toggle cache depends on new date
|
|
||||||
const listener = async (data, options) => {
|
|
||||||
const now = new Date();
|
|
||||||
now.setMilliseconds(0);
|
|
||||||
const timestamp = now.getTime();
|
|
||||||
const startTime = getDataOptionTime(data, startsOn);
|
|
||||||
const endTime = getDataOptionTime(data, endsOn, -1);
|
|
||||||
if (!startTime) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (startTime && startTime > timestamp + this.cacheCycle) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (endTime && endTime <= timestamp) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!matchNext.call(this, workflow, now)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
typeof repeat === 'number' &&
|
|
||||||
repeat > this.cacheCycle &&
|
|
||||||
(timestamp - startTime) % repeat > this.cacheCycle
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setCache(workflow);
|
|
||||||
};
|
|
||||||
this.events.set(name, listener);
|
|
||||||
this.workflow.app.db.on(event, listener);
|
|
||||||
},
|
|
||||||
|
|
||||||
off(workflow) {
|
|
||||||
const { collection } = workflow.config;
|
|
||||||
const event = `${collection}.afterSave`;
|
|
||||||
const name = getHookId(workflow, event);
|
|
||||||
if (this.events.has(name)) {
|
|
||||||
const listener = this.events.get(name);
|
|
||||||
this.events.delete(name);
|
|
||||||
this.workflow.app.db.off(event, listener);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async shouldCache(workflow, now) {
|
|
||||||
const { db } = this.workflow.app;
|
|
||||||
const { startsOn, endsOn, repeat, collection } = workflow.config;
|
|
||||||
const timestamp = now.getTime();
|
|
||||||
|
|
||||||
const startTimestamp = getOnTimestampWithOffset(startsOn, now);
|
|
||||||
if (!startTimestamp) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const conditions: any[] = [
|
|
||||||
{
|
|
||||||
[startsOn.field]: {
|
|
||||||
[Op.lt]: new Date(startTimestamp + this.cacheCycle),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// when repeat is number, means repeat after startsOn
|
|
||||||
// (now - startsOn) % repeat <= cacheCycle
|
|
||||||
if (repeat) {
|
|
||||||
const tsFn = DialectTimestampFnMap[db.options.dialect];
|
|
||||||
if (typeof repeat === 'number' && repeat > this.cacheCycle && tsFn) {
|
|
||||||
const modExp = fn(
|
|
||||||
'MOD',
|
|
||||||
literal(`${Math.round(timestamp / 1000)} - ${tsFn(startsOn.field)}`),
|
|
||||||
Math.round(repeat / 1000),
|
|
||||||
);
|
|
||||||
conditions.push(where(modExp, { [Op.lt]: Math.round(this.cacheCycle / 1000) }));
|
|
||||||
// conditions.push(literal(`mod(${timestamp} - ${tsFn(startsOn.field)} * 1000, ${repeat}) < ${this.cacheCycle}`));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (endsOn) {
|
|
||||||
const endTimestamp = getOnTimestampWithOffset(endsOn, now);
|
|
||||||
if (!endTimestamp) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (typeof endsOn === 'string') {
|
|
||||||
if (endTimestamp <= timestamp) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
conditions.push({
|
|
||||||
[endsOn.field]: {
|
|
||||||
[Op.gte]: new Date(endTimestamp + this.interval),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
conditions.push({
|
|
||||||
[startsOn.field]: {
|
|
||||||
[Op.gte]: new Date(startTimestamp),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const { model } = db.getCollection(collection);
|
|
||||||
const count = await model.count({
|
|
||||||
where: { [Op.and]: conditions },
|
|
||||||
});
|
|
||||||
|
|
||||||
return Boolean(count);
|
|
||||||
},
|
|
||||||
|
|
||||||
async trigger(workflow, now: Date) {
|
|
||||||
const { startsOn, repeat, endsOn, collection, appends } = workflow.config;
|
|
||||||
const timestamp = now.getTime();
|
|
||||||
|
|
||||||
const startTimestamp = getOnTimestampWithOffset(startsOn, now);
|
|
||||||
if (!startTimestamp) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const conditions: any[] = [
|
|
||||||
{
|
|
||||||
[startsOn.field]: {
|
|
||||||
[Op.lt]: new Date(startTimestamp + this.interval),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (repeat) {
|
|
||||||
// startsOn not after now
|
|
||||||
conditions.push({
|
|
||||||
[startsOn.field]: {
|
|
||||||
[Op.lt]: new Date(startTimestamp),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const tsFn = DialectTimestampFnMap[this.workflow.app.db.options.dialect];
|
|
||||||
if (typeof repeat === 'number' && tsFn) {
|
|
||||||
const modExp = fn(
|
|
||||||
'MOD',
|
|
||||||
literal(`${Math.round(timestamp / 1000)} - ${tsFn(startsOn.field)}`),
|
|
||||||
Math.round(repeat / 1000),
|
|
||||||
);
|
|
||||||
conditions.push(where(modExp, { [Op.eq]: 0 }));
|
|
||||||
// conditions.push(literal(`MOD(CAST(${timestamp} AS BIGINT) - CAST((FLOOR(${tsFn(startsOn.field)}) AS BIGINT) * 1000), ${repeat}) = 0`));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (endsOn) {
|
|
||||||
const endTimestamp = getOnTimestampWithOffset(endsOn, now);
|
|
||||||
if (!endTimestamp) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof endsOn === 'string') {
|
|
||||||
if (endTimestamp <= timestamp) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
conditions.push({
|
|
||||||
[endsOn.field]: {
|
|
||||||
[Op.gte]: new Date(endTimestamp + this.interval),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// startsOn exactly equal to now in 1s
|
|
||||||
conditions.push({
|
|
||||||
[startsOn.field]: {
|
|
||||||
[Op.gte]: new Date(startTimestamp),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const repo = this.workflow.app.db.getRepository(collection);
|
|
||||||
const instances = await repo.find({
|
|
||||||
where: {
|
|
||||||
[Op.and]: conditions,
|
|
||||||
},
|
|
||||||
appends,
|
|
||||||
...(workflow.config.limit
|
|
||||||
? {
|
|
||||||
limit: Math.max(workflow.config.limit - workflow.allExecuted, 0),
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
});
|
|
||||||
|
|
||||||
instances.forEach((item) => {
|
|
||||||
this.workflow.trigger(workflow, {
|
|
||||||
date: now,
|
|
||||||
data: item.toJSON(),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return instances.length;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function matchNext(this: ScheduleTrigger, workflow, now: Date, range: number = this.cacheCycle): boolean {
|
|
||||||
const { repeat } = workflow.config;
|
|
||||||
// no repeat means no need to rerun
|
|
||||||
// but if in current cycle, should be put in cache
|
|
||||||
// no repeat but in current cycle means startsOn has been configured
|
|
||||||
// so we need to more info to determine if necessary config items
|
|
||||||
if (typeof repeat !== 'string') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentDate = new Date(now);
|
|
||||||
currentDate.setMilliseconds(-1);
|
|
||||||
const timestamp = now.getTime();
|
|
||||||
const interval = parser.parseExpression(repeat, { currentDate });
|
|
||||||
const next = interval.next();
|
|
||||||
|
|
||||||
// NOTE: cache all workflows will be matched in current cycle
|
|
||||||
if (next.getTime() - timestamp <= range) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default class ScheduleTrigger extends Trigger {
|
|
||||||
sync = false;
|
|
||||||
|
|
||||||
static CacheRules = [
|
|
||||||
({ config, allExecuted }) => (config.limit ? allExecuted < config.limit : true) && config.startsOn,
|
|
||||||
matchNext,
|
|
||||||
function (workflow, now) {
|
|
||||||
const { mode } = workflow.config;
|
|
||||||
const modeHandlers = ScheduleModes.get(mode);
|
|
||||||
if (!modeHandlers) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return modeHandlers.shouldCache.call(this, workflow, now);
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
static TriggerRules = [
|
|
||||||
({ config, allExecuted }) => (config.limit ? allExecuted < config.limit : true) && config.startsOn,
|
|
||||||
function (workflow, now) {
|
|
||||||
return matchNext.call(this, workflow, now, 0);
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
events = new Map();
|
|
||||||
|
|
||||||
private timer: NodeJS.Timeout | null = null;
|
|
||||||
|
|
||||||
private cache: Map<number | string, any> = new Map();
|
|
||||||
|
|
||||||
// running interval, default to 1s
|
|
||||||
interval = 1_000;
|
|
||||||
// caching workflows in range, default to 1min
|
|
||||||
cacheCycle = 60_000;
|
|
||||||
|
|
||||||
constructor(workflow: Plugin) {
|
|
||||||
super(workflow);
|
|
||||||
|
|
||||||
workflow.app.on('beforeStop', () => {
|
|
||||||
if (this.timer) {
|
|
||||||
clearInterval(this.timer);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
init() {
|
|
||||||
if (this.workflow.app.getPlugin('multi-app-share-collection')?.enabled && this.workflow.app.name !== 'main') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.timer) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
// NOTE: assign to this.timer to avoid duplicated initialization
|
|
||||||
this.timer = setTimeout(
|
|
||||||
this.run,
|
|
||||||
// NOTE:
|
|
||||||
// try to align to system time on each second starts,
|
|
||||||
// after at least 1 second initialized for everything to get ready.
|
|
||||||
// so jobs in 2 seconds will be missed at first start.
|
|
||||||
1_000 - now.getMilliseconds(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
run = () => {
|
|
||||||
const now = new Date();
|
|
||||||
// 1001 to avoid 999
|
|
||||||
const nextInterval = 1_001 - now.getMilliseconds();
|
|
||||||
now.setMilliseconds(0);
|
|
||||||
|
|
||||||
// NOTE: trigger `onTick` for high interval jobs which are cached in last 1 min
|
|
||||||
this.onTick(now);
|
|
||||||
|
|
||||||
// NOTE: reload when second match cache cycle
|
|
||||||
if (!(now.getTime() % this.cacheCycle)) {
|
|
||||||
this.reload();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.timer = setTimeout(this.run, nextInterval);
|
|
||||||
};
|
|
||||||
|
|
||||||
async onTick(now) {
|
|
||||||
// NOTE: trigger workflows in sequence when sqlite due to only one transaction
|
|
||||||
const isSqlite = this.workflow.app.db.options.dialect === 'sqlite';
|
|
||||||
return Array.from(this.cache.values()).reduce(
|
|
||||||
(prev, workflow) => {
|
|
||||||
if (!this.shouldTrigger(workflow, now)) {
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
if (isSqlite) {
|
|
||||||
return prev.then(() => this.trigger(workflow, now));
|
|
||||||
}
|
|
||||||
this.trigger(workflow, now);
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
isSqlite ? Promise.resolve() : null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async reload() {
|
|
||||||
const WorkflowRepo = this.workflow.app.db.getRepository('workflows');
|
|
||||||
const workflows = await WorkflowRepo.find({
|
|
||||||
filter: { enabled: true, type: 'schedule' },
|
|
||||||
});
|
|
||||||
|
|
||||||
// NOTE: clear cached jobs in last cycle
|
|
||||||
this.cache = new Map();
|
|
||||||
|
|
||||||
this.inspect(workflows);
|
|
||||||
}
|
|
||||||
|
|
||||||
inspect(workflows) {
|
|
||||||
const now = new Date();
|
|
||||||
now.setMilliseconds(0);
|
|
||||||
|
|
||||||
workflows.forEach(async (workflow) => {
|
|
||||||
const should = await this.shouldCache(workflow, now);
|
|
||||||
|
|
||||||
if (should) {
|
|
||||||
this.workflow.getLogger(workflow.id).info('caching scheduled workflow will run in next minute');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setCache(workflow, !should);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setCache(workflow, out = false) {
|
|
||||||
out ? this.cache.delete(workflow.id) : this.cache.set(workflow.id, workflow);
|
|
||||||
}
|
|
||||||
|
|
||||||
async shouldCache(workflow, now) {
|
|
||||||
for await (const rule of (<typeof ScheduleTrigger>this.constructor).CacheRules) {
|
|
||||||
if (!(await rule.call(this, workflow, now))) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
shouldTrigger(workflow, now): boolean {
|
|
||||||
for (const rule of (<typeof ScheduleTrigger>this.constructor).TriggerRules) {
|
|
||||||
if (!rule.call(this, workflow, now)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async trigger(workflow, date: Date) {
|
|
||||||
const { mode } = workflow.config;
|
|
||||||
const modeHandlers = ScheduleModes.get(mode);
|
|
||||||
if (!modeHandlers) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return modeHandlers.trigger.call(this, workflow, date);
|
|
||||||
}
|
|
||||||
|
|
||||||
on(workflow) {
|
|
||||||
// NOTE: lazy initialization
|
|
||||||
this.init();
|
|
||||||
|
|
||||||
const { mode } = workflow.config;
|
|
||||||
const modeHandlers = ScheduleModes.get(mode);
|
|
||||||
if (modeHandlers && modeHandlers.on) {
|
|
||||||
modeHandlers.on.call(this, workflow);
|
|
||||||
}
|
|
||||||
this.inspect([workflow]);
|
|
||||||
}
|
|
||||||
off(workflow) {
|
|
||||||
const { mode } = workflow.config;
|
|
||||||
const modeHandlers = ScheduleModes.get(mode);
|
|
||||||
if (modeHandlers && modeHandlers.off) {
|
|
||||||
modeHandlers.off.call(this, workflow);
|
|
||||||
}
|
|
||||||
this.cache.delete(workflow.id);
|
|
||||||
}
|
|
||||||
}
|
|
@ -0,0 +1,369 @@
|
|||||||
|
import { fn, literal, Op, Transactionable, where } from '@nocobase/database';
|
||||||
|
import parser from 'cron-parser';
|
||||||
|
import type Plugin from '../../Plugin';
|
||||||
|
import type { WorkflowModel } from '../../types';
|
||||||
|
import { parseDateWithoutMs, SCHEDULE_MODE } from './utils';
|
||||||
|
|
||||||
|
export type ScheduleOnField = {
|
||||||
|
field: string;
|
||||||
|
// in seconds
|
||||||
|
offset?: number;
|
||||||
|
unit?: 1000 | 60000 | 3600000 | 86400000;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ScheduleTriggerConfig {
|
||||||
|
// trigger mode
|
||||||
|
mode: number;
|
||||||
|
// how to repeat
|
||||||
|
repeat?: string | number | null;
|
||||||
|
// limit of repeat times
|
||||||
|
limit?: number;
|
||||||
|
|
||||||
|
startsOn?: ScheduleOnField;
|
||||||
|
endsOn?: string | ScheduleOnField;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOnTimestampWithOffset({ field, offset = 0, unit = 1000 }: ScheduleOnField, now: Date) {
|
||||||
|
if (!field) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const timestamp = now.getTime();
|
||||||
|
// onDate + offset > now
|
||||||
|
// onDate > now - offset
|
||||||
|
return timestamp - offset * unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDataOptionTime(record, on, dir = 1) {
|
||||||
|
if (!on) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
switch (typeof on) {
|
||||||
|
case 'string': {
|
||||||
|
const time = parseDateWithoutMs(on);
|
||||||
|
return time ? time : null;
|
||||||
|
}
|
||||||
|
case 'object': {
|
||||||
|
const { field, offset = 0, unit = 1000 } = on;
|
||||||
|
if (!record.get(field)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const second = new Date(record.get(field).getTime());
|
||||||
|
second.setMilliseconds(0);
|
||||||
|
return second.getTime() + offset * unit * dir;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DialectTimestampFnMap: { [key: string]: (col: string) => string } = {
|
||||||
|
postgres(col) {
|
||||||
|
return `CAST(FLOOR(extract(epoch from "${col}")) AS INTEGER)`;
|
||||||
|
},
|
||||||
|
mysql(col) {
|
||||||
|
return `CAST(FLOOR(UNIX_TIMESTAMP(\`${col}\`)) AS SIGNED INTEGER)`;
|
||||||
|
},
|
||||||
|
sqlite(col) {
|
||||||
|
return `CAST(FLOOR(unixepoch(${col})) AS INTEGER)`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
DialectTimestampFnMap.mariadb = DialectTimestampFnMap.mysql;
|
||||||
|
|
||||||
|
function getCronNextTime(cron, currentDate: Date): number {
|
||||||
|
const interval = parser.parseExpression(cron, { currentDate });
|
||||||
|
const next = interval.next();
|
||||||
|
return next.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchCronNextTime(cron, currentDate: Date, range: number): boolean {
|
||||||
|
return getCronNextTime(cron, currentDate) - currentDate.getTime() <= range;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHookId(workflow, type: string) {
|
||||||
|
return `${type}#${workflow.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class ScheduleTrigger {
|
||||||
|
events = new Map();
|
||||||
|
|
||||||
|
private timer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
private cache: Map<string, any> = new Map();
|
||||||
|
|
||||||
|
// caching workflows in range, default to 5min
|
||||||
|
cacheCycle = 300_000;
|
||||||
|
|
||||||
|
constructor(public workflow: Plugin) {
|
||||||
|
workflow.app.on('afterStart', async () => {
|
||||||
|
if (this.timer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.timer = setInterval(() => this.reload(), this.cacheCycle);
|
||||||
|
|
||||||
|
this.reload();
|
||||||
|
});
|
||||||
|
|
||||||
|
workflow.app.on('beforeStop', () => {
|
||||||
|
if (this.timer) {
|
||||||
|
clearInterval(this.timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, timer] of this.cache.entries()) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
this.cache.delete(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async reload() {
|
||||||
|
const WorkflowRepo = this.workflow.app.db.getRepository('workflows');
|
||||||
|
const workflows = await WorkflowRepo.find({
|
||||||
|
filter: { enabled: true, type: 'schedule', 'config.mode': SCHEDULE_MODE.DATE_FIELD },
|
||||||
|
});
|
||||||
|
|
||||||
|
// NOTE: clear cached jobs in last cycle
|
||||||
|
this.cache = new Map();
|
||||||
|
|
||||||
|
this.inspect(workflows);
|
||||||
|
}
|
||||||
|
|
||||||
|
inspect(workflows: WorkflowModel[]) {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
workflows.forEach(async (workflow) => {
|
||||||
|
const records = await this.loadRecordsToSchedule(workflow, now);
|
||||||
|
records.forEach((record) => {
|
||||||
|
const nextTime = this.getRecordNextTime(workflow, record);
|
||||||
|
this.schedule(workflow, record, nextTime, Boolean(nextTime));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. startsOn in range -> yes
|
||||||
|
// 2. startsOn before now, has no repeat -> no
|
||||||
|
// 3. startsOn before now, and has repeat:
|
||||||
|
// a. repeat out of range -> no
|
||||||
|
// b. repeat in range (number or cron):
|
||||||
|
// i. endsOn after now -> yes
|
||||||
|
// ii. endsOn before now -> no
|
||||||
|
async loadRecordsToSchedule(
|
||||||
|
{ config: { collection, limit, startsOn, repeat, endsOn }, allExecuted }: WorkflowModel,
|
||||||
|
currentDate: Date,
|
||||||
|
) {
|
||||||
|
const { db } = this.workflow.app;
|
||||||
|
if (limit && allExecuted >= limit) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (!startsOn) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const timestamp = currentDate.getTime();
|
||||||
|
|
||||||
|
const startTimestamp = getOnTimestampWithOffset(startsOn, currentDate);
|
||||||
|
if (!startTimestamp) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = this.cacheCycle * 2;
|
||||||
|
|
||||||
|
const conditions: any[] = [
|
||||||
|
{
|
||||||
|
[startsOn.field]: {
|
||||||
|
// cache next 2 cycles
|
||||||
|
[Op.lt]: new Date(startTimestamp + range),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (repeat) {
|
||||||
|
// when repeat is number, means repeat after startsOn
|
||||||
|
// (now - startsOn) % repeat <= cacheCycle
|
||||||
|
if (typeof repeat === 'number') {
|
||||||
|
const tsFn = DialectTimestampFnMap[db.options.dialect];
|
||||||
|
if (repeat > range && tsFn) {
|
||||||
|
const modExp = fn(
|
||||||
|
'MOD',
|
||||||
|
literal(`${Math.round(timestamp / 1000)} - ${tsFn(startsOn.field)}`),
|
||||||
|
Math.round(repeat / 1000),
|
||||||
|
);
|
||||||
|
conditions.push(where(modExp, { [Op.lt]: Math.round(range / 1000) }));
|
||||||
|
}
|
||||||
|
} else if (typeof repeat === 'string') {
|
||||||
|
if (!matchCronNextTime(repeat, currentDate, range)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endsOn) {
|
||||||
|
const now = new Date();
|
||||||
|
const endTimestamp = getOnTimestampWithOffset(endsOn, now);
|
||||||
|
if (!endTimestamp) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (typeof endsOn === 'string') {
|
||||||
|
if (endTimestamp <= timestamp) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
conditions.push({
|
||||||
|
[endsOn.field]: {
|
||||||
|
[Op.gte]: new Date(endTimestamp),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
conditions.push({
|
||||||
|
[startsOn.field]: {
|
||||||
|
[Op.gte]: new Date(startTimestamp),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { model } = db.getCollection(collection);
|
||||||
|
return model.findAll({
|
||||||
|
where: {
|
||||||
|
[Op.and]: conditions,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getRecordNextTime(workflow: WorkflowModel, record, nextSecond = false) {
|
||||||
|
const {
|
||||||
|
config: { startsOn, endsOn, repeat, limit },
|
||||||
|
allExecuted,
|
||||||
|
} = workflow;
|
||||||
|
if (limit && allExecuted >= limit) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const range = this.cacheCycle;
|
||||||
|
const now = new Date();
|
||||||
|
now.setMilliseconds(nextSecond ? 1000 : 0);
|
||||||
|
const timestamp = now.getTime();
|
||||||
|
const startTime = getDataOptionTime(record, startsOn);
|
||||||
|
const endTime = getDataOptionTime(record, endsOn);
|
||||||
|
let nextTime = null;
|
||||||
|
if (!startTime) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (startTime > timestamp + range) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (startTime >= timestamp) {
|
||||||
|
return !endTime || (endTime >= startTime && endTime < timestamp + range) ? startTime : null;
|
||||||
|
} else {
|
||||||
|
if (!repeat) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof repeat === 'number') {
|
||||||
|
const nextRepeatTime = ((startTime - timestamp) % repeat) + repeat;
|
||||||
|
if (nextRepeatTime > range) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (endTime && endTime < timestamp + nextRepeatTime) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
nextTime = timestamp + nextRepeatTime;
|
||||||
|
} else if (typeof repeat === 'string') {
|
||||||
|
nextTime = getCronNextTime(repeat, now);
|
||||||
|
if (nextTime - timestamp > range) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (endTime && endTime < nextTime) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (endTime && endTime <= timestamp) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return nextTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
schedule(workflow: WorkflowModel, record, nextTime, toggle = true, options = {}) {
|
||||||
|
const { model } = this.workflow.app.db.getCollection(workflow.config.collection);
|
||||||
|
const recordPk = record.get(model.primaryKeyAttribute);
|
||||||
|
if (toggle) {
|
||||||
|
const nextInterval = Math.max(0, nextTime - Date.now());
|
||||||
|
const key = `${workflow.id}:${recordPk}@${nextTime}`;
|
||||||
|
|
||||||
|
if (!this.cache.has(key)) {
|
||||||
|
if (nextInterval) {
|
||||||
|
this.cache.set(key, setTimeout(this.trigger.bind(this, workflow, record, nextTime), nextInterval));
|
||||||
|
} else {
|
||||||
|
return this.trigger(workflow, record, nextTime, options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const [key, timer] of this.cache.entries()) {
|
||||||
|
if (key.startsWith(`${workflow.id}:${recordPk}@`)) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
this.cache.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async trigger(workflow: WorkflowModel, record, nextTime, { transaction }: Transactionable = {}) {
|
||||||
|
const { repository, model } = this.workflow.app.db.getCollection(workflow.config.collection);
|
||||||
|
const recordPk = record.get(model.primaryKeyAttribute);
|
||||||
|
const data = await repository.findOne({
|
||||||
|
filterByTk: recordPk,
|
||||||
|
appends: workflow.config.appends,
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
const key = `${workflow.id}:${recordPk}@${nextTime}`;
|
||||||
|
this.cache.delete(key);
|
||||||
|
this.workflow.trigger(workflow, {
|
||||||
|
data: data.toJSON(),
|
||||||
|
date: new Date(nextTime),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!workflow.config.repeat || (workflow.config.limit && workflow.allExecuted >= workflow.config.limit - 1)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const n = this.getRecordNextTime(workflow, data, true);
|
||||||
|
if (n) {
|
||||||
|
this.schedule(workflow, data, n, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
on(workflow: WorkflowModel) {
|
||||||
|
this.inspect([workflow]);
|
||||||
|
|
||||||
|
const { collection } = workflow.config;
|
||||||
|
const event = `${collection}.afterSaveWithAssociations`;
|
||||||
|
const name = getHookId(workflow, event);
|
||||||
|
if (this.events.has(name)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const listener = async (data, { transaction }) => {
|
||||||
|
const nextTime = this.getRecordNextTime(workflow, data);
|
||||||
|
return this.schedule(workflow, data, nextTime, Boolean(nextTime), { transaction });
|
||||||
|
};
|
||||||
|
|
||||||
|
this.events.set(name, listener);
|
||||||
|
this.workflow.app.db.on(event, listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
off(workflow: WorkflowModel) {
|
||||||
|
for (const [key, timer] of this.cache.entries()) {
|
||||||
|
if (key.startsWith(`${workflow.id}:`)) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
this.cache.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { collection } = workflow.config;
|
||||||
|
const event = `${collection}.afterSave`;
|
||||||
|
const name = getHookId(workflow, event);
|
||||||
|
if (this.events.has(name)) {
|
||||||
|
const listener = this.events.get(name);
|
||||||
|
this.events.delete(name);
|
||||||
|
this.workflow.app.db.off(event, listener);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,114 @@
|
|||||||
|
import parser from 'cron-parser';
|
||||||
|
|
||||||
|
import type Plugin from '../../Plugin';
|
||||||
|
import { SCHEDULE_MODE, parseDateWithoutMs } from './utils';
|
||||||
|
|
||||||
|
export default class StaticScheduleTrigger {
|
||||||
|
private timers: Map<string, NodeJS.Timeout | null> = new Map();
|
||||||
|
|
||||||
|
constructor(public workflow: Plugin) {
|
||||||
|
workflow.app.on('afterStart', async () => {
|
||||||
|
const WorkflowRepo = this.workflow.app.db.getRepository('workflows');
|
||||||
|
const workflows = await WorkflowRepo.find({
|
||||||
|
filter: { enabled: true, type: 'schedule', 'config.mode': SCHEDULE_MODE.STATIC },
|
||||||
|
});
|
||||||
|
|
||||||
|
this.inspect(workflows);
|
||||||
|
});
|
||||||
|
|
||||||
|
workflow.app.on('beforeStop', () => {
|
||||||
|
for (const timer of this.timers.values()) {
|
||||||
|
clearInterval(timer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
inspect(workflows) {
|
||||||
|
const now = new Date();
|
||||||
|
now.setMilliseconds(0);
|
||||||
|
|
||||||
|
workflows.forEach((workflow) => {
|
||||||
|
const nextTime = this.getNextTime(workflow, now);
|
||||||
|
if (nextTime) {
|
||||||
|
this.workflow
|
||||||
|
.getLogger(workflow.id)
|
||||||
|
.info(`caching scheduled workflow will run at: ${new Date(nextTime).toISOString()}`);
|
||||||
|
} else {
|
||||||
|
this.workflow.getLogger(workflow.id).info('workflow will not be scheduled');
|
||||||
|
}
|
||||||
|
this.schedule(workflow, nextTime, nextTime >= now.getTime());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getNextTime({ config, allExecuted }, currentDate, nextSecond = false) {
|
||||||
|
if (config.limit && allExecuted >= config.limit) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!config.startsOn) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
currentDate.setMilliseconds(nextSecond ? 1000 : 0);
|
||||||
|
const timestamp = currentDate.getTime();
|
||||||
|
const startTime = parseDateWithoutMs(config.startsOn);
|
||||||
|
if (startTime > timestamp) {
|
||||||
|
return startTime;
|
||||||
|
}
|
||||||
|
if (config.repeat) {
|
||||||
|
const endTime = config.endsOn ? parseDateWithoutMs(config.endsOn) : null;
|
||||||
|
if (endTime && endTime < timestamp) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof config.repeat === 'string') {
|
||||||
|
const interval = parser.parseExpression(config.repeat, { currentDate });
|
||||||
|
const next = interval.next();
|
||||||
|
return next.getTime();
|
||||||
|
} else if (typeof config.repeat === 'number') {
|
||||||
|
return timestamp + ((timestamp - startTime) % config.repeat);
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (startTime < timestamp) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return timestamp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
schedule(workflow, nextTime, toggle = true) {
|
||||||
|
const key = `${workflow.id}@${nextTime}`;
|
||||||
|
if (toggle) {
|
||||||
|
if (!this.timers.has(key)) {
|
||||||
|
const interval = Math.max(nextTime - Date.now(), 0);
|
||||||
|
this.timers.set(key, setTimeout(this.trigger.bind(this, workflow, nextTime), interval));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const timer = this.timers.get(key);
|
||||||
|
clearTimeout(timer);
|
||||||
|
this.timers.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async trigger(workflow, time) {
|
||||||
|
this.timers.delete(`${workflow.id}@${time}`);
|
||||||
|
|
||||||
|
this.workflow.trigger(workflow, { date: new Date(time) });
|
||||||
|
|
||||||
|
if (!workflow.config.repeat || (workflow.config.limit && workflow.allExecuted >= workflow.config.limit - 1)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextTime = this.getNextTime(workflow, new Date(), true);
|
||||||
|
if (nextTime) {
|
||||||
|
this.schedule(workflow, nextTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
on(workflow) {
|
||||||
|
this.inspect([workflow]);
|
||||||
|
}
|
||||||
|
|
||||||
|
off(workflow) {
|
||||||
|
this.schedule(workflow, null, false);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,53 @@
|
|||||||
|
import { Transactionable } from 'sequelize';
|
||||||
|
import Trigger from '..';
|
||||||
|
import type Plugin from '../../Plugin';
|
||||||
|
import { WorkflowModel } from '../../types';
|
||||||
|
import DateFieldScheduleTrigger from './DateFieldScheduleTrigger';
|
||||||
|
import StaticScheduleTrigger from './StaticScheduleTrigger';
|
||||||
|
import { SCHEDULE_MODE } from './utils';
|
||||||
|
|
||||||
|
export default class ScheduleTrigger extends Trigger {
|
||||||
|
sync = false;
|
||||||
|
|
||||||
|
private modes = new Map();
|
||||||
|
|
||||||
|
constructor(workflow: Plugin) {
|
||||||
|
super(workflow);
|
||||||
|
|
||||||
|
this.modes.set(SCHEDULE_MODE.STATIC, new StaticScheduleTrigger(workflow));
|
||||||
|
this.modes.set(SCHEDULE_MODE.DATE_FIELD, new DateFieldScheduleTrigger(workflow));
|
||||||
|
}
|
||||||
|
|
||||||
|
private getTrigger(mode: number) {
|
||||||
|
return this.modes.get(mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
on(workflow) {
|
||||||
|
const mode = workflow.config.mode;
|
||||||
|
const trigger = this.getTrigger(mode);
|
||||||
|
if (trigger) {
|
||||||
|
trigger.on(workflow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
off(workflow) {
|
||||||
|
const mode = workflow.config.mode;
|
||||||
|
const trigger = this.getTrigger(mode);
|
||||||
|
if (trigger) {
|
||||||
|
trigger.off(workflow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async validateEvent(workflow: WorkflowModel, context: any, options: Transactionable): Promise<boolean> {
|
||||||
|
if (!context.date) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const existed = await workflow.countExecutions({
|
||||||
|
where: {
|
||||||
|
'context.date': context.date,
|
||||||
|
},
|
||||||
|
transaction: options.transaction,
|
||||||
|
});
|
||||||
|
return !existed;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,8 @@
|
|||||||
|
export const SCHEDULE_MODE = {
|
||||||
|
STATIC: 0,
|
||||||
|
DATE_FIELD: 1,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function parseDateWithoutMs(date: string) {
|
||||||
|
return Math.floor(Date.parse(date) / 1000) * 1000;
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user