fix(core): Improve task broker resilience when a runner dies (#35298)

This commit is contained in:
Lorent Lempereur 2026-07-31 16:02:24 +02:00 committed by GitHub
parent 4f84eeae66
commit c15bbca848
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 1251 additions and 199 deletions

View File

@ -62,6 +62,15 @@ export class TaskRunnersConfig {
@Env('N8N_RUNNERS_TASK_REQUEST_TIMEOUT')
taskRequestTimeout: number = 60;
/**
* How long (in seconds) the broker waits for a runner or requester to
* acknowledge a matched task before abandoning the match.
* Must be greater than 0.
* Increase on infrastructure where runners are slow to respond.
*/
@Env('N8N_RUNNERS_TASK_ACCEPT_TIMEOUT')
taskAcceptTimeout: number = 2;
/** Interval in seconds between heartbeats from runner to broker; missing heartbeats abort the task (and restart the runner in internal mode). Must be > 0. */
@Env('N8N_RUNNERS_HEARTBEAT_INTERVAL')
heartbeatInterval: number = 30;

View File

@ -408,6 +408,7 @@ describe('GlobalConfig', () => {
maxConcurrency: 10,
taskTimeout: 300,
taskRequestTimeout: 60,
taskAcceptTimeout: 2,
heartbeatInterval: 30,
grantTokenTtl: 30,
insecureMode: false,

View File

@ -3,11 +3,18 @@ import { OperationalError } from 'n8n-workflow';
export class TaskRequestTimeoutError extends OperationalError {
description: string;
constructor({ timeout, isSelfHosted }: { timeout: number; isSelfHosted: boolean }) {
super(`Task request timed out after ${timeout} ${timeout === 1 ? 'second' : 'seconds'}`);
constructor({
elapsedSeconds,
isSelfHosted,
}: {
elapsedSeconds: number;
isSelfHosted: boolean;
}) {
// Keep the message static so Sentry groups all occurrences as one issue.
super('Task request timed out', { extra: { elapsedSeconds } });
const description = [
'Your Code node task was not matched to a runner within the timeout period. This indicates that the task runner is currently down, or not ready, or at capacity, so it cannot service your task.',
`Your Code node task was not matched to a runner within the timeout period (waited ${elapsedSeconds} ${elapsedSeconds === 1 ? 'second' : 'seconds'}). This indicates that the task runner is currently down, or not ready, or at capacity, so it cannot service your task.`,
'If you are repeatedly executing Code nodes with long-running tasks across your instance, please space them apart to give the runner time to catch up. If this does not describe your use case, please open a GitHub issue or reach out to support.',
];

View File

@ -4,35 +4,241 @@ import { mock } from 'vitest-mock-extended';
import type WebSocket from 'ws';
import { WsStatusCodes } from '@/constants';
import type { DefaultTaskRunnerDisconnectAnalyzer } from '@/task-runners/default-task-runner-disconnect-analyzer';
import { TaskBrokerWsServer } from '@/task-runners/task-broker/task-broker-ws-server';
import type { TaskBroker } from '@/task-runners/task-broker/task-broker.service';
const globalConfig = mock<GlobalConfig>({ generic: { gracefulShutdownTimeout: 30 } });
const WS_OPEN = 1;
const WS_CLOSING = 2;
const setReadyState = (ws: WebSocket, readyState: number) => {
(ws as unknown as { readyState: number }).readyState = readyState;
};
const mockWs = (readyState = WS_OPEN, isAlive = true) => {
const ws = mock<WebSocket>({ OPEN: WS_OPEN });
setReadyState(ws, readyState);
ws.isAlive = isAlive;
return ws;
};
const createServer = ({
taskBroker = mock<TaskBroker>(),
disconnectAnalyzer = mock<DefaultTaskRunnerDisconnectAnalyzer>(),
heartbeatInterval = 30,
}: {
taskBroker?: TaskBroker;
disconnectAnalyzer?: DefaultTaskRunnerDisconnectAnalyzer;
heartbeatInterval?: number;
} = {}) =>
new TaskBrokerWsServer(
mock(),
taskBroker,
disconnectAnalyzer,
mock<TaskRunnersConfig>({ path: '/runners', heartbeatInterval }),
mock(),
globalConfig,
);
const wsMessage = (message: unknown) => Buffer.from(JSON.stringify(message));
const messageHandlerOf = (ws: WebSocket) => {
// `on` is overloaded, so its mock calls are unreachable through the ws types
const onCalls = (ws.on as unknown as { mock: { calls: Array<[string, unknown]> } }).mock.calls;
return onCalls.find(([event]) => event === 'message')?.[1] as (
data: WebSocket.RawData,
) => Promise<void>;
};
const registerOverWs = async (server: TaskBrokerWsServer, id: string, ws: WebSocket) => {
server.add(id, ws);
const onMessage = messageHandlerOf(ws);
await onMessage(
wsMessage({ type: 'runner:info', name: 'JS Task Runner', types: ['javascript'] }),
);
return onMessage;
};
const pendingAnalysis = () => {
let complete: (error: Error) => void = () => {};
const disconnectAnalyzer = mock<DefaultTaskRunnerDisconnectAnalyzer>();
disconnectAnalyzer.toDisconnectError.mockReturnValue(
new Promise<Error>((resolve) => {
complete = resolve;
}),
);
return { disconnectAnalyzer, complete: (error: Error) => complete(error) };
};
describe('TaskBrokerWsServer', () => {
describe('removeConnection', () => {
it('should close with 1000 status code by default', async () => {
const server = new TaskBrokerWsServer(mock(), mock(), mock(), mock(), mock(), globalConfig);
const ws = mock<WebSocket>();
const server = createServer();
const ws = mockWs();
server.runnerConnections.set('test-runner', ws);
await server.removeConnection('test-runner');
expect(ws.close).toHaveBeenCalledWith(WsStatusCodes.CloseNormal);
});
it('should stop routing to the runner before the disconnect analysis completes', async () => {
const { disconnectAnalyzer, complete } = pendingAnalysis();
const taskBroker = mock<TaskBroker>();
const server = createServer({ taskBroker, disconnectAnalyzer });
const ws = mockWs();
server.runnerConnections.set('test-runner', ws);
const removal = server.removeConnection('test-runner');
await Promise.resolve();
expect(server.runnerConnections.has('test-runner')).toBe(false);
expect(ws.close).toHaveBeenCalled();
expect(taskBroker.deregisterRunner).not.toHaveBeenCalled();
complete(new Error('disconnected'));
await removal;
expect(taskBroker.deregisterRunner).toHaveBeenCalled();
});
it('should not deregister a runner that reconnects before disconnect analysis completes', async () => {
const { disconnectAnalyzer, complete } = pendingAnalysis();
const taskBroker = mock<TaskBroker>();
const server = createServer({ taskBroker, disconnectAnalyzer });
const newWs = mockWs();
server.runnerConnections.set('test-runner', mockWs());
const removal = server.removeConnection('test-runner');
await Promise.resolve();
server.runnerConnections.set('test-runner', newWs);
complete(new Error('disconnected'));
await removal;
expect(taskBroker.deregisterRunner).not.toHaveBeenCalled();
expect(server.runnerConnections.get('test-runner')).toBe(newWs);
});
it('should fail the tasks in flight on the replaced connection when the runner reconnects', async () => {
const { disconnectAnalyzer, complete } = pendingAnalysis();
const taskBroker = mock<TaskBroker>();
taskBroker.getInFlightTaskIds.mockReturnValue(['task1', 'task2']);
const server = createServer({ taskBroker, disconnectAnalyzer });
server.runnerConnections.set('test-runner', mockWs());
const removal = server.removeConnection('test-runner');
await Promise.resolve();
server.runnerConnections.set('test-runner', mockWs());
const disconnectError = new Error('disconnected');
complete(disconnectError);
await removal;
expect(taskBroker.deregisterRunner).not.toHaveBeenCalled();
expect(taskBroker.failTasks).toHaveBeenCalledWith(['task1', 'task2'], disconnectError);
});
it('should ignore stale close events from replaced connections', async () => {
const taskBroker = mock<TaskBroker>();
const server = createServer({ taskBroker });
const staleWs = mockWs();
const currentWs = mockWs();
server.runnerConnections.set('test-runner', currentWs);
await server.removeConnection('test-runner', { expectedConnection: staleWs });
expect(currentWs.close).not.toHaveBeenCalled();
expect(taskBroker.deregisterRunner).not.toHaveBeenCalled();
expect(server.runnerConnections.get('test-runner')).toBe(currentWs);
});
});
describe('runner reachability', () => {
const registerAndGetReachability = async (ws: WebSocket) => {
const taskBroker = mock<TaskBroker>();
const server = createServer({ taskBroker });
await registerOverWs(server, 'test-runner', ws);
return { server, isRunnerReachable: taskBroker.registerRunner.mock.calls[0][2]! };
};
it('should report the runner as unreachable once its connection stops being open', async () => {
const ws = mockWs();
const { isRunnerReachable } = await registerAndGetReachability(ws);
expect(isRunnerReachable()).toBe(true);
setReadyState(ws, WS_CLOSING);
expect(isRunnerReachable()).toBe(false);
});
it('should report the runner as unreachable once its connection is replaced', async () => {
const { server, isRunnerReachable } = await registerAndGetReachability(mockWs());
server.runnerConnections.set('test-runner', mockWs());
expect(isRunnerReachable()).toBe(false);
});
});
describe('incoming runner messages', () => {
const offer = {
type: 'runner:taskoffer',
offerId: 'offer1',
taskType: 'javascript',
validFor: 5000,
};
it('should forward a message from the connection the runner is registered with', async () => {
const taskBroker = mock<TaskBroker>();
const server = createServer({ taskBroker });
const onMessage = await registerOverWs(server, 'test-runner', mockWs());
await onMessage(wsMessage(offer));
expect(taskBroker.onRunnerMessage).toHaveBeenCalledWith('test-runner', offer);
});
it('should drop a message buffered on a connection that was replaced', async () => {
const taskBroker = mock<TaskBroker>();
const server = createServer({ taskBroker });
const onStaleMessage = await registerOverWs(server, 'test-runner', mockWs());
await registerOverWs(server, 'test-runner', mockWs());
await onStaleMessage(wsMessage(offer));
expect(taskBroker.onRunnerMessage).not.toHaveBeenCalled();
});
});
describe('heartbeat timer', () => {
const DEAD = false;
const runHeartbeatCheck = async (...connections: WebSocket[]) => {
vi.useFakeTimers();
const server = createServer();
connections.forEach((ws, i) => server.runnerConnections.set(`runner-${i}`, ws));
server.start();
vi.advanceTimersByTime(30 * Time.seconds.toMilliseconds);
await Promise.resolve();
// Restoring real timers discards the heartbeat interval, so the server needs no stop.
vi.useRealTimers();
};
it('should set up heartbeat timer on server start', async () => {
const setIntervalSpy = vi.spyOn(global, 'setInterval');
const server = new TaskBrokerWsServer(
mock(),
mock(),
mock(),
mock<TaskRunnersConfig>({ path: '/runners', heartbeatInterval: 30 }),
mock(),
globalConfig,
);
const server = createServer();
server.start();
@ -47,15 +253,7 @@ describe('TaskBrokerWsServer', () => {
it('should clear heartbeat timer on server stop', async () => {
vi.spyOn(global, 'setInterval');
const clearIntervalSpy = vi.spyOn(global, 'clearInterval');
const server = new TaskBrokerWsServer(
mock(),
mock(),
mock(),
mock<TaskRunnersConfig>({ path: '/runners', heartbeatInterval: 30 }),
mock(),
globalConfig,
);
const server = createServer();
server.start();
await server.stop();
@ -64,37 +262,38 @@ describe('TaskBrokerWsServer', () => {
});
it('should close connection with protocol error code when heartbeat check fails', async () => {
vi.useFakeTimers();
const server = new TaskBrokerWsServer(
mock(),
mock(),
mock(),
mock<TaskRunnersConfig>({ path: '/runners', heartbeatInterval: 30 }),
mock(),
globalConfig,
);
const ws = mockWs(WS_OPEN, DEAD);
const ws = mock<WebSocket>();
ws.isAlive = false;
server.runnerConnections.set('test-runner', ws);
server.start();
vi.advanceTimersByTime(30 * Time.seconds.toMilliseconds);
await Promise.resolve();
await runHeartbeatCheck(ws);
expect(ws.close).toHaveBeenCalledWith(WsStatusCodes.CloseProtocolError);
});
await server.stop();
vi.useRealTimers();
it('should close every dead connection in a single check', async () => {
const deadWs1 = mockWs(WS_OPEN, DEAD);
const deadWs2 = mockWs(WS_OPEN, DEAD);
await runHeartbeatCheck(deadWs1, deadWs2);
expect(deadWs1.close).toHaveBeenCalledWith(WsStatusCodes.CloseProtocolError);
expect(deadWs2.close).toHaveBeenCalledWith(WsStatusCodes.CloseProtocolError);
});
it('should keep pinging live connections listed after a dead one', async () => {
const liveWs = mockWs();
await runHeartbeatCheck(mockWs(WS_OPEN, DEAD), liveWs);
expect(liveWs.ping).toHaveBeenCalled();
expect(liveWs.isAlive).toBe(false);
expect(liveWs.close).not.toHaveBeenCalled();
});
});
describe('sendMessage', () => {
it('should work with a message containing circular references', () => {
const server = new TaskBrokerWsServer(mock(), mock(), mock(), mock(), mock(), globalConfig);
const ws = mock<WebSocket>();
const server = createServer();
const ws = mockWs();
server.runnerConnections.set('test-runner', ws);
const messageData: Record<string, unknown> = {};

View File

@ -1,14 +1,16 @@
import type { Logger } from '@n8n/backend-common';
import type { TaskRunnersConfig } from '@n8n/config';
import { Time } from '@n8n/constants';
import type { RunnerMessage, TaskResultData } from '@n8n/task-runner';
import type { BrokerMessage, RunnerMessage, TaskResultData } from '@n8n/task-runner';
import { type INodeTypeBaseDescription } from 'n8n-workflow';
import { mock } from 'vitest-mock-extended';
import type { TaskRunnerLifecycleEvents } from '@/task-runners/task-runner-lifecycle-events';
import { TaskRejectError } from '../errors/task-reject.error';
import { TaskRequesterAcceptTimeoutError } from '../errors/task-requester-accept-timeout.error';
import { TaskRunnerExecutionTimeoutError } from '../errors/task-runner-execution-timeout.error';
import { TaskRunnerUnreachableError } from '../errors/task-runner-unreachable.error';
import { TaskBroker } from '../task-broker.service';
import type { TaskOffer, TaskRequest, TaskRunner } from '../task-broker.service';
@ -18,10 +20,34 @@ describe('TaskBroker', () => {
let taskBroker: TaskBroker;
beforeEach(() => {
taskBroker = new TaskBroker(mock(), mock(), mock(), mock());
// real timeout values, so flows that arm timers never call setTimeout(NaN)
taskBroker = new TaskBroker(
mock(),
mock<TaskRunnersConfig>({ taskRequestTimeout: 60, taskTimeout: 60, taskAcceptTimeout: 2 }),
mock(),
mock(),
);
vi.restoreAllMocks();
});
describe('constructor', () => {
it('should reject a non-positive task request timeout', () => {
const config = mock<TaskRunnersConfig>({ taskRequestTimeout: 0 });
expect(() => new TaskBroker(mock(), config, mock(), mock())).toThrowError(
'Task request timeout must be greater than 0',
);
});
it('should reject a non-positive task accept timeout', () => {
const config = mock<TaskRunnersConfig>({ taskRequestTimeout: 60, taskAcceptTimeout: 0 });
expect(() => new TaskBroker(mock(), config, mock(), mock())).toThrowError(
'Task accept timeout must be greater than 0',
);
});
});
describe('expireTasks', () => {
it('should remove expired task offers and keep valid task offers', () => {
const validOffer: TaskOffer = {
@ -115,6 +141,190 @@ describe('TaskBroker', () => {
});
});
describe('unreachable runners', () => {
beforeEach(() => {
// fake timers so the matching flows arm no lingering real timers
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
const offerFrom = (runnerId: string): TaskOffer => ({
offerId: `offer-${runnerId}`,
runnerId,
taskType: 'taskType1',
validFor: 1000,
validUntil: createValidUntil(1000),
});
const requestTaskFrom = (runnerId: string, isRunnerReachable: () => boolean) => {
const messageCallback = vi.fn();
taskBroker.registerRunner(
mock<TaskRunner>({ id: runnerId }),
messageCallback,
isRunnerReachable,
);
messageCallback.mockClear();
taskBroker.setPendingTaskOffers([offerFrom(runnerId)]);
const request: TaskRequest = {
requestId: 'request1',
requesterId: 'requester1',
taskType: 'taskType1',
};
taskBroker.taskRequested(request);
return { request, messageCallback };
};
it('should not match an offer from a registered but unreachable runner', () => {
const { request, messageCallback } = requestTaskFrom('deadRunner', () => false);
expect(taskBroker.getPendingTaskOffers()).toHaveLength(0);
expect(request.acceptInProgress).toBeUndefined();
expect(messageCallback).not.toHaveBeenCalled();
});
it('should still match an offer from a reachable runner', () => {
const { request, messageCallback } = requestTaskFrom('liveRunner', () => true);
expect(request.acceptInProgress).toBe(true);
expect(messageCallback).toHaveBeenCalledWith(
expect.objectContaining({ type: 'broker:taskofferaccept' }),
);
});
it('should keep offers from runners that are not registered', () => {
taskBroker.setPendingTaskOffers([offerFrom('unregisteredRunner')]);
taskBroker.settleTasks();
expect(taskBroker.getPendingTaskOffers()).toHaveLength(1);
});
const setupInFlightTask = (taskId: string, runnerId: string, isReachable: boolean) => {
const runnerCallback = vi.fn();
const requesterCallback = vi.fn();
taskBroker.registerRunner(
mock<TaskRunner>({ id: runnerId }),
runnerCallback,
() => isReachable,
);
taskBroker.registerRequester('requester1', requesterCallback);
taskBroker.setTasks({
[taskId]: { id: taskId, runnerId, requesterId: 'requester1', taskType: 'taskType1' },
});
runnerCallback.mockClear();
return { runnerCallback, requesterCallback };
};
it('should fail an in-flight task instead of sending settings to an unreachable runner', async () => {
const { runnerCallback, requesterCallback } = setupInFlightTask('task1', 'deadRunner', false);
await expect(taskBroker.sendTaskSettings('task1', {})).rejects.toThrow(
TaskRunnerUnreachableError,
);
expect(runnerCallback).not.toHaveBeenCalled();
expect(requesterCallback).toHaveBeenCalledWith({
type: 'broker:taskerror',
taskId: 'task1',
error: expect.any(TaskRunnerUnreachableError),
});
expect(taskBroker.getTasks().get('task1')).toBeUndefined();
const [[taskErrorMessage]] = requesterCallback.mock.calls as [
[{ error: TaskRunnerUnreachableError }],
];
expect(taskErrorMessage.error.level).toBe('warning');
});
it('should fail an in-flight task instead of relaying a requester response to an unreachable runner', async () => {
const { runnerCallback, requesterCallback } = setupInFlightTask('task1', 'deadRunner', false);
await expect(taskBroker.handleRequesterDataResponse('task1', 'req1', {})).rejects.toThrow(
TaskRunnerUnreachableError,
);
expect(runnerCallback).not.toHaveBeenCalled();
expect(requesterCallback).toHaveBeenCalledWith({
type: 'broker:taskerror',
taskId: 'task1',
error: expect.any(TaskRunnerUnreachableError),
});
expect(taskBroker.getTasks().get('task1')).toBeUndefined();
});
it('should still send settings to a reachable runner', async () => {
const { runnerCallback } = setupInFlightTask('task1', 'liveRunner', true);
await taskBroker.sendTaskSettings('task1', {});
expect(runnerCallback).toHaveBeenCalledWith(
expect.objectContaining({ type: 'broker:tasksettings', taskId: 'task1' }),
);
expect(taskBroker.getTasks().get('task1')).toBeDefined();
});
it('should settle the accept flow gracefully when the runner dies before receiving settings', async () => {
const loggerMock = mock<Logger>();
taskBroker = new TaskBroker(
loggerMock,
mock<TaskRunnersConfig>({ taskRequestTimeout: 60, taskTimeout: 60, taskAcceptTimeout: 2 }),
mock(),
mock(),
);
let isReachable = true;
const runnerCallback = vi.fn((message: BrokerMessage.ToRunner.All) => {
if (message.type === 'broker:taskofferaccept') {
taskBroker.handleRunnerAccept(message.taskId);
isReachable = false; // runner dies right after acknowledging
}
});
const requesterCallback = vi.fn((message: BrokerMessage.ToRequester.All) => {
if (message.type === 'broker:taskready') {
taskBroker.handleRequesterAccept(message.taskId, {});
}
});
taskBroker.registerRunner(
mock<TaskRunner>({ id: 'runner1' }),
runnerCallback,
() => isReachable,
);
taskBroker.registerRequester('requester1', requesterCallback);
const request: TaskRequest = {
requestId: 'request1',
requesterId: 'requester1',
taskType: 'taskType1',
};
taskBroker.setPendingTaskRequests([request]);
await expect(taskBroker.acceptOffer(offerFrom('runner1'), request)).resolves.toBeUndefined();
expect(runnerCallback).not.toHaveBeenCalledWith(
expect.objectContaining({ type: 'broker:tasksettings' }),
);
expect(requesterCallback).toHaveBeenCalledWith(
expect.objectContaining({
type: 'broker:taskerror',
error: expect.any(TaskRunnerUnreachableError),
}),
);
expect(taskBroker.getTasks().size).toBe(0);
expect(loggerMock.warn).toHaveBeenCalledWith(
expect.stringContaining('Runner (runner1) became unreachable while processing task'),
);
});
});
describe('registerRequester', () => {
it('should add a requester to known requesters', () => {
const requesterId = 'requester1';
@ -183,7 +393,6 @@ describe('TaskBroker', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const failSpy = vi.spyOn(taskBroker as any, 'failTask');
const rejectSpy = vi.spyOn(taskBroker, 'handleRunnerReject');
taskBroker.registerRunner(runner, messageCallback);
taskBroker.setTasks({
@ -194,10 +403,7 @@ describe('TaskBroker', () => {
taskBroker.deregisterRunner(runnerId, error);
expect(failSpy).toBeCalledWith(taskId, error);
expect(rejectSpy).toBeCalledWith(
taskId,
`The Task Runner (${runnerId}) has disconnected: error`,
);
expect(failSpy).not.toBeCalledWith('task2', expect.anything());
});
});
@ -490,7 +696,7 @@ describe('TaskBroker', () => {
const accept = vi.fn();
const reject = vi.fn();
taskBroker.setRunnerAcceptRejects({ [taskId]: { accept, reject } });
taskBroker.setRunnerAcceptRejects({ [taskId]: { accept, reject, runnerId } });
taskBroker.registerRunner(mock<TaskRunner>({ id: runnerId }), vi.fn());
await taskBroker.onRunnerMessage(runnerId, message);
@ -516,7 +722,7 @@ describe('TaskBroker', () => {
const accept = vi.fn();
const reject = vi.fn();
taskBroker.setRunnerAcceptRejects({ [taskId]: { accept, reject } });
taskBroker.setRunnerAcceptRejects({ [taskId]: { accept, reject, runnerId } });
taskBroker.registerRunner(mock<TaskRunner>({ id: runnerId }), vi.fn());
await taskBroker.onRunnerMessage(runnerId, message);
@ -896,6 +1102,59 @@ describe('TaskBroker', () => {
expect(taskBroker.getTasks().get(taskId)).toBeUndefined();
});
it('on failing a task, we should clear timeout', async () => {
const taskId = 'task1';
const runnerId = 'runner1';
const requesterId = 'requester1';
taskBroker.registerRequester(requesterId, vi.fn());
taskBroker.setTasks({
[taskId]: {
id: taskId,
runnerId,
requesterId,
taskType: 'test',
timeout: setTimeout(() => {}, config.taskTimeout * Time.seconds.toMilliseconds),
},
});
const armedTimers = vi.getTimerCount();
taskBroker.deregisterRunner(runnerId, new Error('Runner died'));
await Promise.resolve();
expect(vi.getTimerCount()).toBe(armedTimers - 1);
expect(taskBroker.getTasks().get(taskId)).toBeUndefined();
});
it('on cancelling a task, we should clear timeout', async () => {
const taskId = 'task1';
const runnerId = 'runner1';
const requesterId = 'requester1';
taskBroker.registerRunner(mock<TaskRunner>({ id: runnerId }), vi.fn());
taskBroker.setTasks({
[taskId]: {
id: taskId,
runnerId,
requesterId,
taskType: 'test',
timeout: setTimeout(() => {}, config.taskTimeout * Time.seconds.toMilliseconds),
},
});
const armedTimers = vi.getTimerCount();
await taskBroker.onRequesterMessage(requesterId, {
type: 'requester:taskcancel',
taskId,
reason: 'Cancelled by requester',
});
expect(vi.getTimerCount()).toBe(armedTimers - 1);
expect(taskBroker.getTasks().get(taskId)).toBeUndefined();
});
it('[internal mode] on timeout, we should emit `runner:timed-out-during-task` event and send error to requester', async () => {
vi.spyOn(global, 'clearTimeout');
@ -983,22 +1242,38 @@ describe('TaskBroker', () => {
});
describe('task runner accept timeout', () => {
const ACCEPT_TIMEOUT_MS = 2100;
// a failing assertion must not leak fake timers into later tests
afterEach(() => {
vi.useRealTimers();
});
const offerFor = (runnerId: string, offerId: string): TaskOffer => ({
offerId,
runnerId,
taskType: 'taskType1',
validFor: 10_000,
validUntil: createValidUntil(10_000),
});
const expectAcceptTimeout = async (offer: TaskOffer, request: TaskRequest) => {
vi.useFakeTimers();
const acceptPromise = taskBroker.acceptOffer(offer, request);
vi.advanceTimersByTime(ACCEPT_TIMEOUT_MS);
await acceptPromise;
};
it('broker should handle timeout when waiting for acknowledgment of offer accept', async () => {
const runnerId = 'runner1';
const runner = mock<TaskRunner>({ id: runnerId });
const messageCallback = vi.fn();
const loggerMock = mock<Logger>();
taskBroker = new TaskBroker(loggerMock, mock(), mock(), mock());
taskBroker.registerRunner(runner, messageCallback);
const offer: TaskOffer = {
offerId: 'offer1',
runnerId,
taskType: 'taskType1',
validFor: 1000,
validUntil: createValidUntil(1000),
};
taskBroker = new TaskBroker(
loggerMock,
mock<TaskRunnersConfig>({ taskRequestTimeout: 60, taskAcceptTimeout: 2 }),
mock(),
mock(),
);
taskBroker.registerRunner(mock<TaskRunner>({ id: 'runner1' }), vi.fn());
const request: TaskRequest = {
requestId: 'request1',
@ -1006,26 +1281,390 @@ describe('TaskBroker', () => {
taskType: 'taskType1',
};
vi.useFakeTimers();
const acceptPromise = taskBroker.acceptOffer(offer, request);
vi.advanceTimersByTime(2100);
await acceptPromise;
await expectAcceptTimeout(offerFor('runner1', 'offer1'), request);
expect(request.acceptInProgress).toBe(false);
expect(loggerMock.warn).toHaveBeenCalledWith(
expect.stringContaining(
`Runner (${runnerId}) took too long to acknowledge acceptance of task`,
),
expect.stringContaining('Runner (runner1) took too long to acknowledge acceptance of task'),
);
});
it('should discard the unresponsive runner remaining offers and retry the request', async () => {
const deadRunnerCallback = vi.fn();
const liveRunnerCallback = vi.fn();
taskBroker = new TaskBroker(
mock<Logger>(),
mock<TaskRunnersConfig>({ taskRequestTimeout: 60, taskAcceptTimeout: 2 }),
mock(),
mock(),
);
taskBroker.registerRunner(mock<TaskRunner>({ id: 'deadRunner' }), deadRunnerCallback);
taskBroker.registerRunner(mock<TaskRunner>({ id: 'liveRunner' }), liveRunnerCallback);
const matchedOffer = offerFor('deadRunner', 'deadOffer1');
const request: TaskRequest = {
requestId: 'request1',
requesterId: 'requester1',
taskType: 'taskType1',
acceptInProgress: true,
};
taskBroker.setPendingTaskRequests([request]);
taskBroker.setPendingTaskOffers([
offerFor('deadRunner', 'deadOffer2'),
offerFor('liveRunner', 'liveOffer1'),
]);
await expectAcceptTimeout(matchedOffer, request);
expect(taskBroker.getPendingTaskOffers()).toHaveLength(0);
expect(liveRunnerCallback).toHaveBeenCalledWith(
expect.objectContaining({ type: 'broker:taskofferaccept', offerId: 'liveOffer1' }),
);
});
it('should release the task on the runner that failed to acknowledge', async () => {
const runnerCallback = vi.fn();
taskBroker = new TaskBroker(
mock<Logger>(),
mock<TaskRunnersConfig>({ taskRequestTimeout: 60, taskAcceptTimeout: 2 }),
mock(),
mock(),
);
taskBroker.registerRunner(mock<TaskRunner>({ id: 'runner1' }), runnerCallback);
await expectAcceptTimeout(offerFor('runner1', 'offer1'), {
requestId: 'request1',
requesterId: 'requester1',
taskType: 'taskType1',
});
expect(runnerCallback).toHaveBeenCalledWith(
expect.objectContaining({ type: 'broker:taskcancel' }),
);
});
it('should stop tracking the acknowledgment that timed out', async () => {
taskBroker = new TaskBroker(
mock<Logger>(),
mock<TaskRunnersConfig>({ taskRequestTimeout: 60, taskAcceptTimeout: 2 }),
mock(),
mock(),
);
taskBroker.registerRunner(mock<TaskRunner>({ id: 'runner1' }), vi.fn());
await expectAcceptTimeout(offerFor('runner1', 'offer1'), {
requestId: 'request1',
requesterId: 'requester1',
taskType: 'taskType1',
});
expect(taskBroker.getRunnerAcceptRejects().size).toBe(0);
});
it('should wait for the configured acknowledgment window before timing out', async () => {
vi.useFakeTimers();
const loggerMock = mock<Logger>();
taskBroker = new TaskBroker(
loggerMock,
mock<TaskRunnersConfig>({ taskRequestTimeout: 60, taskAcceptTimeout: 5 }),
mock(),
mock(),
);
taskBroker.registerRunner(mock<TaskRunner>({ id: 'runner1' }), vi.fn());
const acceptPromise = taskBroker.acceptOffer(offerFor('runner1', 'offer1'), {
requestId: 'request1',
requesterId: 'requester1',
taskType: 'taskType1',
});
vi.advanceTimersByTime(ACCEPT_TIMEOUT_MS);
expect(loggerMock.warn).not.toHaveBeenCalled();
vi.advanceTimersByTime(3000);
await acceptPromise;
expect(loggerMock.warn).toHaveBeenCalledWith(
expect.stringContaining('took too long to acknowledge'),
);
});
it('should restart the request expiry window when the runner fails to acknowledge', async () => {
vi.useFakeTimers();
const config = mock<TaskRunnersConfig>({ taskRequestTimeout: 60, taskAcceptTimeout: 2 });
taskBroker = new TaskBroker(mock(), config, mock(), mock());
taskBroker.registerRunner(mock<TaskRunner>({ id: 'runner1' }), vi.fn());
const requesterCallback = vi.fn();
taskBroker.registerRequester('requester1', requesterCallback);
const request: TaskRequest = {
requestId: 'request1',
requesterId: 'requester1',
taskType: 'taskType1',
timeout: taskBroker['createRequestTimeout']('request1'),
acceptInProgress: true,
};
taskBroker.setPendingTaskRequests([request]);
// most of the window elapses before the matched runner fails to acknowledge
vi.advanceTimersByTime(55_000);
const acceptPromise = taskBroker.acceptOffer(offerFor('runner1', 'offer1'), request);
vi.advanceTimersByTime(ACCEPT_TIMEOUT_MS);
await acceptPromise;
// without the restart, the original window would have expired by now
vi.advanceTimersByTime(30_000);
expect(taskBroker.getPendingTaskRequests()).toHaveLength(1);
vi.advanceTimersByTime(60_000);
expect(taskBroker.getPendingTaskRequests()).toHaveLength(0);
expect(requesterCallback).toHaveBeenCalledWith({
type: 'broker:requestexpired',
requestId: 'request1',
reason: 'timeout',
});
});
it('should stop restarting the expiry window after repeated acknowledgment failures', async () => {
vi.useFakeTimers();
const config = mock<TaskRunnersConfig>({ taskRequestTimeout: 60, taskAcceptTimeout: 2 });
taskBroker = new TaskBroker(mock(), config, mock(), mock());
taskBroker.registerRunner(mock<TaskRunner>({ id: 'runner1' }), vi.fn());
const request: TaskRequest = {
requestId: 'request1',
requesterId: 'requester1',
taskType: 'taskType1',
acceptInProgress: true,
};
taskBroker.setPendingTaskRequests([request]);
const timeoutAfterAcceptFailure = async (offerId: string) => {
const acceptPromise = taskBroker.acceptOffer(offerFor('runner1', offerId), request);
vi.advanceTimersByTime(ACCEPT_TIMEOUT_MS);
await acceptPromise;
return request.timeout;
};
let previousTimeout = await timeoutAfterAcceptFailure('offer1');
expect(previousTimeout).toBeDefined();
for (const offerId of ['offer2', 'offer3']) {
const refreshedTimeout = await timeoutAfterAcceptFailure(offerId);
expect(refreshedTimeout).not.toBe(previousTimeout);
previousTimeout = refreshedTimeout;
}
expect(await timeoutAfterAcceptFailure('offer4')).toBe(previousTimeout);
});
it('should not restart the expiry window when the request already expired', async () => {
taskBroker = new TaskBroker(
mock<Logger>(),
mock<TaskRunnersConfig>({ taskRequestTimeout: 60, taskAcceptTimeout: 2 }),
mock(),
mock(),
);
taskBroker.registerRunner(mock<TaskRunner>({ id: 'runner1' }), vi.fn());
// the request expired during acceptance, so it is no longer pending
const request: TaskRequest = {
requestId: 'request1',
requesterId: 'requester1',
taskType: 'taskType1',
acceptInProgress: true,
};
await expectAcceptTimeout(offerFor('runner1', 'offer1'), request);
expect(request.timeoutRefreshes).toBeUndefined();
expect(request.timeout).toBeUndefined();
expect(vi.getTimerCount()).toBe(0);
});
});
describe('acceptOffer', () => {
// a failing assertion must not leak fake timers into later tests
afterEach(() => {
vi.useRealTimers();
});
const offerFor = (runnerId: string, offerId: string): TaskOffer => ({
offerId,
runnerId,
taskType: 'taskType1',
validFor: 10_000,
validUntil: createValidUntil(10_000),
});
const acknowledgingRunnerCallback = () =>
vi.fn((message: BrokerMessage.ToRunner.All) => {
if (message.type === 'broker:taskofferaccept') {
taskBroker.handleRunnerAccept(message.taskId);
}
});
it('should cancel the task toward the runner when the request expired during acceptance', async () => {
const runnerCallback = acknowledgingRunnerCallback();
taskBroker.registerRunner(mock<TaskRunner>({ id: 'runner1' }), runnerCallback);
// the request is no longer pending by the time the runner acknowledges
await taskBroker.acceptOffer(offerFor('runner1', 'offer1'), {
requestId: 'request1',
requesterId: 'requester1',
taskType: 'taskType1',
acceptInProgress: true,
});
expect(runnerCallback).toHaveBeenCalledWith(
expect.objectContaining({ type: 'broker:taskcancel', reason: 'Task request expired' }),
);
expect(taskBroker.getTasks().size).toBe(0);
});
it('should not leave acknowledgment timers armed after a successful accept flow', async () => {
vi.useFakeTimers();
const config = mock<TaskRunnersConfig>({
taskRequestTimeout: 60,
taskTimeout: 60,
taskAcceptTimeout: 2,
});
taskBroker = new TaskBroker(mock(), config, mock(), mock());
const runnerCallback = acknowledgingRunnerCallback();
const requesterCallback = vi.fn((message: BrokerMessage.ToRequester.All) => {
if (message.type === 'broker:taskready') {
taskBroker.handleRequesterAccept(message.taskId, {});
}
});
taskBroker.registerRunner(mock<TaskRunner>({ id: 'runner1' }), runnerCallback);
taskBroker.registerRequester('requester1', requesterCallback);
const request: TaskRequest = {
requestId: 'request1',
requesterId: 'requester1',
taskType: 'taskType1',
acceptInProgress: true,
};
taskBroker.setPendingTaskRequests([request]);
await taskBroker.acceptOffer(offerFor('runner1', 'offer1'), request);
// only the task execution timeout remains armed
expect(vi.getTimerCount()).toBe(1);
});
it('should cancel the task and stop tracking it when the requester fails to acknowledge', async () => {
vi.useFakeTimers();
const runnerCallback = acknowledgingRunnerCallback();
taskBroker.registerRunner(mock<TaskRunner>({ id: 'runner1' }), runnerCallback);
taskBroker.registerRequester('requester1', vi.fn());
const request: TaskRequest = {
requestId: 'request1',
requesterId: 'requester1',
taskType: 'taskType1',
acceptInProgress: true,
};
taskBroker.setPendingTaskRequests([request]);
const acceptPromise = taskBroker.acceptOffer(offerFor('runner1', 'offer1'), request);
await vi.advanceTimersByTimeAsync(2100);
// resolves instead of leaking the rejection through the void'ed caller
await acceptPromise;
expect(taskBroker.getTasks().size).toBe(0);
expect(taskBroker.getRequesterAcceptRejects().size).toBe(0);
expect(runnerCallback).toHaveBeenCalledWith(
expect.objectContaining({
type: 'broker:taskcancel',
reason: 'Requester took too long to acknowledge the task',
}),
);
expect(new TaskRequesterAcceptTimeoutError('task1', 'requester1').level).toBe('warning');
});
it('should stop restarting the expiry window after repeated deferrals', async () => {
vi.useFakeTimers();
const deferringRunnerCallback = vi.fn((message: BrokerMessage.ToRunner.All) => {
if (message.type === 'broker:taskofferaccept') {
taskBroker.handleRunnerDeferred(message.taskId);
}
});
taskBroker.registerRunner(mock<TaskRunner>({ id: 'runner1' }), deferringRunnerCallback);
const request: TaskRequest = {
requestId: 'request1',
requesterId: 'requester1',
taskType: 'taskType1',
acceptInProgress: true,
};
taskBroker.setPendingTaskRequests([request]);
const deferOnce = async (offerId: string) => {
await taskBroker.acceptOffer(offerFor('runner1', offerId), request);
return request.timeout;
};
let previousTimeout = await deferOnce('offer1');
expect(previousTimeout).toBeDefined();
for (const offerId of ['offer2', 'offer3']) {
const refreshedTimeout = await deferOnce(offerId);
expect(refreshedTimeout).not.toBe(previousTimeout);
previousTimeout = refreshedTimeout;
}
expect(await deferOnce('offer4')).toBe(previousTimeout);
});
it('should reject an acceptance awaiting acknowledgment when its runner deregisters', async () => {
vi.useFakeTimers();
const liveRunnerCallback = vi.fn();
taskBroker.registerRunner(mock<TaskRunner>({ id: 'deadRunner' }), vi.fn());
taskBroker.registerRunner(mock<TaskRunner>({ id: 'liveRunner' }), liveRunnerCallback);
const request: TaskRequest = {
requestId: 'request1',
requesterId: 'requester1',
taskType: 'taskType1',
acceptInProgress: true,
};
taskBroker.setPendingTaskRequests([request]);
taskBroker.setPendingTaskOffers([offerFor('liveRunner', 'liveOffer1')]);
const acceptPromise = taskBroker.acceptOffer(offerFor('deadRunner', 'deadOffer1'), request);
taskBroker.deregisterRunner('deadRunner', new Error('connection lost'));
// resolves without the acknowledgment window elapsing
await acceptPromise;
const pendingAcceptanceRunnerIds = [...taskBroker.getRunnerAcceptRejects().values()].map(
({ runnerId }) => runnerId,
);
expect(pendingAcceptanceRunnerIds).toEqual(['liveRunner']);
expect(liveRunnerCallback).toHaveBeenCalledWith(
expect.objectContaining({ type: 'broker:taskofferaccept', offerId: 'liveOffer1' }),
);
});
});
describe('request timeout', () => {
// a failing assertion must not leak fake timers into later tests
afterEach(() => {
vi.useRealTimers();
});
it('should time out request and send `broker:requestexpired` message', async () => {
vi.useFakeTimers();
@ -1055,8 +1694,6 @@ describe('TaskBroker', () => {
requestId: 'request1',
reason: 'timeout',
});
vi.useRealTimers();
});
it('should clear timeout on request matched', async () => {
@ -1104,8 +1741,6 @@ describe('TaskBroker', () => {
expect(requesterCallback).not.toHaveBeenCalledWith(
expect.objectContaining({ type: 'broker:requestexpired' }),
);
vi.useRealTimers();
});
it('should reset timeout on request deferred', async () => {
@ -1159,7 +1794,6 @@ describe('TaskBroker', () => {
});
handleTimeoutSpy.mockRestore();
vi.useRealTimers();
});
});
});

View File

@ -0,0 +1,7 @@
import { OperationalError } from 'n8n-workflow';
export class TaskRequesterAcceptTimeoutError extends OperationalError {
constructor(taskId: string, requesterId: string) {
super(`Requester (${requesterId}) took too long to acknowledge task (${taskId})`);
}
}

View File

@ -0,0 +1,7 @@
import { OperationalError } from 'n8n-workflow';
export class TaskRunnerUnreachableError extends OperationalError {
constructor(taskId: string, runnerId: string) {
super(`Runner (${runnerId}) became unreachable while processing task (${taskId})`);
}
}

View File

@ -25,6 +25,17 @@ function heartbeat(this: WebSocket) {
type WsStatusCode = (typeof WsStatusCodes)[keyof typeof WsStatusCodes];
type RemoveConnectionOptions = {
reason?: DisconnectReason;
/** Close code sent to the runner. */
code?: WsStatusCode;
/**
* The connection the caller intends to remove. If the runner is registered
* with a different connection by now, the removal is skipped as stale.
*/
expectedConnection?: WebSocket;
};
/**
* Responsible for handling WebSocket connections with task runners
* and monitoring the connection liveness
@ -55,21 +66,32 @@ export class TaskBrokerWsServer {
throw new UserError('Heartbeat interval must be greater than 0');
}
this.heartbeatTimer = setInterval(() => {
for (const [runnerId, connection] of this.runnerConnections.entries()) {
if (!connection.isAlive) {
void this.removeConnection(
runnerId,
'failed-heartbeat-check',
WsStatusCodes.CloseProtocolError,
);
this.runnerLifecycleEvents.emit('runner:failed-heartbeat-check');
return;
}
this.heartbeatTimer = setInterval(
() => this.checkConnectionLiveness(),
heartbeatInterval * Time.seconds.toMilliseconds,
);
}
private checkConnectionLiveness() {
let anyDead = false;
for (const [runnerId, connection] of this.runnerConnections) {
if (connection.isAlive) {
connection.isAlive = false;
connection.ping();
} else {
anyDead = true;
void this.removeConnection(runnerId, {
reason: 'failed-heartbeat-check',
code: WsStatusCodes.CloseProtocolError,
expectedConnection: connection,
});
}
}, heartbeatInterval * Time.seconds.toMilliseconds);
}
if (anyDead) {
this.runnerLifecycleEvents.emit('runner:failed-heartbeat-check');
}
}
async stop() {
@ -111,29 +133,29 @@ export class TaskBrokerWsServer {
buffer.toString('utf8'),
) as RunnerMessage.ToBroker.All;
if (!isConnected && message.type !== 'runner:info') {
return;
} else if (!isConnected && message.type === 'runner:info') {
await this.removeConnection(id);
isConnected = true;
if (!isConnected) {
if (message.type === 'runner:info') {
await this.removeConnection(id);
isConnected = true;
this.runnerConnections.set(id, connection);
this.runnerConnections.set(id, connection);
this.taskBroker.registerRunner(
{
id,
taskTypes: message.types,
lastSeen: new Date(),
name: message.name,
},
this.sendMessage.bind(this, id) as MessageCallback,
);
this.taskBroker.registerRunner(
{
id,
taskTypes: message.types,
lastSeen: new Date(),
name: message.name,
},
this.sendMessage.bind(this, id) as MessageCallback,
() => this.isRunnerReachable(id, connection),
);
this.logger.info(`Registered runner "${message.name}" (${id}) `);
return;
this.logger.info(`Registered runner "${message.name}" (${id}) `);
}
} else if (this.isCurrentConnection(id, connection)) {
void this.taskBroker.onRunnerMessage(id, message);
}
void this.taskBroker.onRunnerMessage(id, message);
} catch (error) {
this.logger.error(`Couldn't parse message from runner "${id}"`, {
error: error as unknown,
@ -147,7 +169,7 @@ export class TaskBrokerWsServer {
connection.once('close', async () => {
connection.off('pong', heartbeat);
connection.off('message', onMessage);
await this.removeConnection(id);
await this.removeConnection(id, { expectedConnection: connection });
});
connection.on('message', onMessage);
@ -158,20 +180,37 @@ export class TaskBrokerWsServer {
async removeConnection(
id: TaskRunner['id'],
reason: DisconnectReason = 'unknown',
code: WsStatusCode = WsStatusCodes.CloseNormal,
{
reason = 'unknown',
code = WsStatusCodes.CloseNormal,
expectedConnection,
}: RemoveConnectionOptions = {},
) {
const connection = this.runnerConnections.get(id);
if (connection) {
const isStaleRemoval =
expectedConnection !== undefined && !this.isCurrentConnection(id, expectedConnection);
if (connection && !isStaleRemoval) {
// Stop routing to the runner before the disconnect analysis, which may be slow.
this.runnerConnections.delete(id);
connection.close(code);
const inFlightTaskIds = this.taskBroker.getInFlightTaskIds(id);
const disconnectError = await this.disconnectAnalyzer.toDisconnectError({
runnerId: id,
reason,
heartbeatInterval: this.taskRunnersConfig.heartbeatInterval,
});
this.taskBroker.deregisterRunner(id, disconnectError);
this.logger.debug(`Deregistered runner "${id}"`);
connection.close(code);
this.runnerConnections.delete(id);
const hasReconnected = this.runnerConnections.has(id);
if (hasReconnected) {
this.taskBroker.failTasks(inFlightTaskIds, disconnectError);
} else {
this.taskBroker.deregisterRunner(id, disconnectError);
this.logger.debug(`Deregistered runner "${id}"`);
}
}
}
@ -179,13 +218,35 @@ export class TaskBrokerWsServer {
this.add(req.query.id, req.ws);
}
/**
* Whether `connection` is the connection the runner is currently registered with. A
* replaced connection keeps delivering the frames it already buffered, and nothing in
* those frames distinguishes them from the current connection's, since both speak for
* the same runner id.
*/
private isCurrentConnection(id: TaskRunner['id'], connection: WebSocket) {
return this.runnerConnections.get(id) === connection;
}
/**
* Whether the runner behind `connection` can still be sent messages. A socket leaves
* `OPEN` as soon as it starts closing, while frames it already buffered keep arriving.
*/
private isRunnerReachable(id: TaskRunner['id'], connection: WebSocket) {
return this.isCurrentConnection(id, connection) && connection.readyState === connection.OPEN;
}
private async stopConnectedRunners() {
await this.drainActiveTasks();
await Promise.all(
Array.from(this.runnerConnections.keys()).map(
async (id) =>
await this.removeConnection(id, 'shutting-down', WsStatusCodes.CloseGoingAway),
Array.from(this.runnerConnections.entries()).map(
async ([id, connection]) =>
await this.removeConnection(id, {
reason: 'shutting-down',
code: WsStatusCodes.CloseGoingAway,
expectedConnection: connection,
}),
),
);
}

View File

@ -13,8 +13,10 @@ import { nanoid } from 'nanoid';
import { TaskDeferredError } from '@/task-runners/task-broker/errors/task-deferred.error';
import { TaskRejectError } from '@/task-runners/task-broker/errors/task-reject.error';
import { TaskRequesterAcceptTimeoutError } from '@/task-runners/task-broker/errors/task-requester-accept-timeout.error';
import { TaskRunnerAcceptTimeoutError } from '@/task-runners/task-broker/errors/task-runner-accept-timeout.error';
import { TaskRunnerExecutionTimeoutError } from '@/task-runners/task-broker/errors/task-runner-execution-timeout.error';
import { TaskRunnerUnreachableError } from '@/task-runners/task-broker/errors/task-runner-unreachable.error';
import { TaskRunnerLifecycleEvents } from '@/task-runners/task-runner-lifecycle-events';
export interface TaskRunner {
@ -42,12 +44,15 @@ export interface TaskOffer {
validUntil: bigint;
}
const MAX_REQUEST_TIMEOUT_REFRESHES = 3;
export interface TaskRequest {
requestId: string;
requesterId: string;
taskType: string;
timeout?: NodeJS.Timeout;
acceptInProgress?: boolean;
timeoutRefreshes?: number;
}
export type MessageCallback = (message: BrokerMessage.ToRunner.All) => Promise<void> | void;
@ -61,11 +66,17 @@ type RequesterAcceptCallback = (
) => void;
type TaskRejectCallback = (reason: TaskRejectError | TaskDeferredError) => void;
export type RunnerReachabilityCheck = () => boolean;
@Service()
export class TaskBroker {
private knownRunners: Map<
TaskRunner['id'],
{ runner: TaskRunner; messageCallback: MessageCallback }
{
runner: TaskRunner;
messageCallback: MessageCallback;
isRunnerReachable: RunnerReachabilityCheck;
}
> = new Map();
private requesters: Map<string, RequesterMessageCallback> = new Map();
@ -79,7 +90,7 @@ export class TaskBroker {
private runnerAcceptRejects: Map<
Task['id'],
{ accept: RunnerAcceptCallback; reject: TaskRejectCallback }
{ accept: RunnerAcceptCallback; reject: TaskRejectCallback; runnerId: TaskRunner['id'] }
> = new Map();
private requesterAcceptRejects: Map<
@ -103,6 +114,12 @@ export class TaskBroker {
if (this.taskRunnersConfig.taskTimeout <= 0) {
throw new UserError('Task timeout must be greater than 0');
}
if (this.taskRunnersConfig.taskRequestTimeout <= 0) {
throw new UserError('Task request timeout must be greater than 0');
}
if (this.taskRunnersConfig.taskAcceptTimeout <= 0) {
throw new UserError('Task accept timeout must be greater than 0');
}
}
private createRequestTimeout(requestId: string): NodeJS.Timeout {
@ -111,6 +128,23 @@ export class TaskBroker {
}, this.taskRunnersConfig.taskRequestTimeout * Time.seconds.toMilliseconds);
}
/**
* Restarts the request's expiry window, so a request that lost part of its window
* to a failure the requester is not responsible for gets a full window for the
* next matching attempt. Capped per request, so a runner that keeps offering but
* never acknowledges cannot postpone the expiry indefinitely. No-op for a request
* that is no longer pending, so an already expired request arms no new timer.
*/
private refreshRequestTimeout(request: TaskRequest) {
const isPending = this.pendingTaskRequests.some((r) => r.requestId === request.requestId);
const refreshes = request.timeoutRefreshes ?? 0;
if (isPending && refreshes < MAX_REQUEST_TIMEOUT_REFRESHES) {
request.timeoutRefreshes = refreshes + 1;
clearTimeout(request.timeout);
request.timeout = this.createRequestTimeout(request.requestId);
}
}
private handleRequestTimeout(requestId: string) {
const requestIndex = this.pendingTaskRequests.findIndex((r) => r.requestId === requestId);
if (requestIndex === -1) return;
@ -128,42 +162,76 @@ export class TaskBroker {
});
}
expireTasks() {
const now = process.hrtime.bigint();
private discardOffers(shouldDiscard: (offer: TaskOffer) => boolean) {
for (let i = this.pendingTaskOffers.length - 1; i >= 0; i--) {
const offer = this.pendingTaskOffers[i];
if (offer.validFor === -1) continue; // non-expiring offer
if (offer.validUntil < now) {
if (shouldDiscard(this.pendingTaskOffers[i])) {
this.pendingTaskOffers.splice(i, 1);
}
}
}
registerRunner(runner: TaskRunner, messageCallback: MessageCallback) {
this.knownRunners.set(runner.id, { runner, messageCallback });
expireTasks() {
const now = process.hrtime.bigint();
this.discardOffers(({ validFor, validUntil }) => validFor !== -1 && validUntil < now);
}
private discardOffersFrom(runnerId: TaskRunner['id']) {
this.discardOffers((offer) => offer.runnerId === runnerId);
}
/**
* Discards offers from runners that are registered but no longer reachable, as matching
* one burns a task request on a runner that can never acknowledge it. Offers from
* unregistered runners are kept, since deregistration already discards them.
*/
private discardOffersFromUnreachableRunners() {
this.discardOffers(
(offer) => this.knownRunners.get(offer.runnerId)?.isRunnerReachable() === false,
);
}
/**
* Registers a runner as eligible for task offers. `isRunnerReachable` is consulted
* before matching any of its offers, and defaults to always reachable. Registration
* outlives reachability: a transport can die while messages it already buffered are
* still being delivered, so offers can enter the pool after the runner is gone.
*/
registerRunner(
runner: TaskRunner,
messageCallback: MessageCallback,
isRunnerReachable: RunnerReachabilityCheck = () => true,
) {
this.knownRunners.set(runner.id, { runner, messageCallback, isRunnerReachable });
void this.knownRunners.get(runner.id)!.messageCallback({ type: 'broker:runnerregistered' });
}
deregisterRunner(runnerId: string, error: Error) {
this.knownRunners.delete(runnerId);
// Remove any pending offers
for (let i = this.pendingTaskOffers.length - 1; i >= 0; i--) {
if (this.pendingTaskOffers[i].runnerId === runnerId) {
this.pendingTaskOffers.splice(i, 1);
}
}
this.discardOffersFrom(runnerId);
// Fail any tasks
for (const task of this.tasks.values()) {
if (task.runnerId === runnerId) {
void this.failTask(task.id, error);
for (const [taskId, acceptReject] of this.runnerAcceptRejects) {
if (acceptReject.runnerId === runnerId) {
this.handleRunnerReject(
task.id,
taskId,
`The Task Runner (${runnerId}) has disconnected: ${error.message}`,
);
}
}
this.failTasks(this.getInFlightTaskIds(runnerId), error);
}
getInFlightTaskIds(runnerId: TaskRunner['id']): Array<Task['id']> {
return [...this.tasks.values()]
.filter((task) => task.runnerId === runnerId)
.map((task) => task.id);
}
failTasks(taskIds: Array<Task['id']>, error: Error) {
for (const taskId of taskIds) {
void this.failTask(taskId, error);
}
}
registerRequester(requesterId: string, messageCallback: RequesterMessageCallback) {
@ -425,32 +493,45 @@ export class TaskBroker {
}
}
private async cancelTask(taskId: Task['id'], reason: string) {
/**
* Stops tracking the task and disarms its execution timeout.
* @returns the task that was being tracked, or `undefined` if it was already settled.
* @remarks A task's timeout outlives the task itself unless disarmed, so the two are dropped together.
*/
private untrackTask(taskId: Task['id']) {
const task = this.tasks.get(taskId);
if (!task) {
return;
}
this.tasks.delete(taskId);
await this.messageRunner(task.runnerId, {
type: 'broker:taskcancel',
taskId,
reason,
});
if (task) {
clearTimeout(task.timeout);
this.tasks.delete(taskId);
}
return task;
}
private async cancelTask(taskId: Task['id'], reason: string) {
const task = this.untrackTask(taskId);
if (task) {
await this.messageRunner(task.runnerId, {
type: 'broker:taskcancel',
taskId,
reason,
});
}
}
private async failTask(taskId: Task['id'], error: Error) {
const task = this.tasks.get(taskId);
if (!task) {
return;
const task = this.untrackTask(taskId);
if (task) {
// TODO: special message type?
await this.messageRequester(task.requesterId, {
type: 'broker:taskerror',
taskId,
error,
});
}
this.tasks.delete(taskId);
// TODO: special message type?
await this.messageRequester(task.requesterId, {
type: 'broker:taskerror',
taskId,
error,
});
}
private async getRunnerOrFailTask(taskId: Task['id']): Promise<TaskRunner> {
@ -466,6 +547,11 @@ export class TaskBroker {
await this.failTask(taskId, error);
throw error;
}
if (!runner.isRunnerReachable()) {
const error = new TaskRunnerUnreachableError(taskId, task.runnerId);
await this.failTask(taskId, error);
throw error;
}
return runner.runner;
}
@ -514,44 +600,44 @@ export class TaskBroker {
}
async taskDoneHandler(taskId: Task['id'], data: TaskResultData) {
const task = this.tasks.get(taskId);
if (!task) return;
const task = this.untrackTask(taskId);
clearTimeout(task.timeout);
await this.requesters.get(task.requesterId)?.({
type: 'broker:taskdone',
taskId: task.id,
data,
});
this.tasks.delete(task.id);
if (task) {
await this.requesters.get(task.requesterId)?.({
type: 'broker:taskdone',
taskId: task.id,
data,
});
}
}
async taskErrorHandler(taskId: Task['id'], error: unknown) {
const task = this.tasks.get(taskId);
if (!task) return;
const task = this.untrackTask(taskId);
clearTimeout(task.timeout);
await this.requesters.get(task.requesterId)?.({
type: 'broker:taskerror',
taskId: task.id,
error,
});
this.tasks.delete(task.id);
if (task) {
await this.requesters.get(task.requesterId)?.({
type: 'broker:taskerror',
taskId: task.id,
error,
});
}
}
async acceptOffer(offer: TaskOffer, request: TaskRequest): Promise<void> {
const taskId = nanoid(8);
let runnerAcceptTimer: NodeJS.Timeout | undefined;
try {
const acceptPromise = new Promise((resolve, reject) => {
this.runnerAcceptRejects.set(taskId, { accept: resolve as () => void, reject });
this.runnerAcceptRejects.set(taskId, {
accept: resolve as () => void,
reject,
runnerId: offer.runnerId,
});
// TODO: customisable timeout
setTimeout(() => {
runnerAcceptTimer = setTimeout(() => {
reject(new TaskRunnerAcceptTimeoutError(taskId, offer.runnerId));
}, 2000);
}, this.taskRunnersConfig.taskAcceptTimeout * Time.seconds.toMilliseconds);
});
await this.messageRunner(offer.runnerId, {
@ -565,19 +651,34 @@ export class TaskBroker {
request.acceptInProgress = false;
if (e instanceof TaskRejectError) {
this.logger.info(`Task (${taskId}) rejected by Runner with reason "${e.reason}"`);
this.settleTasks();
return;
}
if (e instanceof TaskDeferredError) {
this.logger.debug(`Task (${taskId}) deferred until runner is ready`);
clearTimeout(request.timeout);
request.timeout = this.createRequestTimeout(request.requestId);
this.refreshRequestTimeout(request);
return;
}
if (e instanceof TaskRunnerAcceptTimeoutError) {
this.logger.warn(e.message);
this.runnerAcceptRejects.delete(taskId);
this.refreshRequestTimeout(request);
// The runner may have created the task before its acknowledgement timed out,
// holding one of its concurrency slots until the task's own timeout elapses.
await this.messageRunner(offer.runnerId, {
type: 'broker:taskcancel',
taskId,
reason: 'Acknowledgement window elapsed',
});
// A runner missing the acknowledgement window may be gone without its transport
// having reported it yet, so its remaining offers cannot be trusted either.
this.discardOffersFrom(offer.runnerId);
this.settleTasks();
return;
}
throw e;
} finally {
clearTimeout(runnerAcceptTimer);
}
clearTimeout(request.timeout);
@ -594,13 +695,16 @@ export class TaskBroker {
(r) => r.requestId === request.requestId,
);
if (requestIndex === -1) {
this.logger.error(
`Failed to find task request (${request.requestId}) after a task was accepted. This shouldn't happen, and might be a race condition.`,
this.logger.warn(
`Task request (${request.requestId}) expired while runner (${offer.runnerId}) was accepting task (${taskId}), canceling task`,
);
await this.cancelTask(taskId, 'Task request expired');
return;
}
this.pendingTaskRequests.splice(requestIndex, 1);
let requesterAcceptTimer: NodeJS.Timeout | undefined;
try {
const acceptPromise = new Promise<RequesterMessage.ToBroker.TaskSettings['settings']>(
(resolve, reject) => {
@ -611,10 +715,9 @@ export class TaskBroker {
reject,
});
// TODO: customisable timeout
setTimeout(() => {
reject('Requester timed out');
}, 2000);
requesterAcceptTimer = setTimeout(() => {
reject(new TaskRequesterAcceptTimeoutError(taskId, request.requesterId));
}, this.taskRunnersConfig.taskAcceptTimeout * Time.seconds.toMilliseconds);
},
);
@ -632,8 +735,20 @@ export class TaskBroker {
this.logger.info(`Task (${taskId}) rejected by Requester with reason "${e.reason}"`);
return;
}
if (e instanceof TaskRunnerUnreachableError) {
this.logger.warn(e.message);
return;
}
if (e instanceof TaskRequesterAcceptTimeoutError) {
this.logger.warn(e.message);
this.requesterAcceptRejects.delete(taskId);
await this.cancelTask(task.id, 'Requester took too long to acknowledge the task');
return;
}
await this.cancelTask(task.id, 'Unknown reason');
throw e;
} finally {
clearTimeout(requesterAcceptTimer);
}
}
@ -647,6 +762,7 @@ export class TaskBroker {
// lists
settleTasks() {
this.expireTasks();
this.discardOffersFromUnreachableRunners();
for (const request of this.pendingTaskRequests) {
if (request.acceptInProgress) {
@ -737,6 +853,10 @@ export class TaskBroker {
return this.runnerAcceptRejects;
}
getRequesterAcceptRejects() {
return this.requesterAcceptRejects;
}
setTasks(tasks: Record<string, Task>) {
this.tasks = new Map(Object.entries(tasks));
}
@ -752,7 +872,7 @@ export class TaskBroker {
setRunnerAcceptRejects(
runnerAcceptRejects: Record<
string,
{ accept: RunnerAcceptCallback; reject: TaskRejectCallback }
{ accept: RunnerAcceptCallback; reject: TaskRejectCallback; runnerId: TaskRunner['id'] }
>,
) {
this.runnerAcceptRejects = new Map(Object.entries(runnerAcceptRejects));

View File

@ -55,7 +55,10 @@ export type RunnerStatus = { available: true } | { available: false; reason?: st
@Service()
export abstract class TaskRequester {
requestAcceptRejects: Map<string, { accept: RequestAccept; reject: RequestReject }> = new Map();
requestAcceptRejects: Map<
string,
{ accept: RequestAccept; reject: RequestReject; requestedAt: number }
> = new Map();
taskAcceptRejects: Map<string, { accept: TaskAccept; reject: TaskReject }> = new Map();
@ -136,6 +139,7 @@ export abstract class TaskRequester {
this.requestAcceptRejects.set(request.requestId, {
accept: resolve,
reject,
requestedAt: Date.now(),
});
});
@ -301,14 +305,17 @@ export abstract class TaskRequester {
const acceptReject = this.requestAcceptRejects.get(requestId);
if (!acceptReject) return;
const elapsedSeconds = Math.round((Date.now() - acceptReject.requestedAt) / 1000);
const error = new TaskRequestTimeoutError({
timeout: this.taskRunnersConfig.taskRequestTimeout,
elapsedSeconds,
isSelfHosted: this.globalConfig.deployment.type !== 'cloud',
});
this.errorReporter.error('Task request timed out', {
extra: {
requestId,
elapsedSeconds,
timeout: this.taskRunnersConfig.taskRequestTimeout,
deploymentType: this.globalConfig.deployment.type,
},