chore: 清理macOS同步产生的重复文件

详细说明:
- 删除了352个带数字后缀的重复文件
- 更新.gitignore防止未来产生此类文件
- 这些文件是由iCloud或其他同步服务冲突产生的
- 不影响项目功能,仅清理冗余文件
This commit is contained in:
Yep_Q
2025-09-08 12:06:01 +08:00
parent 1564396449
commit d6f48d6d14
365 changed files with 2039 additions and 68301 deletions

View File

@@ -1,38 +0,0 @@
import { ApplicationError } from '@n8n/errors';
import { createServer } from 'node:http';
export class HealthCheckServer {
private server = createServer((_, res) => {
res.writeHead(200);
res.end('OK');
});
async start(host: string, port: number) {
return await new Promise<void>((resolve, reject) => {
const portInUseErrorHandler = (error: NodeJS.ErrnoException) => {
if (error.code === 'EADDRINUSE') {
reject(new ApplicationError(`Port ${port} is already in use`));
} else {
reject(error);
}
};
this.server.on('error', portInUseErrorHandler);
this.server.listen(port, host, () => {
this.server.removeListener('error', portInUseErrorHandler);
console.log(`Health check server listening on ${host}, port ${port}`);
resolve();
});
});
}
async stop() {
return await new Promise<void>((resolve, reject) => {
this.server.close((error) => {
if (error) reject(error);
else resolve();
});
});
}
}

View File

@@ -1,4 +0,0 @@
export * from './task-runner';
export * from './runner-types';
export * from './message-types';
export * from './data-request/data-request-response-reconstruct';

View File

@@ -1,258 +0,0 @@
import type { INodeTypeBaseDescription } from 'n8n-workflow';
import type {
NeededNodeType,
AVAILABLE_RPC_METHODS,
TaskDataRequestParams,
TaskResultData,
} from './runner-types';
export namespace BrokerMessage {
export namespace ToRunner {
export interface InfoRequest {
type: 'broker:inforequest';
}
export interface RunnerRegistered {
type: 'broker:runnerregistered';
}
export interface TaskOfferAccept {
type: 'broker:taskofferaccept';
taskId: string;
offerId: string;
}
export interface TaskCancel {
type: 'broker:taskcancel';
taskId: string;
reason: string;
}
export interface TaskSettings {
type: 'broker:tasksettings';
taskId: string;
settings: unknown;
}
export interface RPCResponse {
type: 'broker:rpcresponse';
callId: string;
taskId: string;
status: 'success' | 'error';
data: unknown;
}
export interface TaskDataResponse {
type: 'broker:taskdataresponse';
taskId: string;
requestId: string;
data: unknown;
}
export interface NodeTypes {
type: 'broker:nodetypes';
taskId: string;
requestId: string;
nodeTypes: INodeTypeBaseDescription[];
}
export type All =
| InfoRequest
| TaskOfferAccept
| TaskCancel
| TaskSettings
| RunnerRegistered
| RPCResponse
| TaskDataResponse
| NodeTypes;
}
export namespace ToRequester {
export interface TaskReady {
type: 'broker:taskready';
requestId: string;
taskId: string;
}
export interface TaskDone {
type: 'broker:taskdone';
taskId: string;
data: TaskResultData;
}
export interface TaskError {
type: 'broker:taskerror';
taskId: string;
error: unknown;
}
export interface TaskDataRequest {
type: 'broker:taskdatarequest';
taskId: string;
requestId: string;
requestParams: TaskDataRequestParams;
}
export interface NodeTypesRequest {
type: 'broker:nodetypesrequest';
taskId: string;
requestId: string;
requestParams: NeededNodeType[];
}
export interface RPC {
type: 'broker:rpc';
callId: string;
taskId: string;
name: (typeof AVAILABLE_RPC_METHODS)[number];
params: unknown[];
}
export type All = TaskReady | TaskDone | TaskError | TaskDataRequest | NodeTypesRequest | RPC;
}
}
export namespace RequesterMessage {
export namespace ToBroker {
export interface TaskSettings {
type: 'requester:tasksettings';
taskId: string;
settings: unknown;
}
export interface TaskCancel {
type: 'requester:taskcancel';
taskId: string;
reason: string;
}
export interface TaskDataResponse {
type: 'requester:taskdataresponse';
taskId: string;
requestId: string;
data: unknown;
}
export interface NodeTypesResponse {
type: 'requester:nodetypesresponse';
taskId: string;
requestId: string;
nodeTypes: INodeTypeBaseDescription[];
}
export interface RPCResponse {
type: 'requester:rpcresponse';
taskId: string;
callId: string;
status: 'success' | 'error';
data: unknown;
}
export interface TaskRequest {
type: 'requester:taskrequest';
requestId: string;
taskType: string;
}
export type All =
| TaskSettings
| TaskCancel
| RPCResponse
| TaskDataResponse
| NodeTypesResponse
| TaskRequest;
}
}
export namespace RunnerMessage {
export namespace ToBroker {
export interface Info {
type: 'runner:info';
name: string;
types: string[];
}
export interface TaskAccepted {
type: 'runner:taskaccepted';
taskId: string;
}
export interface TaskRejected {
type: 'runner:taskrejected';
taskId: string;
reason: string;
}
/** Message where launcher (impersonating runner) requests broker to hold task until runner is ready. */
export interface TaskDeferred {
type: 'runner:taskdeferred';
taskId: string;
}
export interface TaskDone {
type: 'runner:taskdone';
taskId: string;
data: TaskResultData;
}
export interface TaskError {
type: 'runner:taskerror';
taskId: string;
error: unknown;
}
export interface TaskOffer {
type: 'runner:taskoffer';
offerId: string;
taskType: string;
validFor: number;
}
export interface TaskDataRequest {
type: 'runner:taskdatarequest';
taskId: string;
requestId: string;
requestParams: TaskDataRequestParams;
}
export interface NodeTypesRequest {
type: 'runner:nodetypesrequest';
taskId: string;
requestId: string;
/**
* Which node types should be included in the runner's node types request.
*
* Node types are needed only when the script relies on paired item functionality.
* If so, we need only the node types not already cached in the runner.
*
* TODO: In future we can trim this down to only node types in the paired item chain,
* rather than assuming we need all node types in the workflow.
*
* @example [{ name: 'n8n-nodes-base.httpRequest', version: 1 }]
*/
requestParams: NeededNodeType[];
}
export interface RPC {
type: 'runner:rpc';
callId: string;
taskId: string;
name: (typeof AVAILABLE_RPC_METHODS)[number];
params: unknown[];
}
export type All =
| Info
| TaskDone
| TaskError
| TaskAccepted
| TaskRejected
| TaskDeferred
| TaskOffer
| RPC
| TaskDataRequest
| NodeTypesRequest;
}
}

View File

@@ -1,92 +0,0 @@
import {
ApplicationError,
type IDataObject,
type INodeType,
type INodeTypeDescription,
type INodeTypes,
type IVersionedNodeType,
} from 'n8n-workflow';
import type { NeededNodeType } from './runner-types';
type VersionedTypes = Map<number, INodeTypeDescription>;
export const DEFAULT_NODETYPE_VERSION = 1;
export class TaskRunnerNodeTypes implements INodeTypes {
private nodeTypesByVersion: Map<string, VersionedTypes>;
constructor(nodeTypes: INodeTypeDescription[]) {
this.nodeTypesByVersion = this.parseNodeTypes(nodeTypes);
}
private parseNodeTypes(nodeTypes: INodeTypeDescription[]): Map<string, VersionedTypes> {
const versionedTypes = new Map<string, VersionedTypes>();
for (const nt of nodeTypes) {
const versions = Array.isArray(nt.version)
? nt.version
: [nt.version ?? DEFAULT_NODETYPE_VERSION];
const versioned: VersionedTypes =
versionedTypes.get(nt.name) ?? new Map<number, INodeTypeDescription>();
for (const version of versions) {
versioned.set(version, { ...versioned.get(version), ...nt });
}
versionedTypes.set(nt.name, versioned);
}
return versionedTypes;
}
// This isn't used in Workflow from what I can see
getByName(_nodeType: string): INodeType | IVersionedNodeType {
throw new ApplicationError('Unimplemented `getByName`', { level: 'error' });
}
getByNameAndVersion(nodeType: string, version?: number): INodeType {
const versions = this.nodeTypesByVersion.get(nodeType);
if (!versions) {
return undefined as unknown as INodeType;
}
const nodeVersion = versions.get(version ?? Math.max(...versions.keys()));
if (!nodeVersion) {
return undefined as unknown as INodeType;
}
return {
description: nodeVersion,
};
}
// This isn't used in Workflow from what I can see
getKnownTypes(): IDataObject {
throw new ApplicationError('Unimplemented `getKnownTypes`', { level: 'error' });
}
addNodeTypeDescriptions(nodeTypeDescriptions: INodeTypeDescription[]) {
const newNodeTypes = this.parseNodeTypes(nodeTypeDescriptions);
for (const [name, newVersions] of newNodeTypes.entries()) {
if (!this.nodeTypesByVersion.has(name)) {
this.nodeTypesByVersion.set(name, newVersions);
} else {
const existingVersions = this.nodeTypesByVersion.get(name)!;
for (const [version, nodeType] of newVersions.entries()) {
existingVersions.set(version, nodeType);
}
}
}
}
/** Filter out node type versions that are already registered. */
onlyUnknown(nodeTypes: NeededNodeType[]) {
return nodeTypes.filter(({ name, version }) => {
const existingVersions = this.nodeTypesByVersion.get(name);
if (!existingVersions) return true;
return !existingVersions.has(version);
});
}
}

View File

@@ -1,175 +0,0 @@
import type {
EnvProviderState,
IDataObject,
IExecuteData,
IExecuteFunctions,
INode,
INodeExecutionData,
INodeParameters,
IRunExecutionData,
ITaskDataConnections,
ITaskDataConnectionsSource,
IWorkflowExecuteAdditionalData,
Workflow,
WorkflowExecuteMode,
WorkflowParameters,
} from 'n8n-workflow';
export interface InputDataChunkDefinition {
startIndex: number;
count: number;
}
export interface InputDataRequestParams {
/** Whether to include the input data in the response */
include: boolean;
/** Optionally request only a specific chunk of data instead of all input data */
chunk?: InputDataChunkDefinition;
}
/**
* Specifies what data should be included for a task data request.
*/
export interface TaskDataRequestParams {
dataOfNodes: string[] | 'all';
prevNode: boolean;
/** Whether input data for the node should be included */
input: InputDataRequestParams;
/** Whether env provider's state should be included */
env: boolean;
}
export interface DataRequestResponse {
workflow: Omit<WorkflowParameters, 'nodeTypes'>;
inputData: ITaskDataConnections;
connectionInputSource: ITaskDataConnectionsSource | null;
node: INode;
runExecutionData: IRunExecutionData;
runIndex: number;
itemIndex: number;
activeNodeName: string;
siblingParameters: INodeParameters;
mode: WorkflowExecuteMode;
envProviderState: EnvProviderState;
defaultReturnRunIndex: number;
selfData: IDataObject;
contextNodeName: string;
additionalData: PartialAdditionalData;
}
export interface TaskResultData {
result: INodeExecutionData[];
customData?: Record<string, string>;
staticData?: IDataObject;
}
export interface TaskData {
executeFunctions: IExecuteFunctions;
inputData: ITaskDataConnections;
node: INode;
workflow: Workflow;
runExecutionData: IRunExecutionData;
runIndex: number;
itemIndex: number;
activeNodeName: string;
connectionInputData: INodeExecutionData[];
siblingParameters: INodeParameters;
mode: WorkflowExecuteMode;
envProviderState: EnvProviderState;
executeData?: IExecuteData;
defaultReturnRunIndex: number;
selfData: IDataObject;
contextNodeName: string;
additionalData: IWorkflowExecuteAdditionalData;
}
export interface PartialAdditionalData {
executionId?: string;
restartExecutionId?: string;
restApiUrl: string;
instanceBaseUrl: string;
formWaitingBaseUrl: string;
webhookBaseUrl: string;
webhookWaitingBaseUrl: string;
webhookTestBaseUrl: string;
currentNodeParameters?: INodeParameters;
executionTimeoutTimestamp?: number;
userId?: string;
variables: IDataObject;
}
/** RPC methods that are exposed directly to the Code Node */
export const EXPOSED_RPC_METHODS = [
// assertBinaryData(itemIndex: number, propertyName: string): Promise<IBinaryData>
'helpers.assertBinaryData',
// getBinaryDataBuffer(itemIndex: number, propertyName: string): Promise<Buffer>
'helpers.getBinaryDataBuffer',
// prepareBinaryData(binaryData: Buffer, fileName?: string, mimeType?: string): Promise<IBinaryData>
'helpers.prepareBinaryData',
// setBinaryDataBuffer(metadata: IBinaryData, buffer: Buffer): Promise<IBinaryData>
'helpers.setBinaryDataBuffer',
// binaryToString(body: Buffer, encoding?: string): string
'helpers.binaryToString',
// httpRequest(opts: IHttpRequestOptions): Promise<IN8nHttpFullResponse | IN8nHttpResponse>
'helpers.httpRequest',
// (deprecated) request(uriOrObject: string | IRequestOptions, options?: IRequestOptions): Promise<any>;
'helpers.request',
];
/** Helpers that exist but that we are not exposing to the Code Node */
export const UNSUPPORTED_HELPER_FUNCTIONS = [
// These rely on checking the credentials from the current node type (Code Node)
// and hence they can't even work (Code Node doesn't have credentials)
'helpers.httpRequestWithAuthentication',
'helpers.requestWithAuthenticationPaginated',
// This has been removed
'helpers.copyBinaryFile',
// We can't support streams over RPC without implementing it ourselves
'helpers.createReadStream',
'helpers.getBinaryStream',
// Makes no sense to support this, as it returns either a stream or a buffer
// and we can't support streams over RPC
'helpers.binaryToBuffer',
// These are pretty low-level, so we shouldn't expose them
// (require binary data id, which we don't expose)
'helpers.getBinaryMetadata',
'helpers.getStoragePath',
'helpers.getBinaryPath',
// We shouldn't allow arbitrary FS writes
'helpers.writeContentToFile',
// Not something we need to expose. Can be done in the node itself
// copyInputItems(items: INodeExecutionData[], properties: string[]): IDataObject[]
'helpers.copyInputItems',
// Code Node does these automatically already
'helpers.returnJsonArray',
'helpers.normalizeItems',
// The client is instantiated and lives on the n8n instance, so we can't
// expose it over RPC without implementing object marshalling
'helpers.getSSHClient',
// Doesn't make sense to expose
'helpers.createDeferredPromise',
'helpers.constructExecutionMetaData',
];
/** List of all RPC methods that task runner supports */
export const AVAILABLE_RPC_METHODS = [...EXPOSED_RPC_METHODS, 'logNodeOutput'] as const;
/** Node types needed for the runner to execute a task. */
export type NeededNodeType = { name: string; version: number };

View File

@@ -1,79 +0,0 @@
import { Container } from '@n8n/di';
import { ensureError, setGlobalState } from 'n8n-workflow';
import { MainConfig } from './config/main-config';
import type { HealthCheckServer } from './health-check-server';
import { JsTaskRunner } from './js-task-runner/js-task-runner';
import { TaskRunnerSentry } from './task-runner-sentry';
let healthCheckServer: HealthCheckServer | undefined;
let runner: JsTaskRunner | undefined;
let isShuttingDown = false;
let sentry: TaskRunnerSentry | undefined;
function createSignalHandler(signal: string, timeoutInS = 10) {
return async function onSignal() {
if (isShuttingDown) {
return;
}
console.log(`Received ${signal} signal, shutting down...`);
setTimeout(() => {
console.error('Shutdown timeout reached, forcing shutdown...');
process.exit(1);
}, timeoutInS * 1000).unref();
isShuttingDown = true;
try {
if (runner) {
await runner.stop();
runner = undefined;
void healthCheckServer?.stop();
}
if (sentry) {
await sentry.shutdown();
sentry = undefined;
}
} catch (e) {
const error = ensureError(e);
console.error('Error stopping task runner', { error });
} finally {
console.log('Task runner stopped');
process.exit(0);
}
};
}
void (async function start() {
const config = Container.get(MainConfig);
setGlobalState({
defaultTimezone: config.baseRunnerConfig.timezone,
});
sentry = Container.get(TaskRunnerSentry);
await sentry.initIfEnabled();
runner = new JsTaskRunner(config);
runner.on('runner:reached-idle-timeout', () => {
// Use shorter timeout since we know we don't have any tasks running
void createSignalHandler('IDLE_TIMEOUT', 3)();
});
const { enabled, host, port } = config.baseRunnerConfig.healthcheckServer;
if (enabled) {
const { HealthCheckServer } = await import('./health-check-server');
healthCheckServer = new HealthCheckServer();
await healthCheckServer.start(host, port);
}
process.on('SIGINT', createSignalHandler('SIGINT'));
process.on('SIGTERM', createSignalHandler('SIGTERM'));
})().catch((e) => {
const error = ensureError(e);
console.error('Task runner failed to start', { error });
process.exit(1);
});

View File

@@ -1,623 +0,0 @@
import { isSerializedBuffer, toBuffer } from 'n8n-core';
import { ApplicationError, ensureError, randomInt } from 'n8n-workflow';
import { nanoid } from 'nanoid';
import { EventEmitter } from 'node:events';
import { type MessageEvent, WebSocket } from 'ws';
import type { BaseRunnerConfig } from '@/config/base-runner-config';
import { TimeoutError } from '@/js-task-runner/errors/timeout-error';
import type { BrokerMessage, RunnerMessage } from '@/message-types';
import { TaskRunnerNodeTypes } from '@/node-types';
import type { TaskResultData } from '@/runner-types';
import { TaskState } from '@/task-state';
import { TaskCancelledError } from './js-task-runner/errors/task-cancelled-error';
export interface TaskOffer {
offerId: string;
validUntil: bigint;
}
interface DataRequest {
taskId: string;
requestId: string;
resolve: (data: unknown) => void;
reject: (error: unknown) => void;
}
interface NodeTypesRequest {
taskId: string;
requestId: string;
resolve: (data: unknown) => void;
reject: (error: unknown) => void;
}
interface RPCCall {
callId: string;
resolve: (data: unknown) => void;
reject: (error: unknown) => void;
}
const OFFER_VALID_TIME_MS = 5000;
const OFFER_VALID_EXTRA_MS = 100;
/** Converts milliseconds to nanoseconds */
const msToNs = (ms: number) => BigInt(ms * 1_000_000);
export const noOp = () => {};
/** Params the task receives when it is executed */
export interface TaskParams<T = unknown> {
taskId: string;
settings: T;
}
export interface TaskRunnerOpts extends BaseRunnerConfig {
taskType: string;
name?: string;
}
export abstract class TaskRunner extends EventEmitter {
id: string = nanoid();
ws: WebSocket;
canSendOffers = false;
runningTasks: Map<TaskState['taskId'], TaskState> = new Map();
offerInterval: NodeJS.Timeout | undefined;
openOffers: Map<TaskOffer['offerId'], TaskOffer> = new Map();
dataRequests: Map<DataRequest['requestId'], DataRequest> = new Map();
nodeTypesRequests: Map<NodeTypesRequest['requestId'], NodeTypesRequest> = new Map();
rpcCalls: Map<RPCCall['callId'], RPCCall> = new Map();
nodeTypes: TaskRunnerNodeTypes = new TaskRunnerNodeTypes([]);
taskType: string;
maxConcurrency: number;
name: string;
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. */
private readonly idleTimeout: number;
constructor(opts: TaskRunnerOpts) {
super();
this.taskType = opts.taskType;
this.name = opts.name ?? 'Node.js Task Runner SDK';
this.maxConcurrency = opts.maxConcurrency;
this.taskTimeout = opts.taskTimeout;
this.idleTimeout = opts.idleTimeout;
const { host: taskBrokerHost } = new URL(opts.taskBrokerUri);
const wsUrl = `ws://${taskBrokerHost}/runners/_ws?id=${this.id}`;
this.ws = new WebSocket(wsUrl, {
headers: {
authorization: `Bearer ${opts.grantToken}`,
},
maxPayload: opts.maxPayloadSize,
});
this.ws.addEventListener('error', (event) => {
const error = ensureError(event.error);
if (
'code' in error &&
typeof error.code === 'string' &&
['ECONNREFUSED', 'ENOTFOUND'].some((code) => code === error.code)
) {
console.error(
`Error: Failed to connect to n8n task broker. Please ensure n8n task broker is reachable at: ${taskBrokerHost}`,
);
process.exit(1);
} else {
console.error(`Error: Failed to connect to n8n task broker at ${taskBrokerHost}`);
console.error('Details:', event.message || 'Unknown error');
}
});
this.ws.addEventListener('message', this.receiveMessage);
this.ws.addEventListener('close', this.stopTaskOffers);
this.resetIdleTimer();
}
private resetIdleTimer() {
if (this.idleTimeout === 0) return;
this.clearIdleTimer();
this.idleTimer = setTimeout(() => {
if (this.runningTasks.size === 0) this.emit('runner:reached-idle-timeout');
}, this.idleTimeout * 1000);
}
private receiveMessage = (message: MessageEvent) => {
// eslint-disable-next-line n8n-local-rules/no-uncaught-json-parse
const data = JSON.parse(message.data as string) as BrokerMessage.ToRunner.All;
void this.onMessage(data);
};
private stopTaskOffers = () => {
this.canSendOffers = false;
if (this.offerInterval) {
clearInterval(this.offerInterval);
this.offerInterval = undefined;
}
};
private startTaskOffers() {
this.canSendOffers = true;
if (this.offerInterval) {
clearInterval(this.offerInterval);
}
this.offerInterval = setInterval(() => this.sendOffers(), 250);
}
deleteStaleOffers() {
this.openOffers.forEach((offer, key) => {
if (offer.validUntil < process.hrtime.bigint()) {
this.openOffers.delete(key);
}
});
}
sendOffers() {
this.deleteStaleOffers();
if (!this.canSendOffers) {
return;
}
const offersToSend = this.maxConcurrency - (this.openOffers.size + this.runningTasks.size);
for (let i = 0; i < offersToSend; i++) {
// Add a bit of randomness so that not all offers expire at the same time
const validForInMs = OFFER_VALID_TIME_MS + randomInt(500);
// Add a little extra time to account for latency
const validUntil = process.hrtime.bigint() + msToNs(validForInMs + OFFER_VALID_EXTRA_MS);
const offer: TaskOffer = {
offerId: nanoid(),
validUntil,
};
this.openOffers.set(offer.offerId, offer);
this.send({
type: 'runner:taskoffer',
taskType: this.taskType,
offerId: offer.offerId,
validFor: validForInMs,
});
}
}
send(message: RunnerMessage.ToBroker.All) {
this.ws.send(JSON.stringify(message));
}
onMessage(message: BrokerMessage.ToRunner.All) {
switch (message.type) {
case 'broker:inforequest':
this.send({
type: 'runner:info',
name: this.name,
types: [this.taskType],
});
break;
case 'broker:runnerregistered':
this.startTaskOffers();
break;
case 'broker:taskofferaccept':
this.offerAccepted(message.offerId, message.taskId);
break;
case 'broker:taskcancel':
void this.taskCancelled(message.taskId, message.reason);
break;
case 'broker:tasksettings':
void this.receivedSettings(message.taskId, message.settings);
break;
case 'broker:taskdataresponse':
this.processDataResponse(message.requestId, message.data);
break;
case 'broker:rpcresponse':
this.handleRpcResponse(message.callId, message.status, message.data);
break;
case 'broker:nodetypes':
this.processNodeTypesResponse(message.requestId, message.nodeTypes);
break;
}
}
processDataResponse(requestId: string, data: unknown) {
const request = this.dataRequests.get(requestId);
if (!request) {
return;
}
// Deleting of the request is handled in `requestData`, using a
// `finally` wrapped around the return
request.resolve(data);
}
processNodeTypesResponse(requestId: string, nodeTypes: unknown) {
const request = this.nodeTypesRequests.get(requestId);
if (!request) return;
// Deleting of the request is handled in `requestNodeTypes`, using a
// `finally` wrapped around the return
request.resolve(nodeTypes);
}
/**
* Whether the task runner has capacity to accept more tasks.
*/
hasOpenTaskSlots() {
return this.runningTasks.size < this.maxConcurrency;
}
offerAccepted(offerId: string, taskId: string) {
if (!this.hasOpenTaskSlots()) {
this.openOffers.delete(offerId);
this.send({
type: 'runner:taskrejected',
taskId,
reason: 'No open task slots - runner already at capacity',
});
return;
}
const offer = this.openOffers.get(offerId);
if (!offer) {
this.send({
type: 'runner:taskrejected',
taskId,
reason: 'Offer expired - not accepted within validity window',
});
return;
} else {
this.openOffers.delete(offerId);
}
this.resetIdleTimer();
const taskState = new TaskState({
taskId,
timeoutInS: this.taskTimeout,
onTimeout: () => {
void this.taskTimedOut(taskId);
},
});
this.runningTasks.set(taskId, taskState);
this.send({
type: 'runner:taskaccepted',
taskId,
});
}
async taskCancelled(taskId: string, reason: string) {
const taskState = this.runningTasks.get(taskId);
if (!taskState) {
return;
}
await taskState.caseOf({
// If the cancelled task hasn't received settings yet, we can finish it
waitingForSettings: () => this.finishTask(taskState),
// If the task has already timed out or is already cancelled, we can
// ignore the cancellation
'aborting:timeout': noOp,
'aborting:cancelled': noOp,
running: () => {
taskState.status = 'aborting:cancelled';
taskState.abortController.abort('cancelled');
this.cancelTaskRequests(taskId, reason);
},
});
}
async taskTimedOut(taskId: string) {
const taskState = this.runningTasks.get(taskId);
if (!taskState) {
return;
}
await taskState.caseOf({
// If we are still waiting for settings for the task, we can error the
// task immediately
waitingForSettings: () => {
try {
this.send({
type: 'runner:taskerror',
taskId,
error: new TimeoutError(this.taskTimeout),
});
} finally {
this.finishTask(taskState);
}
},
// This should never happen, the timeout timer should only fire once
'aborting:timeout': TaskState.throwUnexpectedTaskStatus,
// If we are currently executing the task, abort the execution and
// mark the task as timed out
running: () => {
taskState.status = 'aborting:timeout';
taskState.abortController.abort('timeout');
this.cancelTaskRequests(taskId, 'timeout');
},
// If the task is already cancelling, we can ignore the timeout
'aborting:cancelled': noOp,
});
}
async receivedSettings(taskId: string, settings: unknown) {
const taskState = this.runningTasks.get(taskId);
if (!taskState) {
return;
}
await taskState.caseOf({
// These states should never happen, as they are handled already in
// the other lifecycle methods and the task should be removed from the
// running tasks
'aborting:cancelled': TaskState.throwUnexpectedTaskStatus,
'aborting:timeout': TaskState.throwUnexpectedTaskStatus,
running: TaskState.throwUnexpectedTaskStatus,
waitingForSettings: async () => {
taskState.status = 'running';
await this.executeTask(
{
taskId,
settings,
},
taskState.abortController.signal,
)
.then(async (data) => await this.taskExecutionSucceeded(taskState, data))
.catch(async (error) => await this.taskExecutionFailed(taskState, error));
},
});
}
async executeTask(_taskParams: TaskParams, _signal: AbortSignal): Promise<TaskResultData> {
throw new ApplicationError('Unimplemented');
}
async requestNodeTypes<T = unknown>(
taskId: TaskState['taskId'],
requestParams: RunnerMessage.ToBroker.NodeTypesRequest['requestParams'],
) {
const requestId = nanoid();
const nodeTypesPromise = new Promise<T>((resolve, reject) => {
this.nodeTypesRequests.set(requestId, {
requestId,
taskId,
resolve: resolve as (data: unknown) => void,
reject,
});
});
this.send({
type: 'runner:nodetypesrequest',
taskId,
requestId,
requestParams,
});
try {
return await nodeTypesPromise;
} finally {
this.nodeTypesRequests.delete(requestId);
}
}
async requestData<T = unknown>(
taskId: TaskState['taskId'],
requestParams: RunnerMessage.ToBroker.TaskDataRequest['requestParams'],
): Promise<T> {
const requestId = nanoid();
const dataRequestPromise = new Promise<T>((resolve, reject) => {
this.dataRequests.set(requestId, {
requestId,
taskId,
resolve: resolve as (data: unknown) => void,
reject,
});
});
this.send({
type: 'runner:taskdatarequest',
taskId,
requestId,
requestParams,
});
try {
return await dataRequestPromise;
} finally {
this.dataRequests.delete(requestId);
}
}
async makeRpcCall(taskId: string, name: RunnerMessage.ToBroker.RPC['name'], params: unknown[]) {
const callId = nanoid();
const dataPromise = new Promise((resolve, reject) => {
this.rpcCalls.set(callId, {
callId,
resolve,
reject,
});
});
try {
this.send({
type: 'runner:rpc',
callId,
taskId,
name,
params,
});
const returnValue = await dataPromise;
return isSerializedBuffer(returnValue) ? toBuffer(returnValue) : returnValue;
} finally {
this.rpcCalls.delete(callId);
}
}
handleRpcResponse(
callId: string,
status: BrokerMessage.ToRunner.RPCResponse['status'],
data: unknown,
) {
const call = this.rpcCalls.get(callId);
if (!call) {
return;
}
if (status === 'success') {
call.resolve(data);
} else {
call.reject(typeof data === 'string' ? new Error(data) : data);
}
}
/** Close the connection gracefully and wait until has been closed */
async stop() {
this.clearIdleTimer();
this.stopTaskOffers();
await this.waitUntilAllTasksAreDone();
await this.closeConnection();
}
clearIdleTimer() {
if (this.idleTimer) clearTimeout(this.idleTimer);
this.idleTimer = undefined;
}
private async closeConnection() {
// 1000 is the standard close code
// https://www.rfc-editor.org/rfc/rfc6455.html#section-7.1.5
this.ws.close(1000, 'Shutting down');
await new Promise((resolve) => {
this.ws.once('close', resolve);
});
}
private async waitUntilAllTasksAreDone(maxWaitTimeInMs = 30_000) {
// TODO: Make maxWaitTimeInMs configurable
const start = Date.now();
while (this.runningTasks.size > 0) {
if (Date.now() - start > maxWaitTimeInMs) {
throw new ApplicationError('Timeout while waiting for tasks to finish');
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
private async taskExecutionSucceeded(taskState: TaskState, data: TaskResultData) {
try {
const sendData = () => {
this.send({
type: 'runner:taskdone',
taskId: taskState.taskId,
data,
});
};
await taskState.caseOf({
waitingForSettings: TaskState.throwUnexpectedTaskStatus,
'aborting:cancelled': noOp,
// If the task timed out but we ended up reaching this point, we
// might as well send the data
'aborting:timeout': sendData,
running: sendData,
});
} finally {
this.finishTask(taskState);
}
}
private async taskExecutionFailed(taskState: TaskState, error: unknown) {
try {
const sendError = () => {
this.send({
type: 'runner:taskerror',
taskId: taskState.taskId,
error,
});
};
await taskState.caseOf({
waitingForSettings: TaskState.throwUnexpectedTaskStatus,
'aborting:cancelled': noOp,
'aborting:timeout': () => {
console.warn(`Task ${taskState.taskId} timed out`);
sendError();
},
running: sendError,
});
} finally {
this.finishTask(taskState);
}
}
/**
* Cancels all node type and data requests made by the given task
*/
private cancelTaskRequests(taskId: string, reason: string) {
for (const [requestId, request] of this.dataRequests.entries()) {
if (request.taskId === 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);
}
}
}
/**
* Finishes task by removing it from the running tasks and sending new offers
*/
private finishTask(taskState: TaskState) {
taskState.cleanup();
this.runningTasks.delete(taskState.taskId);
this.sendOffers();
this.resetIdleTimer();
}
}

View File

@@ -1,63 +0,0 @@
import { Service } from '@n8n/di';
import type { ErrorEvent, Exception } from '@sentry/core';
import { ErrorReporter } from 'n8n-core';
import { SentryConfig } from './config/sentry-config';
/**
* Sentry service for the task runner.
*/
@Service()
export class TaskRunnerSentry {
constructor(
private readonly config: SentryConfig,
private readonly errorReporter: ErrorReporter,
) {}
async initIfEnabled() {
const { dsn, n8nVersion, environment, deploymentName } = this.config;
if (!dsn) return;
await this.errorReporter.init({
serverType: 'task_runner',
dsn,
release: `n8n@${n8nVersion}`,
environment,
serverName: deploymentName,
beforeSendFilter: this.filterOutUserCodeErrors,
withEventLoopBlockDetection: false,
});
}
async shutdown() {
if (!this.config.dsn) return;
await this.errorReporter.shutdown();
}
/**
* Filter out errors originating from user provided code.
* It is possible for users to create code that causes unhandledrejections
* that end up in the sentry error reporting.
*/
filterOutUserCodeErrors = (event: ErrorEvent) => {
const error = event?.exception?.values?.[0];
return error ? this.isUserCodeError(error) : false;
};
/**
* Check if the error is originating from user provided code.
* It is possible for users to create code that causes unhandledrejections
* that end up in the sentry error reporting.
*/
private isUserCodeError(error: Exception) {
const frames = error.stacktrace?.frames;
if (!frames) return false;
return frames.some(
(frame) => frame.filename === 'node:vm' && frame.function === 'runInContext',
);
}
}

View File

@@ -1,118 +0,0 @@
import * as a from 'node:assert';
export type TaskStatus =
| 'waitingForSettings'
| 'running'
| 'aborting:cancelled'
| 'aborting:timeout';
export type TaskStateOpts = {
taskId: string;
timeoutInS: number;
onTimeout: () => void;
};
/**
* The state of a task. The task can be in one of the following states:
* - waitingForSettings: The task is waiting for settings from the broker
* - running: The task is currently running
* - aborting:cancelled: The task was canceled by the broker and is being aborted
* - aborting:timeout: The task took too long to complete and is being aborted
*
* The task is discarded once it reaches an end state.
*
* The class only holds the state, and does not have any logic.
*
* The task has the following lifecycle:
*
* ┌───┐
* └───┘
* │
* broker:taskofferaccept : create task state
* │
* ▼
* ┌────────────────────┐ broker:taskcancel / timeout
* │ waitingForSettings ├──────────────────────────────────┐
* └────────┬───────────┘ │
* │ │
* broker:tasksettings │
* │ │
* ▼ │
* ┌───────────────┐ ┌────────────────────┐ │
* │ running │ │ aborting:timeout │ │
* │ │ timeout │ │ │
* ┌───────┤- execute task ├───────────►│- fire abort signal │ │
* │ └──────┬────────┘ └──────────┬─────────┘ │
* │ │ │ │
* │ broker:taskcancel │ │
* Task execution │ Task execution │
* resolves / rejects │ resolves / rejects │
* │ ▼ │ │
* │ ┌─────────────────────┐ │ │
* │ │ aborting:cancelled │ │ │
* │ │ │ │ │
* │ │- fire abort signal │ │ │
* │ └──────────┬──────────┘ │ │
* │ Task execution │ │
* │ resolves / rejects │ │
* │ │ │ │
* │ ▼ │ │
* │ ┌──┐ │ │
* └─────────────►│ │◄────────────────────────────┴─────────────┘
* └──┘
*/
export class TaskState {
status: TaskStatus = 'waitingForSettings';
readonly taskId: string;
/** Controller for aborting the execution of the task */
readonly abortController = new AbortController();
/** Timeout timer for the task */
private timeoutTimer: NodeJS.Timeout | undefined;
constructor(opts: TaskStateOpts) {
this.taskId = opts.taskId;
this.timeoutTimer = setTimeout(opts.onTimeout, opts.timeoutInS * 1000);
}
/** Cleans up any resources before the task can be removed */
cleanup() {
clearTimeout(this.timeoutTimer);
this.timeoutTimer = undefined;
}
/** Custom JSON serialization for the task state for logging purposes */
toJSON() {
return `[Task ${this.taskId} (${this.status})]`;
}
/**
* Executes the function matching the current task status
*
* @example
* ```ts
* taskState.caseOf({
* waitingForSettings: () => {...},
* running: () => {...},
* aborting:cancelled: () => {...},
* aborting:timeout: () => {...},
* });
* ```
*/
async caseOf(
conditions: Record<TaskStatus, (taskState: TaskState) => void | Promise<void> | never>,
) {
if (!conditions[this.status]) {
TaskState.throwUnexpectedTaskStatus(this);
}
return await conditions[this.status](this);
}
/** Throws an error that the task status is unexpected */
static throwUnexpectedTaskStatus = (taskState: TaskState) => {
a.fail(`Unexpected task status: ${JSON.stringify(taskState)}`);
};
}