mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-26 21:52:49 +03:00
feat(core): Cancel runner task on timeout in external mode (#12101)
This commit is contained in:
parent
a63f0e878e
commit
addb4fa352
@ -12,6 +12,7 @@
|
|||||||
"N8N_RUNNERS_TASK_BROKER_URI",
|
"N8N_RUNNERS_TASK_BROKER_URI",
|
||||||
"N8N_RUNNERS_MAX_PAYLOAD",
|
"N8N_RUNNERS_MAX_PAYLOAD",
|
||||||
"N8N_RUNNERS_MAX_CONCURRENCY",
|
"N8N_RUNNERS_MAX_CONCURRENCY",
|
||||||
|
"N8N_RUNNERS_TASK_TIMEOUT",
|
||||||
"N8N_RUNNERS_HEALTH_CHECK_SERVER_ENABLED",
|
"N8N_RUNNERS_HEALTH_CHECK_SERVER_ENABLED",
|
||||||
"N8N_RUNNERS_HEALTH_CHECK_SERVER_HOST",
|
"N8N_RUNNERS_HEALTH_CHECK_SERVER_HOST",
|
||||||
"N8N_RUNNERS_HEALTH_CHECK_SERVER_PORT",
|
"N8N_RUNNERS_HEALTH_CHECK_SERVER_PORT",
|
||||||
|
@ -43,11 +43,11 @@ export class TaskRunnersConfig {
|
|||||||
@Env('N8N_RUNNERS_MAX_CONCURRENCY')
|
@Env('N8N_RUNNERS_MAX_CONCURRENCY')
|
||||||
maxConcurrency: number = 5;
|
maxConcurrency: number = 5;
|
||||||
|
|
||||||
/** How long (in seconds) a task is allowed to take for completion, else the task will be aborted and the runner restarted. Must be greater than 0. */
|
/** How long (in seconds) a task is allowed to take for completion, else the task will be aborted. (In internal mode, the runner will also be restarted.) Must be greater than 0. */
|
||||||
@Env('N8N_RUNNERS_TASK_TIMEOUT')
|
@Env('N8N_RUNNERS_TASK_TIMEOUT')
|
||||||
taskTimeout: number = 60;
|
taskTimeout: number = 60;
|
||||||
|
|
||||||
/** How often (in seconds) the runner must send a heartbeat to the broker, else the task will be aborted and the runner restarted. Must be greater than 0. */
|
/** How often (in seconds) the runner must send a heartbeat to the broker, else the task will be aborted. (In internal mode, the runner will also be restarted.) Must be greater than 0. */
|
||||||
@Env('N8N_RUNNERS_HEARTBEAT_INTERVAL')
|
@Env('N8N_RUNNERS_HEARTBEAT_INTERVAL')
|
||||||
heartbeatInterval: number = 30;
|
heartbeatInterval: number = 30;
|
||||||
}
|
}
|
||||||
|
@ -37,6 +37,9 @@ export class BaseRunnerConfig {
|
|||||||
@Env('GENERIC_TIMEZONE')
|
@Env('GENERIC_TIMEZONE')
|
||||||
timezone: string = 'America/New_York';
|
timezone: string = 'America/New_York';
|
||||||
|
|
||||||
|
@Env('N8N_RUNNERS_TASK_TIMEOUT')
|
||||||
|
taskTimeout: number = 60;
|
||||||
|
|
||||||
@Nested
|
@Nested
|
||||||
healthcheckServer!: HealthcheckServerConfig;
|
healthcheckServer!: HealthcheckServerConfig;
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { mock } from 'jest-mock-extended';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { setGlobalState, type CodeExecutionMode, type IDataObject } from 'n8n-workflow';
|
import { setGlobalState, type CodeExecutionMode, type IDataObject } from 'n8n-workflow';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
@ -61,7 +62,7 @@ describe('JsTaskRunner', () => {
|
|||||||
runner?: JsTaskRunner;
|
runner?: JsTaskRunner;
|
||||||
}) => {
|
}) => {
|
||||||
jest.spyOn(runner, 'requestData').mockResolvedValue(taskData);
|
jest.spyOn(runner, 'requestData').mockResolvedValue(taskData);
|
||||||
return await runner.executeTask(task);
|
return await runner.executeTask(task, mock<AbortSignal>());
|
||||||
};
|
};
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
@ -21,6 +21,7 @@ describe('TestRunner', () => {
|
|||||||
maxPayloadSize: 1024,
|
maxPayloadSize: 1024,
|
||||||
taskBrokerUri: 'http://localhost:8080',
|
taskBrokerUri: 'http://localhost:8080',
|
||||||
timezone: 'America/New_York',
|
timezone: 'America/New_York',
|
||||||
|
taskTimeout: 60,
|
||||||
healthcheckServer: {
|
healthcheckServer: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
host: 'localhost',
|
host: 'localhost',
|
||||||
@ -48,6 +49,7 @@ describe('TestRunner', () => {
|
|||||||
maxPayloadSize: 1024,
|
maxPayloadSize: 1024,
|
||||||
taskBrokerUri: 'https://example.com:3000/path',
|
taskBrokerUri: 'https://example.com:3000/path',
|
||||||
timezone: 'America/New_York',
|
timezone: 'America/New_York',
|
||||||
|
taskTimeout: 60,
|
||||||
healthcheckServer: {
|
healthcheckServer: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
host: 'localhost',
|
host: 'localhost',
|
||||||
@ -77,6 +79,7 @@ describe('TestRunner', () => {
|
|||||||
maxPayloadSize: 1024,
|
maxPayloadSize: 1024,
|
||||||
taskBrokerUri: 'not-a-valid-uri',
|
taskBrokerUri: 'not-a-valid-uri',
|
||||||
timezone: 'America/New_York',
|
timezone: 'America/New_York',
|
||||||
|
taskTimeout: 60,
|
||||||
healthcheckServer: {
|
healthcheckServer: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
host: 'localhost',
|
host: 'localhost',
|
||||||
@ -86,4 +89,65 @@ describe('TestRunner', () => {
|
|||||||
).toThrowError(/Invalid URL/);
|
).toThrowError(/Invalid URL/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('taskCancelled', () => {
|
||||||
|
it('should reject pending requests when task is cancelled', () => {
|
||||||
|
const runner = new TestRunner({
|
||||||
|
taskType: 'test-task',
|
||||||
|
maxConcurrency: 5,
|
||||||
|
idleTimeout: 60,
|
||||||
|
grantToken: 'test-token',
|
||||||
|
maxPayloadSize: 1024,
|
||||||
|
taskBrokerUri: 'http://localhost:8080',
|
||||||
|
timezone: 'America/New_York',
|
||||||
|
taskTimeout: 60,
|
||||||
|
healthcheckServer: {
|
||||||
|
enabled: false,
|
||||||
|
host: 'localhost',
|
||||||
|
port: 8081,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const taskId = 'test-task';
|
||||||
|
runner.runningTasks.set(taskId, {
|
||||||
|
taskId,
|
||||||
|
active: false,
|
||||||
|
cancelled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const dataRequestReject = jest.fn();
|
||||||
|
const nodeTypesRequestReject = jest.fn();
|
||||||
|
|
||||||
|
runner.dataRequests.set('data-req', {
|
||||||
|
taskId,
|
||||||
|
requestId: 'data-req',
|
||||||
|
resolve: jest.fn(),
|
||||||
|
reject: dataRequestReject,
|
||||||
|
});
|
||||||
|
|
||||||
|
runner.nodeTypesRequests.set('node-req', {
|
||||||
|
taskId,
|
||||||
|
requestId: 'node-req',
|
||||||
|
resolve: jest.fn(),
|
||||||
|
reject: nodeTypesRequestReject,
|
||||||
|
});
|
||||||
|
|
||||||
|
runner.taskCancelled(taskId, 'test-reason');
|
||||||
|
|
||||||
|
expect(dataRequestReject).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
message: 'Task cancelled: test-reason',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(nodeTypesRequestReject).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
message: 'Task cancelled: test-reason',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(runner.dataRequests.size).toBe(0);
|
||||||
|
expect(runner.nodeTypesRequests.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -0,0 +1,7 @@
|
|||||||
|
import { ApplicationError } from 'n8n-workflow';
|
||||||
|
|
||||||
|
export class TaskCancelledError extends ApplicationError {
|
||||||
|
constructor(reason: string) {
|
||||||
|
super(`Task cancelled: ${reason}`, { level: 'warning' });
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,30 @@
|
|||||||
|
import { ApplicationError } from 'n8n-workflow';
|
||||||
|
|
||||||
|
export class TimeoutError extends ApplicationError {
|
||||||
|
description: string;
|
||||||
|
|
||||||
|
constructor(taskTimeout: number) {
|
||||||
|
super(
|
||||||
|
`Task execution timed out after ${taskTimeout} ${taskTimeout === 1 ? 'second' : 'seconds'}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const subtitle = 'The task runner was taking too long on this task, so the task was aborted.';
|
||||||
|
|
||||||
|
const fixes = {
|
||||||
|
optimizeScript:
|
||||||
|
'Optimize your script to prevent long-running tasks, e.g. by processing data in smaller batches.',
|
||||||
|
ensureTermination:
|
||||||
|
'Ensure that all paths in your script are able to terminate, i.e. no infinite loops.',
|
||||||
|
};
|
||||||
|
|
||||||
|
const suggestions = [fixes.optimizeScript, fixes.ensureTermination];
|
||||||
|
|
||||||
|
const suggestionsText = suggestions
|
||||||
|
.map((suggestion, index) => `${index + 1}. ${suggestion}`)
|
||||||
|
.join('<br/>');
|
||||||
|
|
||||||
|
const description = `${subtitle} You can try the following:<br/><br/>${suggestionsText}`;
|
||||||
|
|
||||||
|
this.description = description;
|
||||||
|
}
|
||||||
|
}
|
@ -32,6 +32,7 @@ import { BuiltInsParserState } from './built-ins-parser/built-ins-parser-state';
|
|||||||
import { isErrorLike } from './errors/error-like';
|
import { isErrorLike } from './errors/error-like';
|
||||||
import { ExecutionError } from './errors/execution-error';
|
import { ExecutionError } from './errors/execution-error';
|
||||||
import { makeSerializable } from './errors/serializable-error';
|
import { makeSerializable } from './errors/serializable-error';
|
||||||
|
import { TimeoutError } from './errors/timeout-error';
|
||||||
import type { RequireResolver } from './require-resolver';
|
import type { RequireResolver } from './require-resolver';
|
||||||
import { createRequireResolver } from './require-resolver';
|
import { createRequireResolver } from './require-resolver';
|
||||||
import { validateRunForAllItemsOutput, validateRunForEachItemOutput } from './result-validation';
|
import { validateRunForAllItemsOutput, validateRunForEachItemOutput } from './result-validation';
|
||||||
@ -94,7 +95,7 @@ export class JsTaskRunner extends TaskRunner {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async executeTask(task: Task<JSExecSettings>): Promise<TaskResultData> {
|
async executeTask(task: Task<JSExecSettings>, signal: AbortSignal): Promise<TaskResultData> {
|
||||||
const settings = task.settings;
|
const settings = task.settings;
|
||||||
a.ok(settings, 'JS Code not sent to runner');
|
a.ok(settings, 'JS Code not sent to runner');
|
||||||
|
|
||||||
@ -133,8 +134,8 @@ export class JsTaskRunner extends TaskRunner {
|
|||||||
|
|
||||||
const result =
|
const result =
|
||||||
settings.nodeMode === 'runOnceForAllItems'
|
settings.nodeMode === 'runOnceForAllItems'
|
||||||
? await this.runForAllItems(task.taskId, settings, data, workflow, customConsole)
|
? await this.runForAllItems(task.taskId, settings, data, workflow, customConsole, signal)
|
||||||
: await this.runForEachItem(task.taskId, settings, data, workflow, customConsole);
|
: await this.runForEachItem(task.taskId, settings, data, workflow, customConsole, signal);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
result,
|
result,
|
||||||
@ -183,6 +184,7 @@ export class JsTaskRunner extends TaskRunner {
|
|||||||
data: JsTaskData,
|
data: JsTaskData,
|
||||||
workflow: Workflow,
|
workflow: Workflow,
|
||||||
customConsole: CustomConsole,
|
customConsole: CustomConsole,
|
||||||
|
signal: AbortSignal,
|
||||||
): Promise<INodeExecutionData[]> {
|
): Promise<INodeExecutionData[]> {
|
||||||
const dataProxy = this.createDataProxy(data, workflow, data.itemIndex);
|
const dataProxy = this.createDataProxy(data, workflow, data.itemIndex);
|
||||||
const inputItems = data.connectionInputData;
|
const inputItems = data.connectionInputData;
|
||||||
@ -199,10 +201,26 @@ export class JsTaskRunner extends TaskRunner {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = (await runInNewContext(
|
const result = await new Promise<TaskResultData['result']>((resolve, reject) => {
|
||||||
`globalThis.global = globalThis; module.exports = async function VmCodeWrapper() {${settings.code}\n}()`,
|
const abortHandler = () => {
|
||||||
context,
|
reject(new TimeoutError(this.taskTimeout));
|
||||||
)) as TaskResultData['result'];
|
};
|
||||||
|
|
||||||
|
signal.addEventListener('abort', abortHandler, { once: true });
|
||||||
|
|
||||||
|
const taskResult = runInNewContext(
|
||||||
|
`globalThis.global = globalThis; module.exports = async function VmCodeWrapper() {${settings.code}\n}()`,
|
||||||
|
context,
|
||||||
|
{ timeout: this.taskTimeout * 1000 },
|
||||||
|
) as Promise<TaskResultData['result']>;
|
||||||
|
|
||||||
|
void taskResult
|
||||||
|
.then(resolve)
|
||||||
|
.catch(reject)
|
||||||
|
.finally(() => {
|
||||||
|
signal.removeEventListener('abort', abortHandler);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
if (result === null) {
|
if (result === null) {
|
||||||
return [];
|
return [];
|
||||||
@ -230,6 +248,7 @@ export class JsTaskRunner extends TaskRunner {
|
|||||||
data: JsTaskData,
|
data: JsTaskData,
|
||||||
workflow: Workflow,
|
workflow: Workflow,
|
||||||
customConsole: CustomConsole,
|
customConsole: CustomConsole,
|
||||||
|
signal: AbortSignal,
|
||||||
): Promise<INodeExecutionData[]> {
|
): Promise<INodeExecutionData[]> {
|
||||||
const inputItems = data.connectionInputData;
|
const inputItems = data.connectionInputData;
|
||||||
const returnData: INodeExecutionData[] = [];
|
const returnData: INodeExecutionData[] = [];
|
||||||
@ -255,10 +274,26 @@ export class JsTaskRunner extends TaskRunner {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let result = (await runInNewContext(
|
let result = await new Promise<INodeExecutionData | undefined>((resolve, reject) => {
|
||||||
`module.exports = async function VmCodeWrapper() {${settings.code}\n}()`,
|
const abortHandler = () => {
|
||||||
context,
|
reject(new TimeoutError(this.taskTimeout));
|
||||||
)) as INodeExecutionData | undefined;
|
};
|
||||||
|
|
||||||
|
signal.addEventListener('abort', abortHandler);
|
||||||
|
|
||||||
|
const taskResult = runInNewContext(
|
||||||
|
`module.exports = async function VmCodeWrapper() {${settings.code}\n}()`,
|
||||||
|
context,
|
||||||
|
{ timeout: this.taskTimeout * 1000 },
|
||||||
|
) as Promise<INodeExecutionData>;
|
||||||
|
|
||||||
|
void taskResult
|
||||||
|
.then(resolve)
|
||||||
|
.catch(reject)
|
||||||
|
.finally(() => {
|
||||||
|
signal.removeEventListener('abort', abortHandler);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Filter out null values
|
// Filter out null values
|
||||||
if (result === null) {
|
if (result === null) {
|
||||||
|
@ -8,6 +8,8 @@ import type { BrokerMessage, RunnerMessage } from '@/message-types';
|
|||||||
import { TaskRunnerNodeTypes } from '@/node-types';
|
import { TaskRunnerNodeTypes } from '@/node-types';
|
||||||
import { RPC_ALLOW_LIST, type TaskResultData } from '@/runner-types';
|
import { RPC_ALLOW_LIST, type TaskResultData } from '@/runner-types';
|
||||||
|
|
||||||
|
import { TaskCancelledError } from './js-task-runner/errors/task-cancelled-error';
|
||||||
|
|
||||||
export interface Task<T = unknown> {
|
export interface Task<T = unknown> {
|
||||||
taskId: string;
|
taskId: string;
|
||||||
settings?: T;
|
settings?: T;
|
||||||
@ -21,12 +23,14 @@ export interface TaskOffer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface DataRequest {
|
interface DataRequest {
|
||||||
|
taskId: string;
|
||||||
requestId: string;
|
requestId: string;
|
||||||
resolve: (data: unknown) => void;
|
resolve: (data: unknown) => void;
|
||||||
reject: (error: unknown) => void;
|
reject: (error: unknown) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NodeTypesRequest {
|
interface NodeTypesRequest {
|
||||||
|
taskId: string;
|
||||||
requestId: string;
|
requestId: string;
|
||||||
resolve: (data: unknown) => void;
|
resolve: (data: unknown) => void;
|
||||||
reject: (error: unknown) => void;
|
reject: (error: unknown) => void;
|
||||||
@ -82,14 +86,20 @@ export abstract class TaskRunner extends EventEmitter {
|
|||||||
|
|
||||||
private idleTimer: NodeJS.Timeout | undefined;
|
private idleTimer: NodeJS.Timeout | undefined;
|
||||||
|
|
||||||
|
/** How long (in seconds) a task is allowed to take for completion, else the task will be aborted. */
|
||||||
|
protected readonly taskTimeout: number;
|
||||||
|
|
||||||
/** How long (in seconds) a runner may be idle for before exit. */
|
/** How long (in seconds) a runner may be idle for before exit. */
|
||||||
private readonly idleTimeout: number;
|
private readonly idleTimeout: number;
|
||||||
|
|
||||||
|
protected taskCancellations = new Map<Task['taskId'], AbortController>();
|
||||||
|
|
||||||
constructor(opts: TaskRunnerOpts) {
|
constructor(opts: TaskRunnerOpts) {
|
||||||
super();
|
super();
|
||||||
this.taskType = opts.taskType;
|
this.taskType = opts.taskType;
|
||||||
this.name = opts.name ?? 'Node.js Task Runner SDK';
|
this.name = opts.name ?? 'Node.js Task Runner SDK';
|
||||||
this.maxConcurrency = opts.maxConcurrency;
|
this.maxConcurrency = opts.maxConcurrency;
|
||||||
|
this.taskTimeout = opts.taskTimeout;
|
||||||
this.idleTimeout = opts.idleTimeout;
|
this.idleTimeout = opts.idleTimeout;
|
||||||
|
|
||||||
const { host: taskBrokerHost } = new URL(opts.taskBrokerUri);
|
const { host: taskBrokerHost } = new URL(opts.taskBrokerUri);
|
||||||
@ -210,7 +220,7 @@ export abstract class TaskRunner extends EventEmitter {
|
|||||||
this.offerAccepted(message.offerId, message.taskId);
|
this.offerAccepted(message.offerId, message.taskId);
|
||||||
break;
|
break;
|
||||||
case 'broker:taskcancel':
|
case 'broker:taskcancel':
|
||||||
this.taskCancelled(message.taskId);
|
this.taskCancelled(message.taskId, message.reason);
|
||||||
break;
|
break;
|
||||||
case 'broker:tasksettings':
|
case 'broker:tasksettings':
|
||||||
void this.receivedSettings(message.taskId, message.settings);
|
void this.receivedSettings(message.taskId, message.settings);
|
||||||
@ -285,17 +295,35 @@ export abstract class TaskRunner extends EventEmitter {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
taskCancelled(taskId: string) {
|
taskCancelled(taskId: string, reason: string) {
|
||||||
const task = this.runningTasks.get(taskId);
|
const task = this.runningTasks.get(taskId);
|
||||||
if (!task) {
|
if (!task) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
task.cancelled = true;
|
task.cancelled = true;
|
||||||
if (task.active) {
|
|
||||||
// TODO
|
for (const [requestId, request] of this.dataRequests.entries()) {
|
||||||
} else {
|
if (request.taskId === taskId) {
|
||||||
this.runningTasks.delete(taskId);
|
request.reject(new TaskCancelledError(reason));
|
||||||
|
this.dataRequests.delete(requestId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const [requestId, request] of this.nodeTypesRequests.entries()) {
|
||||||
|
if (request.taskId === taskId) {
|
||||||
|
request.reject(new TaskCancelledError(reason));
|
||||||
|
this.nodeTypesRequests.delete(requestId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = this.taskCancellations.get(taskId);
|
||||||
|
if (controller) {
|
||||||
|
controller.abort();
|
||||||
|
this.taskCancellations.delete(taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!task.active) this.runningTasks.delete(taskId);
|
||||||
|
|
||||||
this.sendOffers();
|
this.sendOffers();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -328,20 +356,33 @@ export abstract class TaskRunner extends EventEmitter {
|
|||||||
this.runningTasks.delete(taskId);
|
this.runningTasks.delete(taskId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
this.taskCancellations.set(taskId, controller);
|
||||||
|
|
||||||
|
const taskTimeout = setTimeout(() => {
|
||||||
|
if (!task.cancelled) {
|
||||||
|
controller.abort();
|
||||||
|
this.taskCancellations.delete(taskId);
|
||||||
|
}
|
||||||
|
}, this.taskTimeout * 1_000);
|
||||||
|
|
||||||
task.settings = settings;
|
task.settings = settings;
|
||||||
task.active = true;
|
task.active = true;
|
||||||
try {
|
try {
|
||||||
const data = await this.executeTask(task);
|
const data = await this.executeTask(task, controller.signal);
|
||||||
this.taskDone(taskId, data);
|
this.taskDone(taskId, data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.taskErrored(taskId, error);
|
if (!task.cancelled) this.taskErrored(taskId, error);
|
||||||
} finally {
|
} finally {
|
||||||
|
clearTimeout(taskTimeout);
|
||||||
|
this.taskCancellations.delete(taskId);
|
||||||
this.resetIdleTimer();
|
this.resetIdleTimer();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
async executeTask(_task: Task): Promise<TaskResultData> {
|
async executeTask(_task: Task, _signal: AbortSignal): Promise<TaskResultData> {
|
||||||
throw new ApplicationError('Unimplemented');
|
throw new ApplicationError('Unimplemented');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -354,6 +395,7 @@ export abstract class TaskRunner extends EventEmitter {
|
|||||||
const nodeTypesPromise = new Promise<T>((resolve, reject) => {
|
const nodeTypesPromise = new Promise<T>((resolve, reject) => {
|
||||||
this.nodeTypesRequests.set(requestId, {
|
this.nodeTypesRequests.set(requestId, {
|
||||||
requestId,
|
requestId,
|
||||||
|
taskId,
|
||||||
resolve: resolve as (data: unknown) => void,
|
resolve: resolve as (data: unknown) => void,
|
||||||
reject,
|
reject,
|
||||||
});
|
});
|
||||||
@ -382,6 +424,7 @@ export abstract class TaskRunner extends EventEmitter {
|
|||||||
const p = new Promise<T>((resolve, reject) => {
|
const p = new Promise<T>((resolve, reject) => {
|
||||||
this.dataRequests.set(requestId, {
|
this.dataRequests.set(requestId, {
|
||||||
requestId,
|
requestId,
|
||||||
|
taskId,
|
||||||
resolve: resolve as (data: unknown) => void,
|
resolve: resolve as (data: unknown) => void,
|
||||||
reject,
|
reject,
|
||||||
});
|
});
|
||||||
|
@ -6,6 +6,7 @@ import { ApplicationError, type INodeTypeBaseDescription } from 'n8n-workflow';
|
|||||||
import { Time } from '@/constants';
|
import { Time } from '@/constants';
|
||||||
|
|
||||||
import { TaskRejectError } from '../errors';
|
import { TaskRejectError } from '../errors';
|
||||||
|
import { TaskRunnerTimeoutError } from '../errors/task-runner-timeout.error';
|
||||||
import type { RunnerLifecycleEvents } from '../runner-lifecycle-events';
|
import type { RunnerLifecycleEvents } from '../runner-lifecycle-events';
|
||||||
import { TaskBroker } from '../task-broker.service';
|
import { TaskBroker } from '../task-broker.service';
|
||||||
import type { TaskOffer, TaskRequest, TaskRunner } from '../task-broker.service';
|
import type { TaskOffer, TaskRequest, TaskRunner } from '../task-broker.service';
|
||||||
@ -721,7 +722,7 @@ describe('TaskBroker', () => {
|
|||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
jest.useFakeTimers();
|
jest.useFakeTimers();
|
||||||
config = mock<TaskRunnersConfig>({ taskTimeout: 30 });
|
config = mock<TaskRunnersConfig>({ taskTimeout: 30, mode: 'internal' });
|
||||||
taskBroker = new TaskBroker(mock(), config, runnerLifecycleEvents);
|
taskBroker = new TaskBroker(mock(), config, runnerLifecycleEvents);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -800,7 +801,7 @@ describe('TaskBroker', () => {
|
|||||||
expect(taskBroker.getTasks().get(taskId)).toBeUndefined();
|
expect(taskBroker.getTasks().get(taskId)).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('on timeout, we should emit `runner:timed-out-during-task` event and send error to requester', async () => {
|
it('[internal mode] on timeout, we should emit `runner:timed-out-during-task` event and send error to requester', async () => {
|
||||||
jest.spyOn(global, 'clearTimeout');
|
jest.spyOn(global, 'clearTimeout');
|
||||||
|
|
||||||
const taskId = 'task1';
|
const taskId = 'task1';
|
||||||
@ -839,5 +840,50 @@ describe('TaskBroker', () => {
|
|||||||
|
|
||||||
expect(taskBroker.getTasks().get(taskId)).toBeUndefined();
|
expect(taskBroker.getTasks().get(taskId)).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('[external mode] on timeout, we should instruct the runner to cancel and send error to requester', async () => {
|
||||||
|
const config = mock<TaskRunnersConfig>({ taskTimeout: 30, mode: 'external' });
|
||||||
|
taskBroker = new TaskBroker(mock(), config, runnerLifecycleEvents);
|
||||||
|
|
||||||
|
jest.spyOn(global, 'clearTimeout');
|
||||||
|
|
||||||
|
const taskId = 'task1';
|
||||||
|
const runnerId = 'runner1';
|
||||||
|
const requesterId = 'requester1';
|
||||||
|
const runner = mock<TaskRunner>({ id: runnerId });
|
||||||
|
const runnerCallback = jest.fn();
|
||||||
|
const requesterCallback = jest.fn();
|
||||||
|
|
||||||
|
taskBroker.registerRunner(runner, runnerCallback);
|
||||||
|
taskBroker.registerRequester(requesterId, requesterCallback);
|
||||||
|
|
||||||
|
taskBroker.setTasks({
|
||||||
|
[taskId]: { id: taskId, runnerId, requesterId, taskType: 'test' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await taskBroker.sendTaskSettings(taskId, {});
|
||||||
|
runnerCallback.mockClear();
|
||||||
|
|
||||||
|
jest.runAllTimers();
|
||||||
|
|
||||||
|
await Promise.resolve(); // for timeout callback
|
||||||
|
await Promise.resolve(); // for sending messages to runner and requester
|
||||||
|
await Promise.resolve(); // for task cleanup and removal
|
||||||
|
|
||||||
|
expect(runnerCallback).toHaveBeenLastCalledWith({
|
||||||
|
type: 'broker:taskcancel',
|
||||||
|
taskId,
|
||||||
|
reason: 'Task execution timed out',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(requesterCallback).toHaveBeenCalledWith({
|
||||||
|
type: 'broker:taskerror',
|
||||||
|
taskId,
|
||||||
|
error: expect.any(TaskRunnerTimeoutError),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(clearTimeout).toHaveBeenCalled();
|
||||||
|
expect(taskBroker.getTasks().get(taskId)).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,15 +1,23 @@
|
|||||||
|
import type { TaskRunnerMode } from '@n8n/config/src/configs/runners.config';
|
||||||
import { ApplicationError } from 'n8n-workflow';
|
import { ApplicationError } from 'n8n-workflow';
|
||||||
|
|
||||||
export class TaskRunnerTimeoutError extends ApplicationError {
|
export class TaskRunnerTimeoutError extends ApplicationError {
|
||||||
description: string;
|
description: string;
|
||||||
|
|
||||||
constructor(taskTimeout: number, isSelfHosted: boolean) {
|
constructor({
|
||||||
|
taskTimeout,
|
||||||
|
isSelfHosted,
|
||||||
|
mode,
|
||||||
|
}: { taskTimeout: number; isSelfHosted: boolean; mode: TaskRunnerMode }) {
|
||||||
super(
|
super(
|
||||||
`Task execution timed out after ${taskTimeout} ${taskTimeout === 1 ? 'second' : 'seconds'}`,
|
`Task execution timed out after ${taskTimeout} ${taskTimeout === 1 ? 'second' : 'seconds'}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const subtitle =
|
const subtitles = {
|
||||||
'The task runner was taking too long on this task, so it was suspected of being unresponsive and restarted, and the task was aborted. You can try the following:';
|
internal:
|
||||||
|
'The task runner was taking too long on this task, so it was suspected of being unresponsive and restarted, and the task was aborted.',
|
||||||
|
external: 'The task runner was taking too long on this task, so the task was aborted.',
|
||||||
|
};
|
||||||
|
|
||||||
const fixes = {
|
const fixes = {
|
||||||
optimizeScript:
|
optimizeScript:
|
||||||
@ -27,7 +35,7 @@ export class TaskRunnerTimeoutError extends ApplicationError {
|
|||||||
.map((suggestion, index) => `${index + 1}. ${suggestion}`)
|
.map((suggestion, index) => `${index + 1}. ${suggestion}`)
|
||||||
.join('<br/>');
|
.join('<br/>');
|
||||||
|
|
||||||
const description = `${subtitle}<br/><br/>${suggestionsText}`;
|
const description = `${mode === 'internal' ? subtitles.internal : subtitles.external} You can try the following:<br/><br/>${suggestionsText}`;
|
||||||
|
|
||||||
this.description = description;
|
this.description = description;
|
||||||
}
|
}
|
||||||
|
@ -459,14 +459,25 @@ export class TaskBroker {
|
|||||||
const task = this.tasks.get(taskId);
|
const task = this.tasks.get(taskId);
|
||||||
if (!task) return;
|
if (!task) return;
|
||||||
|
|
||||||
this.runnerLifecycleEvents.emit('runner:timed-out-during-task');
|
if (this.taskRunnersConfig.mode === 'internal') {
|
||||||
|
this.runnerLifecycleEvents.emit('runner:timed-out-during-task');
|
||||||
|
} else if (this.taskRunnersConfig.mode === 'external') {
|
||||||
|
await this.messageRunner(task.runnerId, {
|
||||||
|
type: 'broker:taskcancel',
|
||||||
|
taskId,
|
||||||
|
reason: 'Task execution timed out',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { taskTimeout, mode } = this.taskRunnersConfig;
|
||||||
|
|
||||||
await this.taskErrorHandler(
|
await this.taskErrorHandler(
|
||||||
taskId,
|
taskId,
|
||||||
new TaskRunnerTimeoutError(
|
new TaskRunnerTimeoutError({
|
||||||
this.taskRunnersConfig.taskTimeout,
|
taskTimeout,
|
||||||
config.getEnv('deployment.type') !== 'cloud',
|
isSelfHosted: config.getEnv('deployment.type') !== 'cloud',
|
||||||
),
|
mode,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user