mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
feat(core): Increase Cron observability (#17626)
This commit is contained in:
@@ -19,10 +19,22 @@ export const LOG_SCOPES = [
|
||||
'insights',
|
||||
'workflow-activation',
|
||||
'ssh-client',
|
||||
'cron',
|
||||
] as const;
|
||||
|
||||
export type LogScope = (typeof LOG_SCOPES)[number];
|
||||
|
||||
@Config
|
||||
export class CronLoggingConfig {
|
||||
/**
|
||||
* Interval in minutes to log currently active cron jobs. Set to `0` to disable.
|
||||
*
|
||||
* @example `N8N_LOG_CRON_ACTIVE_INTERVAL=30` will log active crons every 30 minutes.
|
||||
*/
|
||||
@Env('N8N_LOG_CRON_ACTIVE_INTERVAL')
|
||||
activeInterval: number = 0;
|
||||
}
|
||||
|
||||
@Config
|
||||
class FileLoggingConfig {
|
||||
/**
|
||||
@@ -79,6 +91,9 @@ export class LoggingConfig {
|
||||
@Nested
|
||||
file: FileLoggingConfig;
|
||||
|
||||
@Nested
|
||||
cron: CronLoggingConfig;
|
||||
|
||||
/**
|
||||
* Scopes to filter logs by. Nothing is filtered by default.
|
||||
*
|
||||
|
||||
@@ -51,6 +51,7 @@ export { MfaConfig } from './configs/mfa.config';
|
||||
export { HiringBannerConfig } from './configs/hiring-banner.config';
|
||||
export { PersonalizationConfig } from './configs/personalization.config';
|
||||
export { NodesConfig } from './configs/nodes.config';
|
||||
export { CronLoggingConfig } from './configs/logging.config';
|
||||
|
||||
const protocolSchema = z.enum(['http', 'https']);
|
||||
|
||||
|
||||
@@ -275,6 +275,9 @@ describe('GlobalConfig', () => {
|
||||
location: 'logs/n8n.log',
|
||||
},
|
||||
scopes: [],
|
||||
cron: {
|
||||
activeInterval: 0,
|
||||
},
|
||||
},
|
||||
multiMainSetup: {
|
||||
enabled: false,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { Workflow } from 'n8n-workflow';
|
||||
|
||||
@@ -5,6 +6,8 @@ import type { InstanceSettings } from '@/instance-settings';
|
||||
|
||||
import { ScheduledTaskManager } from '../scheduled-task-manager';
|
||||
|
||||
const logger = mock<Logger>({ scoped: jest.fn().mockReturnValue(mock<Logger>()) });
|
||||
|
||||
describe('ScheduledTaskManager', () => {
|
||||
const instanceSettings = mock<InstanceSettings>({ isLeader: true });
|
||||
const workflow = mock<Workflow>({ timezone: 'GMT' });
|
||||
@@ -16,14 +19,14 @@ describe('ScheduledTaskManager', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.useFakeTimers();
|
||||
scheduledTaskManager = new ScheduledTaskManager(instanceSettings);
|
||||
scheduledTaskManager = new ScheduledTaskManager(instanceSettings, logger, mock());
|
||||
});
|
||||
|
||||
it('should throw when workflow timezone is invalid', () => {
|
||||
expect(() =>
|
||||
scheduledTaskManager.registerCron(
|
||||
mock<Workflow>({ timezone: 'somewhere' }),
|
||||
everyMinute,
|
||||
{ expression: everyMinute },
|
||||
onTick,
|
||||
),
|
||||
).toThrow('Invalid timezone.');
|
||||
@@ -36,17 +39,21 @@ describe('ScheduledTaskManager', () => {
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('should register valid CronJobs', async () => {
|
||||
scheduledTaskManager.registerCron(workflow, everyMinute, onTick);
|
||||
it('should register valid CronJobs', () => {
|
||||
scheduledTaskManager.registerCron(workflow, { expression: everyMinute }, onTick);
|
||||
|
||||
expect(onTick).not.toHaveBeenCalled();
|
||||
jest.advanceTimersByTime(10 * 60 * 1000); // 10 minutes
|
||||
expect(onTick).toHaveBeenCalledTimes(10);
|
||||
});
|
||||
|
||||
it('should should not invoke on follower instances', async () => {
|
||||
scheduledTaskManager = new ScheduledTaskManager(mock<InstanceSettings>({ isLeader: false }));
|
||||
scheduledTaskManager.registerCron(workflow, everyMinute, onTick);
|
||||
it('should not invoke on follower instances', () => {
|
||||
scheduledTaskManager = new ScheduledTaskManager(
|
||||
mock<InstanceSettings>({ isLeader: false }),
|
||||
logger,
|
||||
mock(),
|
||||
);
|
||||
scheduledTaskManager.registerCron(workflow, { expression: everyMinute }, onTick);
|
||||
|
||||
expect(onTick).not.toHaveBeenCalled();
|
||||
jest.advanceTimersByTime(10 * 60 * 1000); // 10 minutes
|
||||
@@ -54,18 +61,26 @@ describe('ScheduledTaskManager', () => {
|
||||
});
|
||||
|
||||
it('should deregister CronJobs for a workflow', async () => {
|
||||
scheduledTaskManager.registerCron(workflow, everyMinute, onTick);
|
||||
scheduledTaskManager.registerCron(workflow, everyMinute, onTick);
|
||||
scheduledTaskManager.registerCron(workflow, everyMinute, onTick);
|
||||
scheduledTaskManager.registerCron(workflow, { expression: everyMinute }, onTick);
|
||||
scheduledTaskManager.registerCron(workflow, { expression: everyMinute }, onTick);
|
||||
scheduledTaskManager.registerCron(workflow, { expression: everyMinute }, onTick);
|
||||
|
||||
expect(scheduledTaskManager.cronJobs.get(workflow.id)?.length).toBe(3);
|
||||
expect(scheduledTaskManager.cronMap.get(workflow.id)).toHaveLength(3);
|
||||
|
||||
scheduledTaskManager.deregisterCrons(workflow.id);
|
||||
|
||||
expect(scheduledTaskManager.cronJobs.get(workflow.id)?.length).toBe(0);
|
||||
expect(scheduledTaskManager.cronMap.get(workflow.id)).toBeUndefined();
|
||||
|
||||
expect(onTick).not.toHaveBeenCalled();
|
||||
jest.advanceTimersByTime(10 * 60 * 1000); // 10 minutes
|
||||
expect(onTick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not set up log interval when activeInterval is 0', () => {
|
||||
const configWithZeroInterval = mock({ activeInterval: 0 });
|
||||
const manager = new ScheduledTaskManager(instanceSettings, logger, configWithZeroInterval);
|
||||
|
||||
// @ts-expect-error Private property
|
||||
expect(manager.logInterval).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -149,7 +149,7 @@ export class ActiveWorkflows {
|
||||
};
|
||||
|
||||
// Get all the trigger times
|
||||
const cronTimes = (pollTimes.item || []).map(toCronExpression);
|
||||
const cronExpressions = (pollTimes.item || []).map(toCronExpression);
|
||||
// The trigger function to execute when the cron-time got reached
|
||||
const executeTrigger = async (testingTrigger = false) => {
|
||||
this.logger.debug(`Polling trigger initiated for workflow "${workflow.name}"`, {
|
||||
@@ -177,15 +177,15 @@ export class ActiveWorkflows {
|
||||
// Execute the trigger directly to be able to know if it works
|
||||
await executeTrigger(true);
|
||||
|
||||
for (const cronTime of cronTimes) {
|
||||
const cronTimeParts = cronTime.split(' ');
|
||||
for (const expression of cronExpressions) {
|
||||
const cronTimeParts = expression.split(' ');
|
||||
if (cronTimeParts.length > 0 && cronTimeParts[0].includes('*')) {
|
||||
throw new ApplicationError(
|
||||
'The polling interval is too short. It has to be at least a minute.',
|
||||
);
|
||||
}
|
||||
|
||||
this.scheduledTaskManager.registerCron(workflow, cronTime, executeTrigger);
|
||||
this.scheduledTaskManager.registerCron(workflow, { expression }, executeTrigger);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,11 +19,11 @@ describe('getSchedulingFunctions', () => {
|
||||
|
||||
describe('registerCron', () => {
|
||||
it('should invoke scheduledTaskManager.registerCron', () => {
|
||||
schedulingFunctions.registerCron(cronExpression, onTick);
|
||||
schedulingFunctions.registerCron({ expression: cronExpression }, onTick);
|
||||
|
||||
expect(scheduledTaskManager.registerCron).toHaveBeenCalledWith(
|
||||
workflow,
|
||||
cronExpression,
|
||||
{ expression: cronExpression },
|
||||
onTick,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -6,7 +6,6 @@ import { ScheduledTaskManager } from '../../scheduled-task-manager';
|
||||
export const getSchedulingFunctions = (workflow: Workflow): SchedulingFunctions => {
|
||||
const scheduledTaskManager = Container.get(ScheduledTaskManager);
|
||||
return {
|
||||
registerCron: (cronExpression, onTick) =>
|
||||
scheduledTaskManager.registerCron(workflow, cronExpression, onTick),
|
||||
registerCron: (cron, onTick) => scheduledTaskManager.registerCron(workflow, cron, onTick),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,44 +1,115 @@
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { CronLoggingConfig } from '@n8n/config';
|
||||
import { Time } from '@n8n/constants';
|
||||
import { Service } from '@n8n/di';
|
||||
import { CronJob } from 'cron';
|
||||
import type { CronExpression, Workflow } from 'n8n-workflow';
|
||||
import type { Cron, Workflow } from 'n8n-workflow';
|
||||
|
||||
import { InstanceSettings } from '@/instance-settings';
|
||||
|
||||
@Service()
|
||||
export class ScheduledTaskManager {
|
||||
constructor(private readonly instanceSettings: InstanceSettings) {}
|
||||
readonly cronMap = new Map<string, Array<{ job: CronJob; displayableCron: string }>>();
|
||||
|
||||
readonly cronJobs = new Map<string, CronJob[]>();
|
||||
private readonly logInterval?: NodeJS.Timeout;
|
||||
|
||||
constructor(
|
||||
private readonly instanceSettings: InstanceSettings,
|
||||
private readonly logger: Logger,
|
||||
private readonly config: CronLoggingConfig,
|
||||
) {
|
||||
this.logger = this.logger.scoped('cron');
|
||||
|
||||
if (this.config.activeInterval === 0) return;
|
||||
|
||||
this.logInterval = setInterval(
|
||||
() => this.logActiveCrons(),
|
||||
this.config.activeInterval * Time.minutes.toMilliseconds,
|
||||
);
|
||||
}
|
||||
|
||||
private logActiveCrons() {
|
||||
const activeCrons: Record<string, string[]> = {};
|
||||
for (const [workflowId, cronJobs] of this.cronMap) {
|
||||
activeCrons[`workflow-${workflowId}`] = cronJobs.map(
|
||||
({ displayableCron }) => displayableCron,
|
||||
);
|
||||
}
|
||||
|
||||
if (Object.keys(activeCrons).length === 0) return;
|
||||
|
||||
this.logger.debug('Currently active crons', { activeCrons });
|
||||
}
|
||||
|
||||
registerCron(workflow: Workflow, { expression, recurrence }: Cron, onTick: () => void) {
|
||||
const recurrenceStr = recurrence?.activated
|
||||
? `every ${recurrence.intervalSize} ${recurrence.typeInterval}`
|
||||
: undefined;
|
||||
|
||||
const displayableCron = recurrenceStr ? `${expression} (${recurrenceStr})` : expression;
|
||||
|
||||
registerCron(workflow: Workflow, cronExpression: CronExpression, onTick: () => void) {
|
||||
const cronJob = new CronJob(
|
||||
cronExpression,
|
||||
expression,
|
||||
() => {
|
||||
if (this.instanceSettings.isLeader) onTick();
|
||||
if (this.instanceSettings.isLeader) {
|
||||
this.logger.debug('Executing cron for workflow', {
|
||||
workflowId: workflow.id,
|
||||
cron: displayableCron,
|
||||
instanceRole: this.instanceSettings.instanceRole,
|
||||
});
|
||||
onTick();
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
true,
|
||||
workflow.timezone,
|
||||
);
|
||||
const cronJobsForWorkflow = this.cronJobs.get(workflow.id);
|
||||
if (cronJobsForWorkflow) {
|
||||
cronJobsForWorkflow.push(cronJob);
|
||||
|
||||
const workflowCronEntries = this.cronMap.get(workflow.id);
|
||||
const cronEntry = { job: cronJob, displayableCron };
|
||||
|
||||
if (workflowCronEntries) {
|
||||
workflowCronEntries.push(cronEntry);
|
||||
} else {
|
||||
this.cronJobs.set(workflow.id, [cronJob]);
|
||||
this.cronMap.set(workflow.id, [cronEntry]);
|
||||
}
|
||||
|
||||
this.logger.debug('Registered cron for workflow', {
|
||||
workflowId: workflow.id,
|
||||
cron: displayableCron,
|
||||
instanceRole: this.instanceSettings.instanceRole,
|
||||
});
|
||||
}
|
||||
|
||||
deregisterCrons(workflowId: string) {
|
||||
const cronJobs = this.cronJobs.get(workflowId) ?? [];
|
||||
const cronJobs = this.cronMap.get(workflowId) ?? [];
|
||||
|
||||
if (cronJobs.length === 0) return;
|
||||
|
||||
const crons: string[] = [];
|
||||
|
||||
while (cronJobs.length) {
|
||||
const cronJob = cronJobs.pop();
|
||||
if (cronJob) cronJob.stop();
|
||||
const cronEntry = cronJobs.pop();
|
||||
if (cronEntry) {
|
||||
crons.push(cronEntry.displayableCron);
|
||||
cronEntry.job.stop();
|
||||
}
|
||||
}
|
||||
|
||||
this.cronMap.delete(workflowId);
|
||||
|
||||
this.logger.info('Deregistered all crons for workflow', {
|
||||
workflowId,
|
||||
crons,
|
||||
instanceRole: this.instanceSettings.instanceRole,
|
||||
});
|
||||
}
|
||||
|
||||
deregisterAllCrons() {
|
||||
for (const workflowId of Object.keys(this.cronJobs)) {
|
||||
for (const workflowId of this.cronMap.keys()) {
|
||||
this.deregisterCrons(workflowId);
|
||||
}
|
||||
|
||||
if (this.logInterval) clearInterval(this.logInterval);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ export class Cron implements INodeType {
|
||||
};
|
||||
|
||||
// Get all the trigger times
|
||||
const cronTimes = (triggerTimes.item || []).map(toCronExpression);
|
||||
const expressions = (triggerTimes.item || []).map(toCronExpression);
|
||||
|
||||
// The trigger function to execute when the cron-time got reached
|
||||
// or when manually triggered
|
||||
@@ -65,7 +65,7 @@ export class Cron implements INodeType {
|
||||
};
|
||||
|
||||
// Register the cron-jobs
|
||||
cronTimes.forEach((cronTime) => this.helpers.registerCron(cronTime, executeTrigger));
|
||||
expressions.forEach((expression) => this.helpers.registerCron({ expression }, executeTrigger));
|
||||
|
||||
return {
|
||||
manualTriggerFunction: async () => executeTrigger(),
|
||||
|
||||
@@ -451,7 +451,8 @@ export class ScheduleTrigger implements INodeType {
|
||||
if (this.getMode() !== 'manual') {
|
||||
for (const { interval, cronExpression, recurrence } of rules) {
|
||||
try {
|
||||
this.helpers.registerCron(cronExpression, () => executeTrigger(recurrence));
|
||||
const cron = { expression: cronExpression, recurrence };
|
||||
this.helpers.registerCron(cron, () => executeTrigger(recurrence));
|
||||
} catch (error) {
|
||||
if (interval.field === 'cronExpression') {
|
||||
throw new NodeOperationError(this.getNode(), 'Invalid cron expression', {
|
||||
|
||||
@@ -24,6 +24,17 @@ import {
|
||||
type Workflow,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
const logger = mock({
|
||||
scoped: jest.fn().mockReturnValue(
|
||||
mock({
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
type MockDeepPartial<T> = Parameters<typeof mock<T>>[0];
|
||||
|
||||
type TestTriggerNodeOptions = {
|
||||
@@ -70,7 +81,11 @@ export async function testTriggerNode(
|
||||
) as INode;
|
||||
const workflow = mock<Workflow>({ timezone: options.timezone ?? 'Europe/Berlin' });
|
||||
|
||||
const scheduledTaskManager = new ScheduledTaskManager(mock<InstanceSettings>());
|
||||
const scheduledTaskManager = new ScheduledTaskManager(
|
||||
mock<InstanceSettings>(),
|
||||
logger as any,
|
||||
mock(),
|
||||
);
|
||||
const helpers = mock<ITriggerFunctions['helpers']>({
|
||||
createDeferredPromise,
|
||||
returnJsonArray,
|
||||
@@ -130,7 +145,11 @@ export async function testWebhookTriggerNode(
|
||||
) as INode;
|
||||
const workflow = mock<Workflow>({ timezone: options.timezone ?? 'Europe/Berlin' });
|
||||
|
||||
const scheduledTaskManager = new ScheduledTaskManager(mock<InstanceSettings>());
|
||||
const scheduledTaskManager = new ScheduledTaskManager(
|
||||
mock<InstanceSettings>(),
|
||||
logger as any,
|
||||
mock(),
|
||||
);
|
||||
const helpers = mock<ITriggerFunctions['helpers']>({
|
||||
returnJsonArray,
|
||||
registerCron: (cronExpression, onTick) =>
|
||||
|
||||
@@ -843,8 +843,19 @@ type CronUnit = number | '*' | `*/${number}`;
|
||||
export type CronExpression =
|
||||
`${CronUnit} ${CronUnit} ${CronUnit} ${CronUnit} ${CronUnit} ${CronUnit}`;
|
||||
|
||||
type RecurrenceRule =
|
||||
| { activated: false }
|
||||
| {
|
||||
activated: true;
|
||||
index: number;
|
||||
intervalSize: number;
|
||||
typeInterval: 'hours' | 'days' | 'weeks' | 'months';
|
||||
};
|
||||
|
||||
export type Cron = { expression: CronExpression; recurrence?: RecurrenceRule };
|
||||
|
||||
export interface SchedulingFunctions {
|
||||
registerCron(cronExpression: CronExpression, onTick: () => void): void;
|
||||
registerCron(cron: Cron, onTick: () => void): void;
|
||||
}
|
||||
|
||||
export type NodeTypeAndVersion = {
|
||||
|
||||
Reference in New Issue
Block a user