mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
perf(core): Launch runners on demand and shut down if idle
This commit is contained in:
parent
471921dc20
commit
b18a3e269d
|
@ -50,4 +50,12 @@ export class TaskRunnersConfig {
|
||||||
/** How many concurrent tasks can a runner execute at a time */
|
/** How many concurrent tasks can a runner execute at a time */
|
||||||
@Env('N8N_RUNNERS_MAX_CONCURRENCY')
|
@Env('N8N_RUNNERS_MAX_CONCURRENCY')
|
||||||
maxConcurrency: number = 5;
|
maxConcurrency: number = 5;
|
||||||
|
|
||||||
|
/** How long (in minutes) until shutting down an idle runner. */
|
||||||
|
@Env('N8N_RUNNERS_IDLE_TIMEOUT')
|
||||||
|
idleTimeout: number = 5;
|
||||||
|
|
||||||
|
/** How often (in minutes) to check if a runner is idle. */
|
||||||
|
@Env('N8N_RUNNERS_IDLE_CHECKS_FREQUENCY')
|
||||||
|
idleChecksFrequency: number = 1;
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,6 +20,7 @@ function createSignalHandler(signal: string) {
|
||||||
if (runner) {
|
if (runner) {
|
||||||
await runner.stop();
|
await runner.stop();
|
||||||
runner = undefined;
|
runner = undefined;
|
||||||
|
console.log('Task runner stopped');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const error = ensureError(e);
|
const error = ensureError(e);
|
||||||
|
|
112
packages/cli/src/runners/runner-lifecycle-manager.ts
Normal file
112
packages/cli/src/runners/runner-lifecycle-manager.ts
Normal file
|
@ -0,0 +1,112 @@
|
||||||
|
import { TaskRunnersConfig } from '@n8n/config';
|
||||||
|
import { strict } from 'node:assert';
|
||||||
|
import { Service } from 'typedi';
|
||||||
|
|
||||||
|
// import { Time } from '@/constants';
|
||||||
|
import { OnShutdown } from '@/decorators/on-shutdown';
|
||||||
|
import { Logger } from '@/logging/logger.service';
|
||||||
|
import { TaskRunnerProcess } from '@/runners/task-runner-process';
|
||||||
|
import { TypedEmitter } from '@/typed-emitter';
|
||||||
|
|
||||||
|
export type RunnerLifecycleEventMap = {
|
||||||
|
'runner:started': never;
|
||||||
|
'runner:stopped': never;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Service()
|
||||||
|
export class RunnerLifecycleEvents extends TypedEmitter<RunnerLifecycleEventMap> {}
|
||||||
|
|
||||||
|
@Service()
|
||||||
|
export class RunnerLifecycleManager {
|
||||||
|
private state: 'stopped' | 'starting' | 'running' | 'stopping' = 'stopped';
|
||||||
|
|
||||||
|
private startPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
private lastActivityTime: number = Date.now();
|
||||||
|
|
||||||
|
private idleChecksInterval: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly logger: Logger,
|
||||||
|
private readonly taskRunnerProcess: TaskRunnerProcess,
|
||||||
|
readonly runnerConfig: TaskRunnersConfig,
|
||||||
|
private readonly lifecycleEvents: RunnerLifecycleEvents,
|
||||||
|
) {
|
||||||
|
const { mode } = runnerConfig;
|
||||||
|
|
||||||
|
strict(
|
||||||
|
mode === 'internal_childprocess' || mode === 'internal_launcher',
|
||||||
|
'Runner mode must be `internal_childprocess` or `internal_launcher`',
|
||||||
|
);
|
||||||
|
|
||||||
|
this.startIdleChecks();
|
||||||
|
}
|
||||||
|
|
||||||
|
async ensureRunnerAvailable() {
|
||||||
|
if (this.state === 'running') return;
|
||||||
|
|
||||||
|
if (this.state === 'starting') return await this.startPromise;
|
||||||
|
|
||||||
|
this.state = 'starting';
|
||||||
|
|
||||||
|
this.startPromise = this.startRunnerProcess().finally(() => {
|
||||||
|
this.startPromise = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return await this.startPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateLastActivityTime() {
|
||||||
|
this.lastActivityTime = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async startRunnerProcess() {
|
||||||
|
try {
|
||||||
|
this.logger.debug('Starting task runner process');
|
||||||
|
await this.taskRunnerProcess.start();
|
||||||
|
|
||||||
|
this.lifecycleEvents.emit('runner:started');
|
||||||
|
|
||||||
|
this.state = 'running';
|
||||||
|
this.lastActivityTime = Date.now();
|
||||||
|
} catch (error) {
|
||||||
|
this.state = 'stopped';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private startIdleChecks() {
|
||||||
|
// const idleTimeout = this.runnerConfig.idleTimeout * Time.minutes.toMilliseconds;
|
||||||
|
// const idleChecksFrequency = this.runnerConfig.idleChecksFrequency * Time.minutes.toMilliseconds;
|
||||||
|
|
||||||
|
const idleTimeout = 10_000;
|
||||||
|
const idleChecksFrequency = 10_000;
|
||||||
|
|
||||||
|
this.idleChecksInterval = setInterval(() => {
|
||||||
|
if (this.state === 'running' && Date.now() - this.lastActivityTime > idleTimeout) {
|
||||||
|
this.logger.info('Runner has been idle for too long, stopping it');
|
||||||
|
void this.stopRunner();
|
||||||
|
}
|
||||||
|
}, idleChecksFrequency);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async stopRunner() {
|
||||||
|
if (this.state !== 'running') return;
|
||||||
|
|
||||||
|
this.state = 'stopping';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.taskRunnerProcess.stop();
|
||||||
|
this.lifecycleEvents.emit('runner:stopped');
|
||||||
|
} finally {
|
||||||
|
this.state = 'stopped';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnShutdown()
|
||||||
|
async shutdown() {
|
||||||
|
if (this.idleChecksInterval) clearInterval(this.idleChecksInterval);
|
||||||
|
|
||||||
|
await this.stopRunner();
|
||||||
|
}
|
||||||
|
}
|
|
@ -4,7 +4,7 @@ import type WebSocket from 'ws';
|
||||||
|
|
||||||
import { Logger } from '@/logging/logger.service';
|
import { Logger } from '@/logging/logger.service';
|
||||||
|
|
||||||
import { DefaultTaskRunnerDisconnectAnalyzer } from './default-task-runner-disconnect-analyzer';
|
import type { DefaultTaskRunnerDisconnectAnalyzer } from './default-task-runner-disconnect-analyzer';
|
||||||
import type {
|
import type {
|
||||||
DisconnectAnalyzer,
|
DisconnectAnalyzer,
|
||||||
TaskRunnerServerInitRequest,
|
TaskRunnerServerInitRequest,
|
||||||
|
@ -23,10 +23,10 @@ export class TaskRunnerWsServer {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly logger: Logger,
|
private readonly logger: Logger,
|
||||||
private readonly taskBroker: TaskBroker,
|
private readonly taskBroker: TaskBroker,
|
||||||
private disconnectAnalyzer: DefaultTaskRunnerDisconnectAnalyzer,
|
private disconnectAnalyzer: DefaultTaskRunnerDisconnectAnalyzer | undefined,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
setDisconnectAnalyzer(disconnectAnalyzer: DisconnectAnalyzer) {
|
setDisconnectAnalyzer(disconnectAnalyzer: DisconnectAnalyzer | undefined) {
|
||||||
this.disconnectAnalyzer = disconnectAnalyzer;
|
this.disconnectAnalyzer = disconnectAnalyzer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -99,7 +99,7 @@ export class TaskRunnerWsServer {
|
||||||
|
|
||||||
async removeConnection(id: TaskRunner['id']) {
|
async removeConnection(id: TaskRunner['id']) {
|
||||||
const connection = this.runnerConnections.get(id);
|
const connection = this.runnerConnections.get(id);
|
||||||
if (connection) {
|
if (connection && this.disconnectAnalyzer) {
|
||||||
const disconnectReason = await this.disconnectAnalyzer.determineDisconnectReason(id);
|
const disconnectReason = await this.disconnectAnalyzer.determineDisconnectReason(id);
|
||||||
this.taskBroker.deregisterRunner(id, disconnectReason);
|
this.taskBroker.deregisterRunner(id, disconnectReason);
|
||||||
connection.close();
|
connection.close();
|
||||||
|
|
|
@ -4,13 +4,14 @@ import type {
|
||||||
RunnerMessage,
|
RunnerMessage,
|
||||||
TaskResultData,
|
TaskResultData,
|
||||||
} from '@n8n/task-runner';
|
} from '@n8n/task-runner';
|
||||||
import { ApplicationError } from 'n8n-workflow';
|
import { ApplicationError, ensureError } from 'n8n-workflow';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import { Service } from 'typedi';
|
import { Service } from 'typedi';
|
||||||
|
|
||||||
import { Logger } from '@/logging/logger.service';
|
import { Logger } from '@/logging/logger.service';
|
||||||
|
|
||||||
import { TaskRejectError } from './errors';
|
import { TaskRejectError } from './errors';
|
||||||
|
import { RunnerLifecycleManager } from './runner-lifecycle-manager';
|
||||||
|
|
||||||
export interface TaskRunner {
|
export interface TaskRunner {
|
||||||
id: string;
|
id: string;
|
||||||
|
@ -78,7 +79,10 @@ export class TaskBroker {
|
||||||
|
|
||||||
private pendingTaskRequests: TaskRequest[] = [];
|
private pendingTaskRequests: TaskRequest[] = [];
|
||||||
|
|
||||||
constructor(private readonly logger: Logger) {}
|
constructor(
|
||||||
|
private readonly logger: Logger,
|
||||||
|
private readonly lifecycleManager: RunnerLifecycleManager,
|
||||||
|
) {}
|
||||||
|
|
||||||
expireTasks() {
|
expireTasks() {
|
||||||
const now = process.hrtime.bigint();
|
const now = process.hrtime.bigint();
|
||||||
|
@ -269,7 +273,7 @@ export class TaskBroker {
|
||||||
await this.cancelTask(message.taskId, message.reason);
|
await this.cancelTask(message.taskId, message.reason);
|
||||||
break;
|
break;
|
||||||
case 'requester:taskrequest':
|
case 'requester:taskrequest':
|
||||||
this.taskRequested({
|
await this.taskRequested({
|
||||||
taskType: message.taskType,
|
taskType: message.taskType,
|
||||||
requestId: message.requestId,
|
requestId: message.requestId,
|
||||||
requesterId,
|
requesterId,
|
||||||
|
@ -553,7 +557,18 @@ export class TaskBroker {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
taskRequested(request: TaskRequest) {
|
async taskRequested(request: TaskRequest) {
|
||||||
|
try {
|
||||||
|
await this.lifecycleManager.ensureRunnerAvailable();
|
||||||
|
} catch (e) {
|
||||||
|
const error = ensureError(e);
|
||||||
|
this.logger.error('Failed to start task runner', { error });
|
||||||
|
this.handleRunnerReject(request.requestId, `Task runner unavailable: ${error.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lifecycleManager.updateLastActivityTime();
|
||||||
|
|
||||||
this.pendingTaskRequests.push(request);
|
this.pendingTaskRequests.push(request);
|
||||||
this.settleTasks();
|
this.settleTasks();
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,6 +4,7 @@ import Container, { Service } from 'typedi';
|
||||||
|
|
||||||
import type { TaskRunnerProcess } from '@/runners/task-runner-process';
|
import type { TaskRunnerProcess } from '@/runners/task-runner-process';
|
||||||
|
|
||||||
|
import { RunnerLifecycleEvents } from './runner-lifecycle-manager';
|
||||||
import { TaskRunnerWsServer } from './runner-ws-server';
|
import { TaskRunnerWsServer } from './runner-ws-server';
|
||||||
import type { LocalTaskManager } from './task-managers/local-task-manager';
|
import type { LocalTaskManager } from './task-managers/local-task-manager';
|
||||||
import type { TaskRunnerServer } from './task-runner-server';
|
import type { TaskRunnerServer } from './task-runner-server';
|
||||||
|
@ -23,20 +24,29 @@ export class TaskRunnerModule {
|
||||||
|
|
||||||
private taskRunnerProcess: TaskRunnerProcess | undefined;
|
private taskRunnerProcess: TaskRunnerProcess | undefined;
|
||||||
|
|
||||||
constructor(private readonly runnerConfig: TaskRunnersConfig) {}
|
constructor(
|
||||||
|
private readonly runnerConfig: TaskRunnersConfig,
|
||||||
|
private readonly lifecycleEvents: RunnerLifecycleEvents,
|
||||||
|
) {
|
||||||
|
this.lifecycleEvents.on('runner:started', async () => {
|
||||||
|
const { InternalTaskRunnerDisconnectAnalyzer } = await import(
|
||||||
|
'@/runners/internal-task-runner-disconnect-analyzer'
|
||||||
|
);
|
||||||
|
this.taskRunnerWsServer?.setDisconnectAnalyzer(
|
||||||
|
Container.get(InternalTaskRunnerDisconnectAnalyzer),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.lifecycleEvents.on('runner:stopped', () => {
|
||||||
|
this.taskRunnerWsServer?.setDisconnectAnalyzer(undefined);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async start() {
|
async start() {
|
||||||
a.ok(!this.runnerConfig.disabled, 'Task runner is disabled');
|
a.ok(!this.runnerConfig.disabled, 'Task runner is disabled');
|
||||||
|
|
||||||
await this.loadTaskManager();
|
await this.loadTaskManager();
|
||||||
await this.loadTaskRunnerServer();
|
await this.loadTaskRunnerServer();
|
||||||
|
|
||||||
if (
|
|
||||||
this.runnerConfig.mode === 'internal_childprocess' ||
|
|
||||||
this.runnerConfig.mode === 'internal_launcher'
|
|
||||||
) {
|
|
||||||
await this.startInternalTaskRunner();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async stop() {
|
async stop() {
|
||||||
|
@ -67,19 +77,4 @@ export class TaskRunnerModule {
|
||||||
|
|
||||||
await this.taskRunnerHttpServer.start();
|
await this.taskRunnerHttpServer.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async startInternalTaskRunner() {
|
|
||||||
a.ok(this.taskRunnerWsServer, 'Task Runner WS Server not loaded');
|
|
||||||
|
|
||||||
const { TaskRunnerProcess } = await import('@/runners/task-runner-process');
|
|
||||||
this.taskRunnerProcess = Container.get(TaskRunnerProcess);
|
|
||||||
await this.taskRunnerProcess.start();
|
|
||||||
|
|
||||||
const { InternalTaskRunnerDisconnectAnalyzer } = await import(
|
|
||||||
'@/runners/internal-task-runner-disconnect-analyzer'
|
|
||||||
);
|
|
||||||
this.taskRunnerWsServer.setDisconnectAnalyzer(
|
|
||||||
Container.get(InternalTaskRunnerDisconnectAnalyzer),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -77,7 +77,7 @@ export class TaskRunnerProcess extends TypedEmitter<TaskRunnerProcessEventMap> {
|
||||||
}
|
}
|
||||||
|
|
||||||
async start() {
|
async start() {
|
||||||
a.ok(!this.process, 'Task Runner Process already running');
|
if (this.isRunning) return;
|
||||||
|
|
||||||
const grantToken = await this.authService.createGrantToken();
|
const grantToken = await this.authService.createGrantToken();
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue