chore: 清理macOS同步产生的重复文件
详细说明: - 删除了352个带数字后缀的重复文件 - 更新.gitignore防止未来产生此类文件 - 这些文件是由iCloud或其他同步服务冲突产生的 - 不影响项目功能,仅清理冗余文件
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export * from './task-runner';
|
||||
export * from './runner-types';
|
||||
export * from './message-types';
|
||||
export * from './data-request/data-request-response-reconstruct';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)}`);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user