mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-24 20:24:05 -08:00
refactor(core): Centralize CronJob management (#10033)
This commit is contained in:
parent
36b314d031
commit
09f2cf9eaf
|
@ -28,7 +28,6 @@
|
|||
"devDependencies": {
|
||||
"@types/aws4": "^1.5.1",
|
||||
"@types/concat-stream": "^2.0.0",
|
||||
"@types/cron": "~1.7.1",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/lodash": "^4.14.195",
|
||||
"@types/mime-types": "^2.1.0",
|
||||
|
@ -40,7 +39,7 @@
|
|||
"aws4": "1.11.0",
|
||||
"axios": "1.6.7",
|
||||
"concat-stream": "2.0.0",
|
||||
"cron": "1.7.2",
|
||||
"cron": "3.1.7",
|
||||
"fast-glob": "3.2.12",
|
||||
"file-type": "16.5.4",
|
||||
"form-data": "4.0.0",
|
||||
|
|
|
@ -1,11 +1,9 @@
|
|||
import { Service } from 'typedi';
|
||||
import { CronJob } from 'cron';
|
||||
|
||||
import type {
|
||||
IGetExecutePollFunctions,
|
||||
IGetExecuteTriggerFunctions,
|
||||
INode,
|
||||
IPollResponse,
|
||||
ITriggerResponse,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
TriggerTime,
|
||||
|
@ -23,10 +21,13 @@ import {
|
|||
WorkflowDeactivationError,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { ScheduledTaskManager } from './ScheduledTaskManager';
|
||||
import type { IWorkflowData } from './Interfaces';
|
||||
|
||||
@Service()
|
||||
export class ActiveWorkflows {
|
||||
constructor(private readonly scheduledTaskManager: ScheduledTaskManager) {}
|
||||
|
||||
private activeWorkflows: { [workflowId: string]: IWorkflowData } = {};
|
||||
|
||||
/**
|
||||
|
@ -102,20 +103,15 @@ export class ActiveWorkflows {
|
|||
|
||||
if (pollingNodes.length === 0) return;
|
||||
|
||||
this.activeWorkflows[workflowId].pollResponses = [];
|
||||
|
||||
for (const pollNode of pollingNodes) {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
|
||||
this.activeWorkflows[workflowId].pollResponses!.push(
|
||||
await this.activatePolling(
|
||||
pollNode,
|
||||
workflow,
|
||||
additionalData,
|
||||
getPollFunctions,
|
||||
mode,
|
||||
activation,
|
||||
),
|
||||
await this.activatePolling(
|
||||
pollNode,
|
||||
workflow,
|
||||
additionalData,
|
||||
getPollFunctions,
|
||||
mode,
|
||||
activation,
|
||||
);
|
||||
} catch (e) {
|
||||
const error = e instanceof Error ? e : new Error(`${e}`);
|
||||
|
@ -138,7 +134,7 @@ export class ActiveWorkflows {
|
|||
getPollFunctions: IGetExecutePollFunctions,
|
||||
mode: WorkflowExecuteMode,
|
||||
activation: WorkflowActivateMode,
|
||||
): Promise<IPollResponse> {
|
||||
): Promise<void> {
|
||||
const pollFunctions = getPollFunctions(workflow, node, additionalData, mode, activation);
|
||||
|
||||
const pollTimes = pollFunctions.getNodeParameter('pollTimes') as unknown as {
|
||||
|
@ -161,7 +157,7 @@ export class ActiveWorkflows {
|
|||
pollFunctions.__emit(pollResponse);
|
||||
}
|
||||
} catch (error) {
|
||||
// If the poll function failes in the first activation
|
||||
// If the poll function fails in the first activation
|
||||
// throw the error back so we let the user know there is
|
||||
// an issue with the trigger.
|
||||
if (testingTrigger) {
|
||||
|
@ -174,11 +170,6 @@ export class ActiveWorkflows {
|
|||
// Execute the trigger directly to be able to know if it works
|
||||
await executeTrigger(true);
|
||||
|
||||
const timezone = pollFunctions.getTimezone();
|
||||
|
||||
// Start the cron-jobs
|
||||
const cronJobs: CronJob[] = [];
|
||||
|
||||
for (const cronTime of cronTimes) {
|
||||
const cronTimeParts = cronTime.split(' ');
|
||||
if (cronTimeParts.length > 0 && cronTimeParts[0].includes('*')) {
|
||||
|
@ -187,19 +178,8 @@ export class ActiveWorkflows {
|
|||
);
|
||||
}
|
||||
|
||||
cronJobs.push(new CronJob(cronTime, executeTrigger, undefined, true, timezone));
|
||||
this.scheduledTaskManager.registerCron(workflow, cronTime, executeTrigger);
|
||||
}
|
||||
|
||||
// Stop the cron-jobs
|
||||
async function closeFunction() {
|
||||
for (const cronJob of cronJobs) {
|
||||
cronJob.stop();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
closeFunction,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -211,14 +191,11 @@ export class ActiveWorkflows {
|
|||
return false;
|
||||
}
|
||||
|
||||
this.scheduledTaskManager.deregisterCrons(workflowId);
|
||||
|
||||
const w = this.activeWorkflows[workflowId];
|
||||
|
||||
for (const r of w.triggerResponses ?? []) {
|
||||
await this.close(r, workflowId, 'trigger');
|
||||
}
|
||||
|
||||
for (const r of w.pollResponses ?? []) {
|
||||
await this.close(r, workflowId, 'poller');
|
||||
await this.closeTrigger(r, workflowId);
|
||||
}
|
||||
|
||||
delete this.activeWorkflows[workflowId];
|
||||
|
@ -232,11 +209,7 @@ export class ActiveWorkflows {
|
|||
}
|
||||
}
|
||||
|
||||
private async close(
|
||||
response: ITriggerResponse | IPollResponse,
|
||||
workflowId: string,
|
||||
target: 'trigger' | 'poller',
|
||||
) {
|
||||
private async closeTrigger(response: ITriggerResponse, workflowId: string) {
|
||||
if (!response.closeFunction) return;
|
||||
|
||||
try {
|
||||
|
@ -246,14 +219,14 @@ export class ActiveWorkflows {
|
|||
Logger.error(
|
||||
`There was a problem calling "closeFunction" on "${e.node.name}" in workflow "${workflowId}"`,
|
||||
);
|
||||
ErrorReporter.error(e, { extra: { target, workflowId } });
|
||||
ErrorReporter.error(e, { extra: { workflowId } });
|
||||
return;
|
||||
}
|
||||
|
||||
const error = e instanceof Error ? e : new Error(`${e}`);
|
||||
|
||||
throw new WorkflowDeactivationError(
|
||||
`Failed to deactivate ${target} of workflow ID "${workflowId}": "${error.message}"`,
|
||||
`Failed to deactivate trigger of workflow ID "${workflowId}": "${error.message}"`,
|
||||
{ cause: error, workflowId },
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import type {
|
||||
IPollResponse,
|
||||
ITriggerResponse,
|
||||
IWorkflowSettings as IWorkflowSettingsWorkflow,
|
||||
ValidationResult,
|
||||
|
@ -18,7 +17,6 @@ export interface IWorkflowSettings extends IWorkflowSettingsWorkflow {
|
|||
}
|
||||
|
||||
export interface IWorkflowData {
|
||||
pollResponses?: IPollResponse[];
|
||||
triggerResponses?: ITriggerResponse[];
|
||||
}
|
||||
|
||||
|
|
|
@ -102,6 +102,7 @@ import type {
|
|||
INodeParameters,
|
||||
EnsureTypeOptions,
|
||||
SSHTunnelFunctions,
|
||||
SchedulingFunctions,
|
||||
} from 'n8n-workflow';
|
||||
import {
|
||||
ExpressionError,
|
||||
|
@ -114,7 +115,6 @@ import {
|
|||
createDeferredPromise,
|
||||
deepCopy,
|
||||
fileTypeFromMimeType,
|
||||
getGlobalState,
|
||||
isObjectEmpty,
|
||||
isResourceMapperValue,
|
||||
validateFieldType,
|
||||
|
@ -157,6 +157,7 @@ import Container from 'typedi';
|
|||
import type { BinaryData } from './BinaryData/types';
|
||||
import merge from 'lodash/merge';
|
||||
import { InstanceSettings } from './InstanceSettings';
|
||||
import { ScheduledTaskManager } from './ScheduledTaskManager';
|
||||
import { SSHClientsManager } from './SSHClientsManager';
|
||||
import { binaryToBuffer } from './BinaryData/utils';
|
||||
|
||||
|
@ -2585,13 +2586,6 @@ export function getNodeWebhookUrl(
|
|||
return NodeHelpers.getNodeWebhookUrl(baseUrl, workflow.id, node, path.toString(), isFullPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the timezone for the workflow
|
||||
*/
|
||||
export function getTimezone(workflow: Workflow): string {
|
||||
return workflow.settings.timezone ?? getGlobalState().defaultTimezone;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the full webhook description of the webhook with the given name
|
||||
*
|
||||
|
@ -2957,7 +2951,7 @@ const getCommonWorkflowFunctions = (
|
|||
getRestApiUrl: () => additionalData.restApiUrl,
|
||||
getInstanceBaseUrl: () => additionalData.instanceBaseUrl,
|
||||
getInstanceId: () => Container.get(InstanceSettings).instanceId,
|
||||
getTimezone: () => getTimezone(workflow),
|
||||
getTimezone: () => workflow.timezone,
|
||||
getCredentialsProperties: (type: string) =>
|
||||
additionalData.credentialsHelper.getCredentialsProperties(type),
|
||||
prepareOutputData: async (outputData) => [outputData],
|
||||
|
@ -3286,6 +3280,14 @@ const getSSHTunnelFunctions = (): SSHTunnelFunctions => ({
|
|||
await Container.get(SSHClientsManager).getClient(credentials),
|
||||
});
|
||||
|
||||
const getSchedulingFunctions = (workflow: Workflow): SchedulingFunctions => {
|
||||
const scheduledTaskManager = Container.get(ScheduledTaskManager);
|
||||
return {
|
||||
registerCron: (cronExpression, onTick) =>
|
||||
scheduledTaskManager.registerCron(workflow, cronExpression, onTick),
|
||||
};
|
||||
};
|
||||
|
||||
const getAllowedPaths = () => {
|
||||
const restrictFileAccessTo = process.env[RESTRICT_FILE_ACCESS_TO];
|
||||
if (!restrictFileAccessTo) {
|
||||
|
@ -3489,6 +3491,7 @@ export function getExecutePollFunctions(
|
|||
createDeferredPromise,
|
||||
...getRequestHelperFunctions(workflow, node, additionalData),
|
||||
...getBinaryHelperFunctions(additionalData, workflow.id),
|
||||
...getSchedulingFunctions(workflow),
|
||||
returnJsonArray,
|
||||
},
|
||||
};
|
||||
|
@ -3553,6 +3556,7 @@ export function getExecuteTriggerFunctions(
|
|||
...getSSHTunnelFunctions(),
|
||||
...getRequestHelperFunctions(workflow, node, additionalData),
|
||||
...getBinaryHelperFunctions(additionalData, workflow.id),
|
||||
...getSchedulingFunctions(workflow),
|
||||
returnJsonArray,
|
||||
},
|
||||
};
|
||||
|
|
31
packages/core/src/ScheduledTaskManager.ts
Normal file
31
packages/core/src/ScheduledTaskManager.ts
Normal file
|
@ -0,0 +1,31 @@
|
|||
import { Service } from 'typedi';
|
||||
import { CronJob } from 'cron';
|
||||
import type { CronExpression, Workflow } from 'n8n-workflow';
|
||||
|
||||
@Service()
|
||||
export class ScheduledTaskManager {
|
||||
readonly cronJobs = new Map<string, CronJob[]>();
|
||||
|
||||
registerCron(workflow: Workflow, cronExpression: CronExpression, onTick: () => void) {
|
||||
const cronJob = new CronJob(cronExpression, onTick, undefined, true, workflow.timezone);
|
||||
const cronJobsForWorkflow = this.cronJobs.get(workflow.id);
|
||||
if (cronJobsForWorkflow) {
|
||||
cronJobsForWorkflow.push(cronJob);
|
||||
} else {
|
||||
this.cronJobs.set(workflow.id, [cronJob]);
|
||||
}
|
||||
}
|
||||
|
||||
deregisterCrons(workflowId: string) {
|
||||
const cronJobs = this.cronJobs.get(workflowId) ?? [];
|
||||
for (const cronJob of cronJobs) {
|
||||
cronJob.stop();
|
||||
}
|
||||
}
|
||||
|
||||
deregisterAllCrons() {
|
||||
for (const workflowId of Object.keys(this.cronJobs)) {
|
||||
this.deregisterCrons(workflowId);
|
||||
}
|
||||
}
|
||||
}
|
54
packages/core/test/ScheduledTaskManager.test.ts
Normal file
54
packages/core/test/ScheduledTaskManager.test.ts
Normal file
|
@ -0,0 +1,54 @@
|
|||
import type { Workflow } from 'n8n-workflow';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
|
||||
import { ScheduledTaskManager } from '@/ScheduledTaskManager';
|
||||
|
||||
describe('ScheduledTaskManager', () => {
|
||||
const workflow = mock<Workflow>({ timezone: 'GMT' });
|
||||
const everyMinute = '0 * * * * *';
|
||||
const onTick = jest.fn();
|
||||
|
||||
let scheduledTaskManager: ScheduledTaskManager;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.useFakeTimers();
|
||||
scheduledTaskManager = new ScheduledTaskManager();
|
||||
});
|
||||
|
||||
it('should throw when workflow timezone is invalid', () => {
|
||||
expect(() =>
|
||||
scheduledTaskManager.registerCron(
|
||||
mock<Workflow>({ timezone: 'somewhere' }),
|
||||
everyMinute,
|
||||
onTick,
|
||||
),
|
||||
).toThrow('Invalid timezone.');
|
||||
});
|
||||
|
||||
it('should throw when cron expression is invalid', () => {
|
||||
expect(() =>
|
||||
//@ts-expect-error invalid cron expression is a type-error
|
||||
scheduledTaskManager.registerCron(workflow, 'invalid-cron-expression', onTick),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('should register valid CronJobs', async () => {
|
||||
scheduledTaskManager.registerCron(workflow, everyMinute, onTick);
|
||||
|
||||
expect(onTick).not.toHaveBeenCalled();
|
||||
jest.advanceTimersByTime(10 * 60 * 1000); // 10 minutes
|
||||
expect(onTick).toHaveBeenCalledTimes(10);
|
||||
});
|
||||
|
||||
it('should deregister CronJobs for a workflow', async () => {
|
||||
scheduledTaskManager.registerCron(workflow, everyMinute, onTick);
|
||||
scheduledTaskManager.registerCron(workflow, everyMinute, onTick);
|
||||
scheduledTaskManager.registerCron(workflow, everyMinute, onTick);
|
||||
scheduledTaskManager.deregisterCrons(workflow.id);
|
||||
|
||||
expect(onTick).not.toHaveBeenCalled();
|
||||
jest.advanceTimersByTime(10 * 60 * 1000); // 10 minutes
|
||||
expect(onTick).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
|
@ -7,8 +7,6 @@ import type {
|
|||
} from 'n8n-workflow';
|
||||
import { NodeHelpers, toCronExpression } from 'n8n-workflow';
|
||||
|
||||
import { CronJob } from 'cron';
|
||||
|
||||
export class Cron implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Cron',
|
||||
|
@ -66,27 +64,11 @@ export class Cron implements INodeType {
|
|||
this.emit([this.helpers.returnJsonArray([{}])]);
|
||||
};
|
||||
|
||||
const timezone = this.getTimezone();
|
||||
|
||||
// Start the cron-jobs
|
||||
const cronJobs = cronTimes.map(
|
||||
(cronTime) => new CronJob(cronTime, executeTrigger, undefined, true, timezone),
|
||||
);
|
||||
|
||||
// Stop the cron-jobs
|
||||
async function closeFunction() {
|
||||
for (const cronJob of cronJobs) {
|
||||
cronJob.stop();
|
||||
}
|
||||
}
|
||||
|
||||
async function manualTriggerFunction() {
|
||||
executeTrigger();
|
||||
}
|
||||
// Register the cron-jobs
|
||||
cronTimes.forEach((cronTime) => this.helpers.registerCron(cronTime, executeTrigger));
|
||||
|
||||
return {
|
||||
closeFunction,
|
||||
manualTriggerFunction,
|
||||
manualTriggerFunction: async () => executeTrigger(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,93 +1,128 @@
|
|||
import type { IDataObject } from 'n8n-workflow';
|
||||
import moment from 'moment-timezone';
|
||||
import type { IRecurencyRule } from './SchedulerInterface';
|
||||
import { type CronExpression, randomInt } from 'n8n-workflow';
|
||||
import type { IRecurrenceRule, ScheduleInterval } from './SchedulerInterface';
|
||||
|
||||
export function recurencyCheck(
|
||||
recurrency: IRecurencyRule,
|
||||
recurrencyRules: number[],
|
||||
export function recurrenceCheck(
|
||||
recurrence: IRecurrenceRule,
|
||||
recurrenceRules: number[],
|
||||
timezone: string,
|
||||
): boolean {
|
||||
const recurrencyRuleIndex = recurrency.index;
|
||||
const intervalSize = recurrency.intervalSize;
|
||||
const typeInterval = recurrency.typeInterval;
|
||||
if (!recurrence.activated) return true;
|
||||
|
||||
const lastExecution =
|
||||
recurrencyRuleIndex !== undefined ? recurrencyRules[recurrencyRuleIndex] : undefined;
|
||||
const intervalSize = recurrence.intervalSize;
|
||||
if (!intervalSize) return false;
|
||||
|
||||
if (
|
||||
intervalSize &&
|
||||
recurrencyRuleIndex !== undefined &&
|
||||
(typeInterval === 'weeks' || typeInterval === 'undefined')
|
||||
) {
|
||||
const index = recurrence.index;
|
||||
const typeInterval = recurrence.typeInterval;
|
||||
const lastExecution = recurrenceRules[index];
|
||||
|
||||
const momentTz = moment.tz(timezone);
|
||||
if (typeInterval === 'hours') {
|
||||
const hour = momentTz.hour();
|
||||
if (lastExecution === undefined || hour === (intervalSize + lastExecution) % 24) {
|
||||
recurrenceRules[index] = hour;
|
||||
return true;
|
||||
}
|
||||
} else if (typeInterval === 'days') {
|
||||
const dayOfYear = momentTz.dayOfYear();
|
||||
if (lastExecution === undefined || dayOfYear === (intervalSize + lastExecution) % 365) {
|
||||
recurrenceRules[index] = dayOfYear;
|
||||
return true;
|
||||
}
|
||||
} else if (typeInterval === 'weeks') {
|
||||
const week = momentTz.week();
|
||||
if (
|
||||
lastExecution === undefined || // First time executing this rule
|
||||
moment.tz(timezone).week() === (intervalSize + lastExecution) % 52 || // not first time, but minimum interval has passed
|
||||
moment.tz(timezone).week() === lastExecution // Trigger on multiple days in the same week
|
||||
week === (intervalSize + lastExecution) % 52 || // not first time, but minimum interval has passed
|
||||
week === lastExecution // Trigger on multiple days in the same week
|
||||
) {
|
||||
recurrencyRules[recurrencyRuleIndex] = moment.tz(timezone).week();
|
||||
recurrenceRules[index] = week;
|
||||
return true;
|
||||
}
|
||||
} else if (intervalSize && recurrencyRuleIndex !== undefined && typeInterval === 'days') {
|
||||
if (
|
||||
lastExecution === undefined ||
|
||||
moment.tz(timezone).dayOfYear() === (intervalSize + lastExecution) % 365
|
||||
) {
|
||||
recurrencyRules[recurrencyRuleIndex] = moment.tz(timezone).dayOfYear();
|
||||
} else if (typeInterval === 'months') {
|
||||
const month = momentTz.month();
|
||||
if (lastExecution === undefined || month === (intervalSize + lastExecution) % 12) {
|
||||
recurrenceRules[index] = month;
|
||||
return true;
|
||||
}
|
||||
} else if (intervalSize && recurrencyRuleIndex !== undefined && typeInterval === 'hours') {
|
||||
if (
|
||||
lastExecution === undefined ||
|
||||
moment.tz(timezone).hour() === (intervalSize + lastExecution) % 24
|
||||
) {
|
||||
recurrencyRules[recurrencyRuleIndex] = moment.tz(timezone).hour();
|
||||
return true;
|
||||
}
|
||||
} else if (intervalSize && recurrencyRuleIndex !== undefined && typeInterval === 'months') {
|
||||
if (
|
||||
lastExecution === undefined ||
|
||||
moment.tz(timezone).month() === (intervalSize + lastExecution) % 12
|
||||
) {
|
||||
recurrencyRules[recurrencyRuleIndex] = moment.tz(timezone).month();
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function convertMonthToUnix(expression: string): string {
|
||||
if (!isNaN(parseInt(expression)) || expression.includes('-') || expression.includes(',')) {
|
||||
let matches = expression.match(/([0-9])+/g) as string[];
|
||||
if (matches) {
|
||||
matches = matches.map((match) =>
|
||||
parseInt(match) !== 0 ? String(parseInt(match) - 1) : match,
|
||||
);
|
||||
}
|
||||
expression = matches?.join(expression.includes('-') ? '-' : ',') || '';
|
||||
}
|
||||
return expression;
|
||||
}
|
||||
export const toCronExpression = (interval: ScheduleInterval): CronExpression => {
|
||||
if (interval.field === 'cronExpression') return interval.expression;
|
||||
if (interval.field === 'seconds') return `*/${interval.secondsInterval} * * * * *`;
|
||||
|
||||
export function convertToUnixFormat(interval: IDataObject) {
|
||||
const expression = (interval.expression as string).split(' ');
|
||||
if (expression.length === 5) {
|
||||
expression[3] = convertMonthToUnix(expression[3]);
|
||||
expression[4] = expression[4].replace('7', '0');
|
||||
} else if (expression.length === 6) {
|
||||
expression[4] = convertMonthToUnix(expression[4]);
|
||||
expression[5] = expression[5].replace('7', '0');
|
||||
}
|
||||
interval.expression = expression.join(' ');
|
||||
}
|
||||
const randomSecond = randomInt(0, 60);
|
||||
if (interval.field === 'minutes') return `${randomSecond} */${interval.minutesInterval} * * * *`;
|
||||
|
||||
export const addFallbackValue = <T>(enabled: boolean, fallback: T) => {
|
||||
if (enabled) {
|
||||
return (value: T) => {
|
||||
if (!value) return fallback;
|
||||
return value;
|
||||
};
|
||||
const minute = interval.triggerAtMinute ?? randomInt(0, 60);
|
||||
if (interval.field === 'hours')
|
||||
return `${randomSecond} ${minute} */${interval.hoursInterval} * * *`;
|
||||
|
||||
// Since Cron does not support `*/` for days or weeks, all following expressions trigger more often, but are then filtered by `recurrenceCheck`
|
||||
const hour = interval.triggerAtHour ?? randomInt(0, 24);
|
||||
if (interval.field === 'days') return `${randomSecond} ${minute} ${hour} * * *`;
|
||||
if (interval.field === 'weeks') {
|
||||
const days = interval.triggerAtDay;
|
||||
const daysOfWeek = days.length === 0 ? '*' : days.join(',');
|
||||
return `${randomSecond} ${minute} ${hour} * * ${daysOfWeek}` as CronExpression;
|
||||
}
|
||||
return (value: T) => value;
|
||||
|
||||
const dayOfMonth = interval.triggerAtDayOfMonth ?? randomInt(0, 31);
|
||||
return `${randomSecond} ${minute} ${hour} ${dayOfMonth} */${interval.monthsInterval} *`;
|
||||
};
|
||||
|
||||
export function intervalToRecurrence(interval: ScheduleInterval, index: number) {
|
||||
let recurrence: IRecurrenceRule = { activated: false };
|
||||
|
||||
if (interval.field === 'hours') {
|
||||
const { hoursInterval } = interval;
|
||||
if (hoursInterval !== 1) {
|
||||
recurrence = {
|
||||
activated: true,
|
||||
index,
|
||||
intervalSize: hoursInterval,
|
||||
typeInterval: 'hours',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (interval.field === 'days') {
|
||||
const { daysInterval } = interval;
|
||||
if (daysInterval !== 1) {
|
||||
recurrence = {
|
||||
activated: true,
|
||||
index,
|
||||
intervalSize: daysInterval,
|
||||
typeInterval: 'days',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (interval.field === 'weeks') {
|
||||
const { weeksInterval } = interval;
|
||||
if (weeksInterval !== 1) {
|
||||
recurrence = {
|
||||
activated: true,
|
||||
index,
|
||||
intervalSize: weeksInterval,
|
||||
typeInterval: 'weeks',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (interval.field === 'months') {
|
||||
const { monthsInterval } = interval;
|
||||
if (monthsInterval !== 1) {
|
||||
recurrence = {
|
||||
activated: true,
|
||||
index,
|
||||
intervalSize: monthsInterval,
|
||||
typeInterval: 'months',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return recurrence;
|
||||
}
|
||||
|
|
|
@ -1,16 +1,15 @@
|
|||
import type {
|
||||
ITriggerFunctions,
|
||||
IDataObject,
|
||||
INodeType,
|
||||
INodeTypeDescription,
|
||||
ITriggerResponse,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { CronJob } from 'cron';
|
||||
import moment from 'moment-timezone';
|
||||
import type { IRecurencyRule } from './SchedulerInterface';
|
||||
import { addFallbackValue, convertToUnixFormat, recurencyCheck } from './GenericFunctions';
|
||||
import { sendAt } from 'cron';
|
||||
|
||||
import type { IRecurrenceRule, Rule } from './SchedulerInterface';
|
||||
import { intervalToRecurrence, recurrenceCheck, toCronExpression } from './GenericFunctions';
|
||||
|
||||
export class ScheduleTrigger implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
|
@ -402,7 +401,7 @@ export class ScheduleTrigger implements INodeType {
|
|||
field: ['cronExpression'],
|
||||
},
|
||||
},
|
||||
hint: 'Format: [Minute] [Hour] [Day of Month] [Month] [Day of Week]',
|
||||
hint: 'Format: [Second] [Minute] [Hour] [Day of Month] [Month] [Day of Week]',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
@ -412,239 +411,74 @@ export class ScheduleTrigger implements INodeType {
|
|||
};
|
||||
|
||||
async trigger(this: ITriggerFunctions): Promise<ITriggerResponse> {
|
||||
const rule = this.getNodeParameter('rule', []) as IDataObject;
|
||||
const interval = rule.interval as IDataObject[];
|
||||
const { interval: intervals } = this.getNodeParameter('rule', []) as Rule;
|
||||
const timezone = this.getTimezone();
|
||||
const nodeVersion = this.getNode().typeVersion;
|
||||
const cronJobs: CronJob[] = [];
|
||||
const intervalArr: NodeJS.Timeout[] = [];
|
||||
const staticData = this.getWorkflowStaticData('node') as {
|
||||
recurrencyRules: number[];
|
||||
recurrenceRules: number[];
|
||||
};
|
||||
if (!staticData.recurrencyRules) {
|
||||
staticData.recurrencyRules = [];
|
||||
if (!staticData.recurrenceRules) {
|
||||
staticData.recurrenceRules = [];
|
||||
}
|
||||
const fallbackToZero = addFallbackValue(nodeVersion >= 1.2, '0');
|
||||
const executeTrigger = async (recurency: IRecurencyRule) => {
|
||||
|
||||
const executeTrigger = (recurrence: IRecurrenceRule) => {
|
||||
const shouldTrigger = recurrenceCheck(recurrence, staticData.recurrenceRules, timezone);
|
||||
if (!shouldTrigger) return;
|
||||
|
||||
const momentTz = moment.tz(timezone);
|
||||
const resultData = {
|
||||
timestamp: moment.tz(timezone).toISOString(true),
|
||||
'Readable date': moment.tz(timezone).format('MMMM Do YYYY, h:mm:ss a'),
|
||||
'Readable time': moment.tz(timezone).format('h:mm:ss a'),
|
||||
'Day of week': moment.tz(timezone).format('dddd'),
|
||||
Year: moment.tz(timezone).format('YYYY'),
|
||||
Month: moment.tz(timezone).format('MMMM'),
|
||||
'Day of month': moment.tz(timezone).format('DD'),
|
||||
Hour: moment.tz(timezone).format('HH'),
|
||||
Minute: moment.tz(timezone).format('mm'),
|
||||
Second: moment.tz(timezone).format('ss'),
|
||||
Timezone: moment.tz(timezone).format('z Z'),
|
||||
timestamp: momentTz.toISOString(true),
|
||||
'Readable date': momentTz.format('MMMM Do YYYY, h:mm:ss a'),
|
||||
'Readable time': momentTz.format('h:mm:ss a'),
|
||||
'Day of week': momentTz.format('dddd'),
|
||||
Year: momentTz.format('YYYY'),
|
||||
Month: momentTz.format('MMMM'),
|
||||
'Day of month': momentTz.format('DD'),
|
||||
Hour: momentTz.format('HH'),
|
||||
Minute: momentTz.format('mm'),
|
||||
Second: momentTz.format('ss'),
|
||||
Timezone: `${timezone} (UTC${momentTz.format('Z')})`,
|
||||
};
|
||||
|
||||
if (!recurency.activated) {
|
||||
this.emit([this.helpers.returnJsonArray([resultData])]);
|
||||
} else {
|
||||
if (recurencyCheck(recurency, staticData.recurrencyRules, timezone)) {
|
||||
this.emit([this.helpers.returnJsonArray([resultData])]);
|
||||
}
|
||||
}
|
||||
this.emit([this.helpers.returnJsonArray([resultData])]);
|
||||
};
|
||||
|
||||
for (let i = 0; i < interval.length; i++) {
|
||||
let intervalValue = 1000;
|
||||
if (interval[i].field === 'cronExpression') {
|
||||
if (nodeVersion > 1) {
|
||||
// ! Remove this part if we use a cron library that follows unix cron expression
|
||||
convertToUnixFormat(interval[i]);
|
||||
}
|
||||
const cronExpression = interval[i].expression as string;
|
||||
const rules = intervals.map((interval, i) => ({
|
||||
interval,
|
||||
cronExpression: toCronExpression(interval),
|
||||
recurrence: intervalToRecurrence(interval, i),
|
||||
}));
|
||||
|
||||
if (this.getMode() !== 'manual') {
|
||||
for (const { interval, cronExpression, recurrence } of rules) {
|
||||
try {
|
||||
const cronJob = new CronJob(
|
||||
cronExpression,
|
||||
async () => await executeTrigger({ activated: false } as IRecurencyRule),
|
||||
undefined,
|
||||
true,
|
||||
timezone,
|
||||
);
|
||||
cronJobs.push(cronJob);
|
||||
this.helpers.registerCron(cronExpression, () => executeTrigger(recurrence));
|
||||
} catch (error) {
|
||||
throw new NodeOperationError(this.getNode(), 'Invalid cron expression', {
|
||||
description: 'More information on how to build them at https://crontab.guru/',
|
||||
});
|
||||
if (interval.field === 'cronExpression') {
|
||||
throw new NodeOperationError(this.getNode(), 'Invalid cron expression', {
|
||||
description: 'More information on how to build them at https://crontab.guru/',
|
||||
});
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (interval[i].field === 'seconds') {
|
||||
const seconds = interval[i].secondsInterval as number;
|
||||
intervalValue *= seconds;
|
||||
const intervalObj = setInterval(
|
||||
async () => await executeTrigger({ activated: false } as IRecurencyRule),
|
||||
intervalValue,
|
||||
) as NodeJS.Timeout;
|
||||
intervalArr.push(intervalObj);
|
||||
}
|
||||
|
||||
if (interval[i].field === 'minutes') {
|
||||
const minutes = interval[i].minutesInterval as number;
|
||||
intervalValue *= 60 * minutes;
|
||||
const intervalObj = setInterval(
|
||||
async () => await executeTrigger({ activated: false } as IRecurencyRule),
|
||||
intervalValue,
|
||||
) as NodeJS.Timeout;
|
||||
intervalArr.push(intervalObj);
|
||||
}
|
||||
|
||||
if (interval[i].field === 'hours') {
|
||||
const hour = interval[i].hoursInterval as number;
|
||||
const minute = fallbackToZero(interval[i].triggerAtMinute?.toString() as string);
|
||||
|
||||
const cronTimes: string[] = [minute, '*', '*', '*', '*'];
|
||||
const cronExpression: string = cronTimes.join(' ');
|
||||
if (hour === 1) {
|
||||
const cronJob = new CronJob(
|
||||
cronExpression,
|
||||
async () => await executeTrigger({ activated: false } as IRecurencyRule),
|
||||
undefined,
|
||||
true,
|
||||
timezone,
|
||||
);
|
||||
cronJobs.push(cronJob);
|
||||
} else {
|
||||
const cronJob = new CronJob(
|
||||
cronExpression,
|
||||
async () =>
|
||||
await executeTrigger({
|
||||
activated: true,
|
||||
index: i,
|
||||
intervalSize: hour,
|
||||
typeInterval: 'hours',
|
||||
} as IRecurencyRule),
|
||||
undefined,
|
||||
true,
|
||||
timezone,
|
||||
);
|
||||
cronJobs.push(cronJob);
|
||||
return {};
|
||||
} else {
|
||||
const manualTriggerFunction = async () => {
|
||||
const { interval, cronExpression, recurrence } = rules[0];
|
||||
if (interval.field === 'cronExpression') {
|
||||
try {
|
||||
sendAt(cronExpression);
|
||||
} catch (error) {
|
||||
throw new NodeOperationError(this.getNode(), 'Invalid cron expression', {
|
||||
description: 'More information on how to build them at https://crontab.guru/',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
executeTrigger(recurrence);
|
||||
};
|
||||
|
||||
if (interval[i].field === 'days') {
|
||||
const day = interval[i].daysInterval as number;
|
||||
const hour = interval[i].triggerAtHour?.toString() as string;
|
||||
const minute = fallbackToZero(interval[i].triggerAtMinute?.toString() as string);
|
||||
const cronTimes: string[] = [minute, hour, '*', '*', '*'];
|
||||
const cronExpression: string = cronTimes.join(' ');
|
||||
if (day === 1) {
|
||||
const cronJob = new CronJob(
|
||||
cronExpression,
|
||||
async () => await executeTrigger({ activated: false } as IRecurencyRule),
|
||||
undefined,
|
||||
true,
|
||||
timezone,
|
||||
);
|
||||
cronJobs.push(cronJob);
|
||||
} else {
|
||||
const cronJob = new CronJob(
|
||||
cronExpression,
|
||||
async () =>
|
||||
await executeTrigger({
|
||||
activated: true,
|
||||
index: i,
|
||||
intervalSize: day,
|
||||
typeInterval: 'days',
|
||||
} as IRecurencyRule),
|
||||
undefined,
|
||||
true,
|
||||
timezone,
|
||||
);
|
||||
cronJobs.push(cronJob);
|
||||
}
|
||||
}
|
||||
|
||||
if (interval[i].field === 'weeks') {
|
||||
const hour = interval[i].triggerAtHour?.toString() as string;
|
||||
const minute = fallbackToZero(interval[i].triggerAtMinute?.toString() as string);
|
||||
const week = interval[i].weeksInterval as number;
|
||||
const days = interval[i].triggerAtDay as IDataObject[];
|
||||
const day = days.length === 0 ? '*' : days.join(',');
|
||||
const cronTimes: string[] = [minute, hour, '*', '*', day];
|
||||
const cronExpression = cronTimes.join(' ');
|
||||
if (week === 1) {
|
||||
const cronJob = new CronJob(
|
||||
cronExpression,
|
||||
async () => await executeTrigger({ activated: false } as IRecurencyRule),
|
||||
undefined,
|
||||
true,
|
||||
timezone,
|
||||
);
|
||||
cronJobs.push(cronJob);
|
||||
} else {
|
||||
const cronJob = new CronJob(
|
||||
cronExpression,
|
||||
async () =>
|
||||
await executeTrigger({
|
||||
activated: true,
|
||||
index: i,
|
||||
intervalSize: week,
|
||||
typeInterval: 'weeks',
|
||||
} as IRecurencyRule),
|
||||
undefined,
|
||||
true,
|
||||
timezone,
|
||||
);
|
||||
cronJobs.push(cronJob);
|
||||
}
|
||||
}
|
||||
|
||||
if (interval[i].field === 'months') {
|
||||
const month = interval[i].monthsInterval;
|
||||
const day = interval[i].triggerAtDayOfMonth?.toString() as string;
|
||||
const hour = interval[i].triggerAtHour?.toString() as string;
|
||||
const minute = fallbackToZero(interval[i].triggerAtMinute?.toString() as string);
|
||||
const cronTimes: string[] = [minute, hour, day, '*', '*'];
|
||||
const cronExpression: string = cronTimes.join(' ');
|
||||
if (month === 1) {
|
||||
const cronJob = new CronJob(
|
||||
cronExpression,
|
||||
async () => await executeTrigger({ activated: false } as IRecurencyRule),
|
||||
undefined,
|
||||
true,
|
||||
timezone,
|
||||
);
|
||||
cronJobs.push(cronJob);
|
||||
} else {
|
||||
const cronJob = new CronJob(
|
||||
cronExpression,
|
||||
async () =>
|
||||
await executeTrigger({
|
||||
activated: true,
|
||||
index: i,
|
||||
intervalSize: month,
|
||||
typeInterval: 'months',
|
||||
} as IRecurencyRule),
|
||||
undefined,
|
||||
true,
|
||||
timezone,
|
||||
);
|
||||
cronJobs.push(cronJob);
|
||||
}
|
||||
}
|
||||
return { manualTriggerFunction };
|
||||
}
|
||||
|
||||
async function closeFunction() {
|
||||
for (const cronJob of cronJobs) {
|
||||
cronJob.stop();
|
||||
}
|
||||
for (const entry of intervalArr) {
|
||||
clearInterval(entry);
|
||||
}
|
||||
}
|
||||
|
||||
async function manualTriggerFunction() {
|
||||
void executeTrigger({ activated: false } as IRecurencyRule);
|
||||
}
|
||||
|
||||
return {
|
||||
closeFunction,
|
||||
manualTriggerFunction,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,53 @@
|
|||
export interface IRecurencyRule {
|
||||
activated: boolean;
|
||||
index?: number;
|
||||
intervalSize?: number;
|
||||
typeInterval?: string;
|
||||
import type { CronExpression } from 'n8n-workflow';
|
||||
|
||||
export type IRecurrenceRule =
|
||||
| { activated: false }
|
||||
| {
|
||||
activated: true;
|
||||
index: number;
|
||||
intervalSize: number;
|
||||
typeInterval: 'hours' | 'days' | 'weeks' | 'months';
|
||||
};
|
||||
|
||||
export type ScheduleInterval =
|
||||
| {
|
||||
field: 'cronExpression';
|
||||
expression: CronExpression;
|
||||
}
|
||||
| {
|
||||
field: 'seconds';
|
||||
secondsInterval: number;
|
||||
}
|
||||
| {
|
||||
field: 'minutes';
|
||||
minutesInterval: number;
|
||||
}
|
||||
| {
|
||||
field: 'hours';
|
||||
hoursInterval: number;
|
||||
triggerAtMinute?: number;
|
||||
}
|
||||
| {
|
||||
field: 'days';
|
||||
daysInterval: number;
|
||||
triggerAtHour?: number;
|
||||
triggerAtMinute?: number;
|
||||
}
|
||||
| {
|
||||
field: 'weeks';
|
||||
weeksInterval: number;
|
||||
triggerAtDay: number[];
|
||||
triggerAtHour?: number;
|
||||
triggerAtMinute?: number;
|
||||
}
|
||||
| {
|
||||
field: 'months';
|
||||
monthsInterval: number;
|
||||
triggerAtDayOfMonth?: number;
|
||||
triggerAtHour?: number;
|
||||
triggerAtMinute?: number;
|
||||
};
|
||||
|
||||
export interface Rule {
|
||||
interval: ScheduleInterval[];
|
||||
}
|
||||
|
|
257
packages/nodes-base/nodes/Schedule/test/GenericFunctions.test.ts
Normal file
257
packages/nodes-base/nodes/Schedule/test/GenericFunctions.test.ts
Normal file
|
@ -0,0 +1,257 @@
|
|||
import * as n8nWorkflow from 'n8n-workflow';
|
||||
import { intervalToRecurrence, recurrenceCheck, toCronExpression } from '../GenericFunctions';
|
||||
import type { IRecurrenceRule } from '../SchedulerInterface';
|
||||
|
||||
describe('toCronExpression', () => {
|
||||
Object.defineProperty(n8nWorkflow, 'randomInt', {
|
||||
value: (min: number, max: number) => Math.floor((min + max) / 2),
|
||||
});
|
||||
|
||||
it('should return cron expression for cronExpression field', () => {
|
||||
const result = toCronExpression({
|
||||
field: 'cronExpression',
|
||||
expression: '1 2 3 * * *',
|
||||
});
|
||||
expect(result).toEqual('1 2 3 * * *');
|
||||
});
|
||||
|
||||
it('should return cron expression for seconds interval', () => {
|
||||
const result = toCronExpression({
|
||||
field: 'seconds',
|
||||
secondsInterval: 10,
|
||||
});
|
||||
expect(result).toEqual('*/10 * * * * *');
|
||||
});
|
||||
|
||||
it('should return cron expression for minutes interval', () => {
|
||||
const result = toCronExpression({
|
||||
field: 'minutes',
|
||||
minutesInterval: 30,
|
||||
});
|
||||
expect(result).toEqual('30 */30 * * * *');
|
||||
});
|
||||
|
||||
it('should return cron expression for hours interval', () => {
|
||||
const result = toCronExpression({
|
||||
field: 'hours',
|
||||
hoursInterval: 3,
|
||||
triggerAtMinute: 22,
|
||||
});
|
||||
expect(result).toEqual('30 22 */3 * * *');
|
||||
|
||||
const result1 = toCronExpression({
|
||||
field: 'hours',
|
||||
hoursInterval: 3,
|
||||
});
|
||||
expect(result1).toEqual('30 30 */3 * * *');
|
||||
});
|
||||
|
||||
it('should return cron expression for days interval', () => {
|
||||
const result = toCronExpression({
|
||||
field: 'days',
|
||||
daysInterval: 4,
|
||||
triggerAtMinute: 30,
|
||||
triggerAtHour: 10,
|
||||
});
|
||||
expect(result).toEqual('30 30 10 * * *');
|
||||
|
||||
const result1 = toCronExpression({
|
||||
field: 'days',
|
||||
daysInterval: 4,
|
||||
});
|
||||
expect(result1).toEqual('30 30 12 * * *');
|
||||
});
|
||||
|
||||
it('should return cron expression for weeks interval', () => {
|
||||
const result = toCronExpression({
|
||||
field: 'weeks',
|
||||
weeksInterval: 2,
|
||||
triggerAtMinute: 0,
|
||||
triggerAtHour: 9,
|
||||
triggerAtDay: [1, 3, 5],
|
||||
});
|
||||
expect(result).toEqual('30 0 9 * * 1,3,5');
|
||||
const result1 = toCronExpression({
|
||||
field: 'weeks',
|
||||
weeksInterval: 2,
|
||||
triggerAtDay: [1, 3, 5],
|
||||
});
|
||||
expect(result1).toEqual('30 30 12 * * 1,3,5');
|
||||
});
|
||||
|
||||
it('should return cron expression for months interval', () => {
|
||||
const result = toCronExpression({
|
||||
field: 'months',
|
||||
monthsInterval: 3,
|
||||
triggerAtMinute: 0,
|
||||
triggerAtHour: 0,
|
||||
triggerAtDayOfMonth: 1,
|
||||
});
|
||||
expect(result).toEqual('30 0 0 1 */3 *');
|
||||
const result1 = toCronExpression({
|
||||
field: 'months',
|
||||
monthsInterval: 3,
|
||||
});
|
||||
expect(result1).toEqual('30 30 12 15 */3 *');
|
||||
});
|
||||
});
|
||||
|
||||
describe('recurrenceCheck', () => {
|
||||
it('should return true if activated=false', () => {
|
||||
const result = recurrenceCheck({ activated: false }, [], 'UTC');
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if intervalSize is falsey', () => {
|
||||
const result = recurrenceCheck(
|
||||
{
|
||||
activated: true,
|
||||
index: 0,
|
||||
intervalSize: 0,
|
||||
typeInterval: 'days',
|
||||
},
|
||||
[],
|
||||
'UTC',
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true only once for a day cron', () => {
|
||||
const recurrence: IRecurrenceRule = {
|
||||
activated: true,
|
||||
index: 0,
|
||||
intervalSize: 2,
|
||||
typeInterval: 'days',
|
||||
};
|
||||
const recurrenceRules: number[] = [];
|
||||
const result1 = recurrenceCheck(recurrence, recurrenceRules, 'UTC');
|
||||
expect(result1).toBe(true);
|
||||
const result2 = recurrenceCheck(recurrence, recurrenceRules, 'UTC');
|
||||
expect(result2).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('intervalToRecurrence', () => {
|
||||
it('should return recurrence rule for seconds interval', () => {
|
||||
const result = intervalToRecurrence(
|
||||
{
|
||||
field: 'seconds',
|
||||
secondsInterval: 10,
|
||||
},
|
||||
0,
|
||||
);
|
||||
expect(result.activated).toBe(false);
|
||||
});
|
||||
|
||||
it('should return recurrence rule for minutes interval', () => {
|
||||
const result = intervalToRecurrence(
|
||||
{
|
||||
field: 'minutes',
|
||||
minutesInterval: 30,
|
||||
},
|
||||
1,
|
||||
);
|
||||
expect(result.activated).toBe(false);
|
||||
});
|
||||
|
||||
it('should return recurrence rule for hours interval', () => {
|
||||
const result = intervalToRecurrence(
|
||||
{
|
||||
field: 'hours',
|
||||
hoursInterval: 3,
|
||||
triggerAtMinute: 22,
|
||||
},
|
||||
2,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
activated: true,
|
||||
index: 2,
|
||||
intervalSize: 3,
|
||||
typeInterval: 'hours',
|
||||
});
|
||||
|
||||
const result1 = intervalToRecurrence(
|
||||
{
|
||||
field: 'hours',
|
||||
hoursInterval: 3,
|
||||
},
|
||||
3,
|
||||
);
|
||||
expect(result1).toEqual({
|
||||
activated: true,
|
||||
index: 3,
|
||||
intervalSize: 3,
|
||||
typeInterval: 'hours',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return recurrence rule for days interval', () => {
|
||||
const result = intervalToRecurrence(
|
||||
{
|
||||
field: 'days',
|
||||
daysInterval: 4,
|
||||
triggerAtMinute: 30,
|
||||
triggerAtHour: 10,
|
||||
},
|
||||
4,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
activated: true,
|
||||
index: 4,
|
||||
intervalSize: 4,
|
||||
typeInterval: 'days',
|
||||
});
|
||||
|
||||
const result1 = intervalToRecurrence(
|
||||
{
|
||||
field: 'days',
|
||||
daysInterval: 4,
|
||||
},
|
||||
5,
|
||||
);
|
||||
expect(result1).toEqual({
|
||||
activated: true,
|
||||
index: 5,
|
||||
intervalSize: 4,
|
||||
typeInterval: 'days',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return recurrence rule for weeks interval', () => {
|
||||
const result = intervalToRecurrence(
|
||||
{
|
||||
field: 'weeks',
|
||||
weeksInterval: 2,
|
||||
triggerAtMinute: 0,
|
||||
triggerAtHour: 9,
|
||||
triggerAtDay: [1, 3, 5],
|
||||
},
|
||||
6,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
activated: true,
|
||||
index: 6,
|
||||
intervalSize: 2,
|
||||
typeInterval: 'weeks',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return recurrence rule for months interval', () => {
|
||||
const result = intervalToRecurrence(
|
||||
{
|
||||
field: 'months',
|
||||
monthsInterval: 3,
|
||||
triggerAtMinute: 0,
|
||||
triggerAtHour: 0,
|
||||
triggerAtDayOfMonth: 1,
|
||||
},
|
||||
8,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
activated: true,
|
||||
index: 8,
|
||||
intervalSize: 3,
|
||||
typeInterval: 'months',
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,83 @@
|
|||
import * as n8nWorkflow from 'n8n-workflow';
|
||||
import type { INode, ITriggerFunctions, Workflow } from 'n8n-workflow';
|
||||
import { returnJsonArray } from 'n8n-core';
|
||||
import { ScheduledTaskManager } from 'n8n-core/dist/ScheduledTaskManager';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import { ScheduleTrigger } from '../ScheduleTrigger.node';
|
||||
|
||||
describe('ScheduleTrigger', () => {
|
||||
Object.defineProperty(n8nWorkflow, 'randomInt', {
|
||||
value: (min: number, max: number) => Math.floor((min + max) / 2),
|
||||
});
|
||||
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
const mockDate = new Date('2023-12-28 12:34:56.789Z');
|
||||
const timezone = 'Europe/Berlin';
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(mockDate);
|
||||
|
||||
const node = mock<INode>({ typeVersion: 1 });
|
||||
const workflow = mock<Workflow>({ timezone });
|
||||
const scheduledTaskManager = new ScheduledTaskManager();
|
||||
const helpers = mock<ITriggerFunctions['helpers']>({
|
||||
returnJsonArray,
|
||||
registerCron: (cronExpression, onTick) =>
|
||||
scheduledTaskManager.registerCron(workflow, cronExpression, onTick),
|
||||
});
|
||||
|
||||
const triggerFunctions = mock<ITriggerFunctions>({
|
||||
helpers,
|
||||
getTimezone: () => timezone,
|
||||
getNode: () => node,
|
||||
getMode: () => 'trigger',
|
||||
});
|
||||
|
||||
const scheduleTrigger = new ScheduleTrigger();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('trigger', () => {
|
||||
it('should emit on defined schedule', async () => {
|
||||
triggerFunctions.getNodeParameter.calledWith('rule', expect.anything()).mockReturnValueOnce({
|
||||
interval: [{ field: 'hours', hoursInterval: 3 }],
|
||||
});
|
||||
triggerFunctions.getWorkflowStaticData.mockReturnValueOnce({ recurrenceRules: [] });
|
||||
|
||||
const result = await scheduleTrigger.trigger.call(triggerFunctions);
|
||||
// Assert that no manualTriggerFunction is returned
|
||||
expect(result).toEqual({});
|
||||
|
||||
expect(triggerFunctions.emit).not.toHaveBeenCalled();
|
||||
|
||||
jest.advanceTimersByTime(HOUR);
|
||||
expect(triggerFunctions.emit).not.toHaveBeenCalled();
|
||||
|
||||
jest.advanceTimersByTime(2 * HOUR);
|
||||
expect(triggerFunctions.emit).toHaveBeenCalledTimes(1);
|
||||
|
||||
const firstTriggerData = triggerFunctions.emit.mock.calls[0][0][0][0];
|
||||
expect(firstTriggerData.json).toEqual({
|
||||
'Day of month': '28',
|
||||
'Day of week': 'Thursday',
|
||||
Hour: '15',
|
||||
Minute: '30',
|
||||
Month: 'December',
|
||||
'Readable date': 'December 28th 2023, 3:30:30 pm',
|
||||
'Readable time': '3:30:30 pm',
|
||||
Second: '30',
|
||||
Timezone: 'Europe/Berlin (UTC+01:00)',
|
||||
Year: '2023',
|
||||
timestamp: '2023-12-28T15:30:30.000+01:00',
|
||||
});
|
||||
|
||||
jest.setSystemTime(new Date(firstTriggerData.json.timestamp as string));
|
||||
|
||||
jest.advanceTimersByTime(2 * HOUR);
|
||||
expect(triggerFunctions.emit).toHaveBeenCalledTimes(1);
|
||||
jest.advanceTimersByTime(HOUR);
|
||||
expect(triggerFunctions.emit).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -804,7 +804,6 @@
|
|||
"@types/aws4": "^1.5.1",
|
||||
"@types/basic-auth": "^1.1.3",
|
||||
"@types/cheerio": "^0.22.15",
|
||||
"@types/cron": "~1.7.1",
|
||||
"@types/eventsource": "^1.1.2",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/html-to-text": "^9.0.1",
|
||||
|
@ -838,7 +837,7 @@
|
|||
"change-case": "4.1.2",
|
||||
"cheerio": "1.0.0-rc.6",
|
||||
"chokidar": "3.5.2",
|
||||
"cron": "1.7.2",
|
||||
"cron": "3.1.7",
|
||||
"csv-parse": "5.5.0",
|
||||
"currency-codes": "2.1.0",
|
||||
"eventsource": "2.0.2",
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import type { CronExpression } from './Interfaces';
|
||||
import { randomInt } from './utils';
|
||||
|
||||
interface BaseTriggerTime<T extends string> {
|
||||
mode: T;
|
||||
}
|
||||
|
||||
type CronExpression = string;
|
||||
interface CustomTrigger extends BaseTriggerTime<'custom'> {
|
||||
cronExpression: CronExpression;
|
||||
}
|
||||
|
@ -49,22 +49,24 @@ export type TriggerTime =
|
|||
| EveryWeek
|
||||
| EveryMonth;
|
||||
|
||||
const randomSecond = () => randomInt(60).toString();
|
||||
|
||||
export const toCronExpression = (item: TriggerTime): CronExpression => {
|
||||
if (item.mode === 'everyMinute') return `${randomSecond()} * * * * *`;
|
||||
if (item.mode === 'everyHour') return `${randomSecond()} ${item.minute} * * * *`;
|
||||
const randomSecond = randomInt(60);
|
||||
|
||||
if (item.mode === 'everyMinute') return `${randomSecond} * * * * *`;
|
||||
if (item.mode === 'everyHour') return `${randomSecond} ${item.minute} * * * *`;
|
||||
|
||||
if (item.mode === 'everyX') {
|
||||
if (item.unit === 'minutes') return `${randomSecond()} */${item.value} * * * *`;
|
||||
if (item.unit === 'hours') return `${randomSecond()} 0 */${item.value} * * *`;
|
||||
if (item.unit === 'minutes') return `${randomSecond} */${item.value} * * * *`;
|
||||
|
||||
const randomMinute = randomInt(60);
|
||||
if (item.unit === 'hours') return `${randomSecond} ${randomMinute} */${item.value} * * *`;
|
||||
}
|
||||
if (item.mode === 'everyDay') return `${randomSecond()} ${item.minute} ${item.hour} * * *`;
|
||||
if (item.mode === 'everyDay') return `${randomSecond} ${item.minute} ${item.hour} * * *`;
|
||||
if (item.mode === 'everyWeek')
|
||||
return `${randomSecond()} ${item.minute} ${item.hour} * * ${item.weekday}`;
|
||||
return `${randomSecond} ${item.minute} ${item.hour} * * ${item.weekday}`;
|
||||
|
||||
if (item.mode === 'everyMonth')
|
||||
return `${randomSecond()} ${item.minute} ${item.hour} ${item.dayOfMonth} * *`;
|
||||
return `${randomSecond} ${item.minute} ${item.hour} ${item.dayOfMonth} * *`;
|
||||
|
||||
return item.cronExpression.trim();
|
||||
return item.cronExpression.trim() as CronExpression;
|
||||
};
|
||||
|
|
|
@ -842,6 +842,14 @@ export interface SSHTunnelFunctions {
|
|||
getSSHClient(credentials: SSHCredentials): Promise<SSHClient>;
|
||||
}
|
||||
|
||||
type CronUnit = number | '*' | `*/${number}`;
|
||||
export type CronExpression =
|
||||
`${CronUnit} ${CronUnit} ${CronUnit} ${CronUnit} ${CronUnit} ${CronUnit}`;
|
||||
|
||||
export interface SchedulingFunctions {
|
||||
registerCron(cronExpression: CronExpression, onTick: () => void): void;
|
||||
}
|
||||
|
||||
export type NodeTypeAndVersion = {
|
||||
name: string;
|
||||
type: string;
|
||||
|
@ -994,6 +1002,7 @@ export interface IPollFunctions
|
|||
helpers: RequestHelperFunctions &
|
||||
BaseHelperFunctions &
|
||||
BinaryHelperFunctions &
|
||||
SchedulingFunctions &
|
||||
JsonHelperFunctions;
|
||||
}
|
||||
|
||||
|
@ -1014,6 +1023,7 @@ export interface ITriggerFunctions
|
|||
BaseHelperFunctions &
|
||||
BinaryHelperFunctions &
|
||||
SSHTunnelFunctions &
|
||||
SchedulingFunctions &
|
||||
JsonHelperFunctions;
|
||||
}
|
||||
|
||||
|
@ -1436,14 +1446,10 @@ export type IParameterLabel = {
|
|||
size?: 'small' | 'medium';
|
||||
};
|
||||
|
||||
export interface IPollResponse {
|
||||
closeFunction?: CloseFunction;
|
||||
}
|
||||
|
||||
export interface ITriggerResponse {
|
||||
closeFunction?: CloseFunction;
|
||||
// To manually trigger the run
|
||||
manualTriggerFunction?: CloseFunction;
|
||||
manualTriggerFunction?: () => Promise<void>;
|
||||
// Gets added automatically at manual workflow runs resolves with
|
||||
// the first emitted data
|
||||
manualTriggerResponse?: Promise<INodeExecutionData[][]>;
|
||||
|
|
|
@ -58,6 +58,7 @@ import {
|
|||
STARTING_NODE_TYPES,
|
||||
} from './Constants';
|
||||
import { ApplicationError } from './errors/application.error';
|
||||
import { getGlobalState } from './GlobalState';
|
||||
|
||||
function dedupe<T>(arr: T[]): T[] {
|
||||
return [...new Set(arr)];
|
||||
|
@ -94,6 +95,8 @@ export class Workflow {
|
|||
|
||||
settings: IWorkflowSettings;
|
||||
|
||||
readonly timezone: string;
|
||||
|
||||
// To save workflow specific static data like for example
|
||||
// ids of registered webhooks of nodes
|
||||
staticData: IDataObject;
|
||||
|
@ -151,6 +154,7 @@ export class Workflow {
|
|||
});
|
||||
|
||||
this.settings = parameters.settings || {};
|
||||
this.timezone = this.settings.timezone ?? getGlobalState().defaultTimezone;
|
||||
|
||||
this.expression = new Expression(this);
|
||||
}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { toCronExpression } from '@/Cron';
|
||||
import type { CronExpression } from '@/Interfaces';
|
||||
|
||||
describe('Cron', () => {
|
||||
describe('toCronExpression', () => {
|
||||
|
@ -6,7 +7,7 @@ describe('Cron', () => {
|
|||
const expression = toCronExpression({
|
||||
mode: 'everyMinute',
|
||||
});
|
||||
expect(expression).toMatch(/^[1-6]?[0-9] \* \* \* \* \*$/);
|
||||
expect(expression).toMatch(/^[1-5]?[0-9] \* \* \* \* \*$/);
|
||||
});
|
||||
|
||||
test('should generate a valid cron for `everyHour` triggers', () => {
|
||||
|
@ -14,7 +15,7 @@ describe('Cron', () => {
|
|||
mode: 'everyHour',
|
||||
minute: 11,
|
||||
});
|
||||
expect(expression).toMatch(/^[1-6]?[0-9] 11 \* \* \* \*$/);
|
||||
expect(expression).toMatch(/^[1-5]?[0-9] 11 \* \* \* \*$/);
|
||||
});
|
||||
|
||||
test('should generate a valid cron for `everyX[minutes]` triggers', () => {
|
||||
|
@ -23,7 +24,7 @@ describe('Cron', () => {
|
|||
unit: 'minutes',
|
||||
value: 42,
|
||||
});
|
||||
expect(expression).toMatch(/^[1-6]?[0-9] \*\/42 \* \* \* \*$/);
|
||||
expect(expression).toMatch(/^[1-5]?[0-9] \*\/42 \* \* \* \*$/);
|
||||
});
|
||||
|
||||
test('should generate a valid cron for `everyX[hours]` triggers', () => {
|
||||
|
@ -32,7 +33,7 @@ describe('Cron', () => {
|
|||
unit: 'hours',
|
||||
value: 3,
|
||||
});
|
||||
expect(expression).toMatch(/^[1-6]?[0-9] 0 \*\/3 \* \* \*$/);
|
||||
expect(expression).toMatch(/^[1-5]?[0-9] [1-5]?[0-9] \*\/3 \* \* \*$/);
|
||||
});
|
||||
|
||||
test('should generate a valid cron for `everyDay` triggers', () => {
|
||||
|
@ -41,7 +42,7 @@ describe('Cron', () => {
|
|||
hour: 13,
|
||||
minute: 17,
|
||||
});
|
||||
expect(expression).toMatch(/^[1-6]?[0-9] 17 13 \* \* \*$/);
|
||||
expect(expression).toMatch(/^[1-5]?[0-9] 17 13 \* \* \*$/);
|
||||
});
|
||||
|
||||
test('should generate a valid cron for `everyWeek` triggers', () => {
|
||||
|
@ -51,7 +52,7 @@ describe('Cron', () => {
|
|||
minute: 17,
|
||||
weekday: 4,
|
||||
});
|
||||
expect(expression).toMatch(/^[1-6]?[0-9] 17 13 \* \* 4$/);
|
||||
expect(expression).toMatch(/^[1-5]?[0-9] 17 13 \* \* 4$/);
|
||||
});
|
||||
|
||||
test('should generate a valid cron for `everyMonth` triggers', () => {
|
||||
|
@ -61,13 +62,13 @@ describe('Cron', () => {
|
|||
minute: 17,
|
||||
dayOfMonth: 12,
|
||||
});
|
||||
expect(expression).toMatch(/^[1-6]?[0-9] 17 13 12 \* \*$/);
|
||||
expect(expression).toMatch(/^[1-5]?[0-9] 17 13 12 \* \*$/);
|
||||
});
|
||||
|
||||
test('should trim custom cron expressions', () => {
|
||||
const expression = toCronExpression({
|
||||
mode: 'custom',
|
||||
cronExpression: ' 0 9-17 * * * ',
|
||||
cronExpression: ' 0 9-17 * * * ' as CronExpression,
|
||||
});
|
||||
expect(expression).toEqual('0 9-17 * * *');
|
||||
});
|
||||
|
|
|
@ -903,8 +903,8 @@ importers:
|
|||
specifier: 2.0.0
|
||||
version: 2.0.0
|
||||
cron:
|
||||
specifier: 1.7.2
|
||||
version: 1.7.2
|
||||
specifier: 3.1.7
|
||||
version: 3.1.7
|
||||
fast-glob:
|
||||
specifier: 3.2.12
|
||||
version: 3.2.12
|
||||
|
@ -957,9 +957,6 @@ importers:
|
|||
'@types/concat-stream':
|
||||
specifier: ^2.0.0
|
||||
version: 2.0.0
|
||||
'@types/cron':
|
||||
specifier: ~1.7.1
|
||||
version: 1.7.3
|
||||
'@types/express':
|
||||
specifier: ^4.17.21
|
||||
version: 4.17.21
|
||||
|
@ -1357,8 +1354,8 @@ importers:
|
|||
specifier: 3.5.2
|
||||
version: 3.5.2
|
||||
cron:
|
||||
specifier: 1.7.2
|
||||
version: 1.7.2
|
||||
specifier: 3.1.7
|
||||
version: 3.1.7
|
||||
csv-parse:
|
||||
specifier: 5.5.0
|
||||
version: 5.5.0
|
||||
|
@ -1528,9 +1525,6 @@ importers:
|
|||
'@types/cheerio':
|
||||
specifier: ^0.22.15
|
||||
version: 0.22.31
|
||||
'@types/cron':
|
||||
specifier: ~1.7.1
|
||||
version: 1.7.3
|
||||
'@types/eventsource':
|
||||
specifier: ^1.1.2
|
||||
version: 1.1.9
|
||||
|
@ -5168,9 +5162,6 @@ packages:
|
|||
'@types/cookiejar@2.1.5':
|
||||
resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==}
|
||||
|
||||
'@types/cron@1.7.3':
|
||||
resolution: {integrity: sha512-iPmUXyIJG1Js+ldPYhOQcYU3kCAQ2FWrSkm1FJPoii2eYSn6wEW6onPukNTT0bfiflexNSRPl6KWmAIqS+36YA==}
|
||||
|
||||
'@types/cross-spawn@6.0.2':
|
||||
resolution: {integrity: sha512-KuwNhp3eza+Rhu8IFI5HUXRP0LIhqH5cAjubUvGXXthh4YYBuP2ntwEX+Cz8GJoZUHlKo247wPWOfA9LYEq4cw==}
|
||||
|
||||
|
@ -5306,6 +5297,9 @@ packages:
|
|||
'@types/luxon@3.2.0':
|
||||
resolution: {integrity: sha512-lGmaGFoaXHuOLXFvuju2bfvZRqxAqkHPx9Y9IQdQABrinJJshJwfNCKV+u7rR3kJbiqfTF/NhOkcxxAFrObyaA==}
|
||||
|
||||
'@types/luxon@3.4.2':
|
||||
resolution: {integrity: sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==}
|
||||
|
||||
'@types/mailparser@3.4.4':
|
||||
resolution: {integrity: sha512-C6Znp2QVS25JqtuPyxj38Qh+QoFcLycdxsvcc6IZCGekhaMBzbdTXzwGzhGoYb3TfKu8IRCNV0sV1o3Od97cEQ==}
|
||||
|
||||
|
@ -6807,8 +6801,8 @@ packages:
|
|||
resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
cron@1.7.2:
|
||||
resolution: {integrity: sha512-+SaJ2OfeRvfQqwXQ2kgr0Y5pzBR/lijf5OpnnaruwWnmI799JfWr2jN2ItOV9s3A/+TFOt6mxvKzQq5F0Jp6VQ==}
|
||||
cron@3.1.7:
|
||||
resolution: {integrity: sha512-tlBg7ARsAMQLzgwqVxy8AZl/qlTc5nibqYwtNGoCrd+cV+ugI+tvZC1oT/8dFH8W455YrywGykx/KMmAqOr7Jw==}
|
||||
|
||||
cross-env@7.0.3:
|
||||
resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==}
|
||||
|
@ -18434,11 +18428,6 @@ snapshots:
|
|||
|
||||
'@types/cookiejar@2.1.5': {}
|
||||
|
||||
'@types/cron@1.7.3':
|
||||
dependencies:
|
||||
'@types/node': 18.16.16
|
||||
moment: 2.29.4
|
||||
|
||||
'@types/cross-spawn@6.0.2':
|
||||
dependencies:
|
||||
'@types/node': 18.16.16
|
||||
|
@ -18581,6 +18570,8 @@ snapshots:
|
|||
|
||||
'@types/luxon@3.2.0': {}
|
||||
|
||||
'@types/luxon@3.4.2': {}
|
||||
|
||||
'@types/mailparser@3.4.4':
|
||||
dependencies:
|
||||
'@types/node': 18.16.16
|
||||
|
@ -20373,9 +20364,10 @@ snapshots:
|
|||
dependencies:
|
||||
luxon: 3.4.4
|
||||
|
||||
cron@1.7.2:
|
||||
cron@3.1.7:
|
||||
dependencies:
|
||||
moment-timezone: 0.5.37
|
||||
'@types/luxon': 3.4.2
|
||||
luxon: 3.4.4
|
||||
|
||||
cross-env@7.0.3:
|
||||
dependencies:
|
||||
|
|
Loading…
Reference in a new issue