refactor(engine): Split execution response endpoints (#39261)

This commit is contained in:
Tomi Turtiainen
2026-09-22 11:44:49 +00:00
committed by GitHub
parent 43d7cc755e
commit 518a24dd64
25 changed files with 355 additions and 365 deletions
@@ -14,14 +14,13 @@ import {
import { generateId } from '../../database/generate-id';
import type { WorkflowGraph } from '../../graph';
import { noopLifecycleEventPublisher } from '../../lifecycle-events';
import { createConsoleLogger } from '../../logging';
import {
InMemoryWorkQueue,
type OrchestrationMessage,
type StepMessage,
type WorkQueue,
} from '../../queue';
import { ExecutionResponseChannel, noopResponseTransport } from '../../response-channel';
import { noopExecutionResponseSender } from '../../response-channel';
import { ExecutionStartHandler } from '../execution-start-handler';
import { OrchestrationWorker } from '../orchestration-worker';
import { StartExecutionService } from '../start-execution.service';
@@ -76,7 +75,7 @@ describe('execution start (integration)', () => {
stepQueue,
orchestrationQueue,
noopLifecycleEventPublisher,
new ExecutionResponseChannel(noopResponseTransport, createConsoleLogger()),
noopExecutionResponseSender,
),
);
worker.start();
@@ -17,9 +17,8 @@ import type { IStepExecutor, StepExecutionRequest } from '../../dependencies';
import type { WorkflowGraph } from '../../graph';
import { noopLifecycleEventPublisher } from '../../lifecycle-events';
import type { LifecycleEventCallback, LifecycleEvent } from '../../lifecycle-events';
import { createConsoleLogger } from '../../logging';
import { InMemoryWorkQueue, type OrchestrationMessage } from '../../queue';
import { ExecutionResponseChannel, noopResponseTransport } from '../../response-channel';
import { noopExecutionResponseSender } from '../../response-channel';
import { createEngineRuntime } from '../../runtime';
import type { TriggerOutputs } from '../execution.types';
import type { StartExecutionResult } from '../start-execution.service';
@@ -82,7 +81,7 @@ describe('step execution (integration)', () => {
dataSource,
admittance: new AllowAllAdmittance(),
identityVerifier: new SharedSecretIdentityVerifier(secret),
responseChannel: new ExecutionResponseChannel(noopResponseTransport, createConsoleLogger()),
responseSender: noopExecutionResponseSender,
// also how the test reaches the stores the runtime owns
externalDependencies: ({ executionStore }) => {
const finishExecution = executionStore.finishExecution.bind(executionStore);
@@ -3,7 +3,7 @@ import { describe, expect, it, vi } from 'vitest';
import type { WorkflowGraph } from '../../graph';
import type { LifecycleEventPublisher } from '../../lifecycle-events';
import type { OrchestrationMessage, StepMessage, WorkQueue } from '../../queue';
import type { ExecutionResponseChannel } from '../../response-channel';
import type { ExecutionResponseSender } from '../../response-channel';
import type { ExecutionRecord, ExecutionStore } from '../execution-store';
import { stepKeyId, type StepKey, type StepStatus } from '../execution.types';
import { StepSettledHandler } from '../step-settled-handler';
@@ -128,9 +128,9 @@ function makeLifecycleEventPublisher(): LifecycleEventPublisher {
return { publish: vi.fn(), stop: vi.fn() };
}
/** A channel fake; tests that care assert on `publish`. */
function makeResponseChannel() {
return { publish: vi.fn() } as unknown as ExecutionResponseChannel;
/** A response sender fake; tests that care assert on `send`. */
function makeResponseSender() {
return { send: vi.fn() } as unknown as ExecutionResponseSender;
}
function makeHandler(
@@ -140,7 +140,7 @@ function makeHandler(
stepQueue = makeStepQueue(),
orchestrationQueue = makeOrchestrationQueue(),
lifecycleEventPublisher = makeLifecycleEventPublisher(),
responseChannel = makeResponseChannel(),
responseSender = makeResponseSender(),
} = {},
) {
return {
@@ -150,13 +150,13 @@ function makeHandler(
stepQueue,
orchestrationQueue,
lifecycleEventPublisher,
responseChannel,
responseSender,
),
executionStore,
stepQueue,
orchestrationQueue,
lifecycleEventPublisher,
responseChannel,
responseSender,
};
}
@@ -488,7 +488,7 @@ describe('StepSettledHandler lifecycle events', () => {
{ id: 'step-m', nodeId: 'm' },
{ countSettledSteps: vi.fn().mockResolvedValue(5) },
);
const { handler, lifecycleEventPublisher, responseChannel } = makeHandler(stepStore);
const { handler, lifecycleEventPublisher, responseSender } = makeHandler(stepStore);
await handler.handle({ ...event, stepId: 'step-m' });
@@ -496,7 +496,7 @@ describe('StepSettledHandler lifecycle events', () => {
type: 'execution:completed',
...finished,
});
expect(responseChannel.publish).toHaveBeenCalledExactlyOnceWith({
expect(responseSender.send).toHaveBeenCalledExactlyOnceWith({
type: 'ended',
executionId: 'exec-1',
workflowId: 'wf-1',
@@ -516,7 +516,7 @@ describe('StepSettledHandler lifecycle events', () => {
status: 'failed',
error: { name: 'NodeOperationError', message: 'it broke', stack: 'at trace' },
});
const { handler, lifecycleEventPublisher, responseChannel } = makeHandler(stepStore);
const { handler, lifecycleEventPublisher, responseSender } = makeHandler(stepStore);
await handler.handle(event);
@@ -524,7 +524,7 @@ describe('StepSettledHandler lifecycle events', () => {
type: 'execution:failed',
...finished,
});
expect(responseChannel.publish).toHaveBeenCalledExactlyOnceWith({
expect(responseSender.send).toHaveBeenCalledExactlyOnceWith({
type: 'ended',
executionId: 'exec-1',
workflowId: 'wf-1',
@@ -546,7 +546,7 @@ describe('StepSettledHandler lifecycle events', () => {
{},
{ finishExecution: vi.fn().mockResolvedValue(false) },
);
const { handler, lifecycleEventPublisher, responseChannel } = makeHandler(
const { handler, lifecycleEventPublisher, responseSender } = makeHandler(
makeStepStore({ status: 'failed' }),
{ executionStore },
);
@@ -554,7 +554,7 @@ describe('StepSettledHandler lifecycle events', () => {
await handler.handle(event);
expect(lifecycleEventPublisher.publish).not.toHaveBeenCalled();
expect(responseChannel.publish).not.toHaveBeenCalled();
expect(responseSender.send).not.toHaveBeenCalled();
});
it('announces nothing while any reachable node is unsettled', async () => {
@@ -7,7 +7,7 @@ import {
type GraphNode,
} from '../graph';
import type { LifecycleEventPublisher } from '../lifecycle-events';
import type { ExecutionResponseChannel } from '../response-channel';
import type { ExecutionResponseSender } from '../response-channel';
import type { OrchestrationMessage, StepMessage, StepSettledEvent, WorkQueue } from '../queue';
import { countExpectedSettledSteps } from './completion';
import type { ExecutionRecord, ExecutionStore } from './execution-store';
@@ -36,7 +36,7 @@ export class StepSettledHandler {
private readonly stepQueue: WorkQueue<StepMessage>,
private readonly orchestrationQueue: WorkQueue<OrchestrationMessage>,
private readonly lifecycleEventPublisher: LifecycleEventPublisher,
private readonly responseChannel: ExecutionResponseChannel,
private readonly responseSender: ExecutionResponseSender,
) {}
async handle(event: StepSettledEvent): Promise<void> {
@@ -227,7 +227,7 @@ export class StepSettledHandler {
);
}
this.responseChannel.publish({
this.responseSender.send({
type: 'ended',
executionId: execution.id,
workflowId: execution.workflowId,
+2 -9
View File
@@ -35,17 +35,10 @@ export type {
} from './lifecycle-events';
export {
ExecutionResponseChannel,
executionResponseSchema,
InMemoryResponseTransport,
noopResponseTransport,
} from './response-channel';
export type {
EndedMessage,
ExecutionResponse,
ResponseTransport,
Unsubscribe,
noopExecutionResponseSender,
} from './response-channel';
export type { EndedMessage, ExecutionResponse, ExecutionResponseSender } from './response-channel';
export { UnimplementedError } from './common';
export type { JsonObject, JsonValue } from './common';
@@ -1,133 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import type { EngineLogger } from '../../logging';
import { ExecutionResponseChannel } from '../execution-response-channel';
import type { ExecutionResponse } from '../execution-response.types';
import { InMemoryResponseTransport } from '../in-memory-transport';
import type { ResponseTransport, Unsubscribe } from '../response-transport';
const silentLogger = (): EngineLogger => ({
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
});
const ended = (outputs: unknown, executionId = 'exec-1'): ExecutionResponse => ({
type: 'ended',
executionId,
workflowId: 'wf-1',
status: 'completed',
lastStep: { nodeId: 'a', nodeName: 'A', status: 'completed', outputs: outputs as never },
});
/** A transport that keeps its frames, so a test can see what crossed. */
class RecordingTransport implements ResponseTransport {
readonly frames: string[] = [];
private handler?: (frame: string) => void;
publish(_executionId: string, frame: string): void {
this.frames.push(frame);
this.handler?.(frame);
}
subscribe(_executionId: string, handler: (frame: string) => void): Unsubscribe {
this.handler = handler;
return () => {
this.handler = undefined;
};
}
async stop(): Promise<void> {}
}
describe('ExecutionResponseChannel', () => {
const newChannel = () =>
new ExecutionResponseChannel(new InMemoryResponseTransport(), silentLogger());
it('delivers a response to every subscriber of that execution', () => {
const channel = newChannel();
const first: ExecutionResponse[] = [];
const second: ExecutionResponse[] = [];
channel.subscribe('exec-1', (r) => first.push(r));
channel.subscribe('exec-1', (r) => second.push(r));
channel.publish(ended([[{ json: { a: 1 } }]]));
expect(first[0]).toMatchObject({
type: 'ended',
status: 'completed',
lastStep: { nodeName: 'A', outputs: [[{ json: { a: 1 } }]] },
});
expect(second).toEqual(first);
});
it('tells one execution nothing about another', () => {
const channel = newChannel();
const seen: ExecutionResponse[] = [];
channel.subscribe('exec-1', (r) => seen.push(r));
channel.publish(ended(null, 'exec-2'));
expect(seen).toEqual([]);
});
it('stops delivering once the subscriber unsubscribes', () => {
const channel = newChannel();
const seen: ExecutionResponse[] = [];
const unsubscribe = channel.subscribe('exec-1', (r) => seen.push(r));
unsubscribe();
channel.publish(ended(null));
expect(seen).toEqual([]);
});
it('strips a value no transport could carry, rather than passing it live', () => {
const channel = newChannel();
const seen: ExecutionResponse[] = [];
channel.subscribe('exec-1', (r) => seen.push(r));
// The channel always serializes, so a `Date` crosses as the ISO string
// `JSON.stringify` gives it, never as the live object a subscriber could
// otherwise get away with in-process.
channel.publish(ended([[{ at: new Date(0) }]]));
expect(seen[0]).toMatchObject({
lastStep: { outputs: [[{ at: '1970-01-01T00:00:00.000Z' }]] },
});
});
it('discards a frame that is not a response', () => {
const transport = new RecordingTransport();
const channel = new ExecutionResponseChannel(transport, silentLogger());
const seen: ExecutionResponse[] = [];
channel.subscribe('exec-1', (r) => seen.push(r));
transport.publish('exec-1', '{"type":"nonsense"}');
transport.publish('exec-1', 'not json at all');
expect(seen).toEqual([]);
});
it('isolates a subscriber that throws', () => {
const channel = newChannel();
channel.subscribe('exec-1', () => {
throw new Error('boom');
});
expect(() => channel.publish(ended(null))).not.toThrow();
});
it('stops the underlying transport', async () => {
const transport = new RecordingTransport();
const stop = vi.spyOn(transport, 'stop');
const channel = new ExecutionResponseChannel(transport, silentLogger());
await channel.stop();
expect(stop).toHaveBeenCalledTimes(1);
});
});
@@ -1,64 +0,0 @@
import type { EngineLogger } from '../logging';
import { executionResponseSchema } from './execution-response.schema';
import type { ExecutionResponse } from './execution-response.types';
import type { ResponseTransport, Unsubscribe } from './response-transport';
/**
* Sends responses from an execution back to its caller.
*
* The engine publishes a response with an execution ID. A caller subscribes
* with the same ID and receives only responses for that execution.
*
* This class serializes outgoing responses and validates incoming responses.
* The transport delivers them in-process or across process boundaries.
*/
export class ExecutionResponseChannel {
constructor(
private readonly transport: ResponseTransport,
private readonly logger: EngineLogger,
) {}
publish(response: ExecutionResponse): void {
this.transport.publish(response.executionId, JSON.stringify(response));
}
/** Listens to one execution. Call the returned function once the run is answered. */
subscribe(executionId: string, handler: (response: ExecutionResponse) => void): Unsubscribe {
return this.transport.subscribe(executionId, (frame) => {
const response = this.fromFrame(frame);
if (response === undefined) return;
try {
handler(response);
} catch (error) {
// One bad subscriber must not take the transport down with it.
this.logger.error('a response subscriber threw', {
executionId: response.executionId,
type: response.type,
error,
});
}
});
}
async stop(): Promise<void> {
await this.transport.stop();
}
private fromFrame(frame: string): ExecutionResponse | undefined {
try {
const parsed = executionResponseSchema.safeParse(JSON.parse(frame));
if (!parsed.success) {
this.logger.error('discarding a malformed response', {
details: parsed.error.flatten(),
});
return undefined;
}
return parsed.data;
} catch (error) {
this.logger.error('discarding an unreadable response frame', { error });
return undefined;
}
}
}
@@ -0,0 +1,13 @@
import type { ExecutionResponse } from './execution-response.types';
/** Sends responses from an execution back to its caller. */
export interface ExecutionResponseSender {
send(response: ExecutionResponse): void;
stop(): Promise<void>;
}
/** Response sender for a host that discards execution responses. */
export const noopExecutionResponseSender: ExecutionResponseSender = Object.freeze({
send: () => {},
stop: async () => {},
});
@@ -6,9 +6,8 @@ import { jsonValueSchema } from '../common';
import { SETTLED_STEP_STATUSES } from '../execution/execution.types';
/**
* The one definition of a response's shape. A transport can cross a process
* boundary, so a frame that arrives is parsed against this before a subscriber
* ever sees it.
* The one definition of a response's shape. A frame can cross a process
* boundary, so it is parsed against this before a handler sees it.
*/
export const executionResponseSchema = z.discriminatedUnion('type', [
z.object({
@@ -1,33 +0,0 @@
import { EventEmitter } from 'node:events';
import type { ResponseTransport, Unsubscribe } from './response-transport';
/**
* In-process `ResponseTransport`, for a deployment where both planes share a
* process.
*
* One emitter event per execution, which is the same addressing a networked
* transport gives each run.
*
* It carries frames rather than objects, exactly as a networked transport does.
* The `JSON.stringify` it pays for is the point: an integrated deployment that
* passed live objects would accept a response a split deployment mangles, and
* the integrated one is the one under test.
*/
export class InMemoryResponseTransport implements ResponseTransport {
private readonly executions = new EventEmitter();
publish(executionId: string, frame: string): void {
this.executions.emit(executionId, frame);
}
subscribe(executionId: string, handler: (frame: string) => void): Unsubscribe {
this.executions.on(executionId, handler);
return () => this.executions.off(executionId, handler);
}
async stop(): Promise<void> {
this.executions.removeAllListeners();
}
}
@@ -1,6 +1,4 @@
export { ExecutionResponseChannel } from './execution-response-channel';
export { noopExecutionResponseSender } from './execution-response-sender';
export type { ExecutionResponseSender } from './execution-response-sender';
export { executionResponseSchema } from './execution-response.schema';
export type { EndedMessage, ExecutionResponse } from './execution-response.types';
export { InMemoryResponseTransport } from './in-memory-transport';
export { noopResponseTransport } from './response-transport';
export type { ResponseTransport, Unsubscribe } from './response-transport';
@@ -1,26 +0,0 @@
/** Ends one subscription. Calling it twice is safe. */
export type Unsubscribe = () => void;
/**
* Delivers serialized responses to subscribers for a specific execution.
*
* A subscriber receives only frames published for its execution ID. The
* transport treats each frame as an opaque string. `ExecutionResponseChannel`
* handles serialization and validation.
*
* Frames are not buffered. Only active subscribers receive them, and each
* frame is delivered at most once.
*/
export interface ResponseTransport {
/** Never throws and never blocks: a step must not wait on the response path. */
publish(executionId: string, frame: string): void;
subscribe(executionId: string, handler: (frame: string) => void): Unsubscribe;
stop(): Promise<void>;
}
/** Transport for a host that is not listening. */
export const noopResponseTransport: ResponseTransport = Object.freeze({
publish: () => {},
subscribe: () => () => {},
stop: async () => {},
});
@@ -6,8 +6,7 @@ import { AllowAllAdmittance } from '../../admittance';
import { mintIdentityToken, SharedSecretIdentityVerifier } from '../../auth';
import type { EngineStores } from '../../database';
import { BatchingLifecycleEventPublisher } from '../../lifecycle-events';
import { createConsoleLogger } from '../../logging';
import { ExecutionResponseChannel, noopResponseTransport } from '../../response-channel';
import { noopExecutionResponseSender } from '../../response-channel';
import { createEngineRuntime } from '../create-engine-runtime';
/** Enough of a `DataSource` for the stores: they only hold on to a repository. */
@@ -16,14 +15,14 @@ const fakeDataSource = () => ({ getRepository: vi.fn(() => ({})) }) as unknown a
const secret = 'a'.repeat(32);
const identityVerifier = new SharedSecretIdentityVerifier(secret);
const token = mintIdentityToken(secret, { cpId: 'cp-1', tenantId: 'tenant-1' });
const responseChannel = new ExecutionResponseChannel(noopResponseTransport, createConsoleLogger());
const responseSender = noopExecutionResponseSender;
const runtime = () =>
createEngineRuntime({
dataSource: fakeDataSource(),
admittance: new AllowAllAdmittance(),
identityVerifier,
responseChannel,
responseSender,
});
describe('createEngineRuntime', () => {
@@ -68,7 +67,7 @@ describe('createEngineRuntime', () => {
dataSource: fakeDataSource(),
admittance: new AllowAllAdmittance(),
identityVerifier,
responseChannel,
responseSender,
externalDependencies: (given) => {
stores = given;
return {};
@@ -87,7 +86,7 @@ describe('createEngineRuntime', () => {
dataSource: fakeDataSource(),
admittance: new AllowAllAdmittance(),
identityVerifier,
responseChannel,
responseSender,
externalDependencies: build,
});
@@ -102,7 +101,7 @@ describe('createEngineRuntime', () => {
dataSource: fakeDataSource(),
admittance: new AllowAllAdmittance(),
identityVerifier,
responseChannel,
responseSender,
externalDependencies: () => ({
lifecycleEventCallback: vi.fn().mockResolvedValue(undefined),
}),
@@ -20,7 +20,7 @@ import type { LifecycleEventPublisher } from '../lifecycle-events';
import { createConsoleLogger, type EngineLogger } from '../logging';
import { InMemoryWorkQueue } from '../queue';
import type { OrchestrationMessage, StepMessage } from '../queue';
import type { ExecutionResponseChannel } from '../response-channel';
import type { ExecutionResponseSender } from '../response-channel';
import { createEngineServer } from '../server';
export interface EngineRuntimeOptions {
@@ -32,11 +32,10 @@ export interface EngineRuntimeOptions {
/** Where the engine writes its own messages. Defaults to the console. */
logger?: EngineLogger;
/**
* Where an execution's responses go. The host owns it, because whoever waits
* for a response subscribes to the same channel. No default: a host that
* listens to nothing says so with `noopResponseTransport`.
* Where an execution sends responses. The host owns it. No default: a host
* that discards responses says so with `noopExecutionResponseSender`.
*/
responseChannel: ExecutionResponseChannel;
responseSender: ExecutionResponseSender;
/**
* Builds the capabilities the engine does not own. It receives the engine's
* stores, because a `v1-node` executor reads step data through them and the
@@ -72,7 +71,7 @@ export function createEngineRuntime({
admittance,
identityVerifier,
logger = createConsoleLogger(),
responseChannel,
responseSender,
externalDependencies,
}: EngineRuntimeOptions): EngineRuntime {
const orchestrationQueue = new InMemoryWorkQueue<OrchestrationMessage>(logger);
@@ -99,7 +98,7 @@ export function createEngineRuntime({
stepQueue,
orchestrationQueue,
lifecycleEventPublisher,
responseChannel,
responseSender,
),
);
const stepWorker = new StepWorker(
+2 -2
View File
@@ -5,7 +5,7 @@ import { AllowAllAdmittance } from './admittance';
import { SharedSecretIdentityVerifier } from './auth';
import { createDataSource } from './database';
import { createConsoleLogger } from './logging';
import { ExecutionResponseChannel, noopResponseTransport } from './response-channel';
import { noopExecutionResponseSender } from './response-channel';
import { createEngineRuntime } from './runtime';
const logger = createConsoleLogger();
@@ -34,7 +34,7 @@ async function main(): Promise<void> {
logger,
// Nothing in this process waits for a response: the caller reads the run
// over the API instead.
responseChannel: new ExecutionResponseChannel(noopResponseTransport, logger),
responseSender: noopExecutionResponseSender,
});
runtime.start();
@@ -11,9 +11,8 @@ import { createDataSource, createStores, WorkflowExecution } from '../../databas
import { generateId } from '../../database/generate-id';
import { ExecutionQueryService, StartExecutionService } from '../../execution';
import type { WorkflowGraph } from '../../graph';
import { createConsoleLogger } from '../../logging';
import type { OrchestrationMessage, WorkQueue } from '../../queue';
import { ExecutionResponseChannel, noopResponseTransport } from '../../response-channel';
import { noopExecutionResponseSender } from '../../response-channel';
import { createEngineRuntime } from '../../runtime';
import { startEngineServer } from '../../testing/start-engine-server';
import type { SearchExecutionsResponse } from '../api.types';
@@ -488,7 +487,7 @@ describe('GET /api/workflow-executions/:id (integration)', () => {
dataSource,
admittance: new AllowAllAdmittance(),
identityVerifier: new SharedSecretIdentityVerifier(secret),
responseChannel: new ExecutionResponseChannel(noopResponseTransport, createConsoleLogger()),
responseSender: noopExecutionResponseSender,
externalDependencies: ({ executionStore }) => {
const finishExecution = executionStore.finishExecution.bind(executionStore);
vi.spyOn(executionStore, 'finishExecution').mockImplementation(async (id, status) => {
@@ -1,6 +1,5 @@
import type {
createDataSource,
EngineLogger,
ExecutionMode,
StartExecutionResult,
TriggerOutputs,
@@ -9,8 +8,7 @@ import type {
import {
AllowAllAdmittance,
createEngineRuntime,
ExecutionResponseChannel,
noopResponseTransport,
noopExecutionResponseSender,
mintIdentityToken,
SharedSecretIdentityVerifier,
WorkflowExecution,
@@ -58,12 +56,6 @@ export const realNodeTypes: INodeTypes = {
export const converter = new V1WorkflowConverter();
const authSecret = 'a'.repeat(32);
const silentLogger: EngineLogger = {
error: () => {},
warn: () => {},
info: () => {},
debug: () => {},
};
const caller = { cpId: 'cp-1', tenantId: 'tenant-1' };
export type Assignment = { name: string; value: string | number; type: string };
@@ -332,7 +324,7 @@ export function makeRunWorkflow(getDataSource: () => EngineDataSource) {
admittance: new AllowAllAdmittance(),
identityVerifier: new SharedSecretIdentityVerifier(authSecret),
// Nothing here waits for a response; the fixture reads the run over the API.
responseChannel: new ExecutionResponseChannel(noopResponseTransport, silentLogger),
responseSender: noopExecutionResponseSender,
// also how the test reaches the stores the runtime owns
externalDependencies: ({ executionStore, stepStore }) => {
const finishExecution = executionStore.finishExecution.bind(executionStore);
@@ -1,6 +1,6 @@
import { mockLogger } from '@n8n/backend-test-utils';
import type { EngineConfig } from '@n8n/config';
import type { ExecutionResponseChannel } from '@n8n/engine';
import type { ExecutionResponseSender } from '@n8n/engine';
import { mock } from 'vitest-mock-extended';
import type { CredentialTypes } from '@/credential-types';
@@ -103,8 +103,8 @@ describe('EngineV2Runtime', () => {
const credentialsHelper = mock<CredentialsHelper>();
const credentialTypes = mock<CredentialTypes>();
// `@n8n/engine` is mocked below, so the channel is a stand-in too.
const responseChannel = () => mock<ExecutionResponseChannel>();
// `@n8n/engine` is mocked below, so the sender is a stand-in too.
const responseSender = () => mock<ExecutionResponseSender>();
const newRuntime = (databaseUrl = 'postgres://engine') =>
new EngineV2Runtime(
@@ -149,7 +149,7 @@ describe('EngineV2Runtime', () => {
describe('init', () => {
it('refuses to start without a data plane database', async () => {
await expect(newRuntime('').init(responseChannel())).rejects.toThrow(
await expect(newRuntime('').init(responseSender())).rejects.toThrow(
'N8N_ENGINE_DATABASE_URL',
);
@@ -157,7 +157,7 @@ describe('EngineV2Runtime', () => {
});
it('opens the data plane connection and migrates it before building the engine', async () => {
await newRuntime().init(responseChannel());
await newRuntime().init(responseSender());
expect(mocks.createDataSource).toHaveBeenCalledWith('postgres://engine');
expect(mocks.dataSource.initialize).toHaveBeenCalled();
@@ -168,13 +168,13 @@ describe('EngineV2Runtime', () => {
});
it('starts the engine', async () => {
await newRuntime().init(responseChannel());
await newRuntime().init(responseSender());
expect(mocks.engine.start).toHaveBeenCalled();
});
it('injects a lifecycle event callback that reports to the control plane', async () => {
await newRuntime().init(responseChannel());
await newRuntime().init(responseSender());
const events = [
{
@@ -198,7 +198,7 @@ describe('EngineV2Runtime', () => {
});
it('injects the v1 step executor so v1-node steps can run', async () => {
await newRuntime().init(responseChannel());
await newRuntime().init(responseSender());
const stores = { executionStore: {}, stepStore: {} };
@@ -215,7 +215,7 @@ describe('EngineV2Runtime', () => {
});
it('builds the v1 additional data for the workflow and user of the step', async () => {
await newRuntime().init(responseChannel());
await newRuntime().init(responseSender());
await additionalDataFactory()(stepContext);
@@ -227,7 +227,7 @@ describe('EngineV2Runtime', () => {
});
it('tags the v1 additional data with the engine execution id', async () => {
await newRuntime().init(responseChannel());
await newRuntime().init(responseSender());
const additionalData = await additionalDataFactory()(stepContext);
@@ -235,7 +235,7 @@ describe('EngineV2Runtime', () => {
});
it('gives the step a credentials helper that asks the control plane', async () => {
await newRuntime().init(responseChannel());
await newRuntime().init(responseSender());
const additionalData = await additionalDataFactory()(stepContext);
@@ -243,7 +243,7 @@ describe('EngineV2Runtime', () => {
});
it('binds the credentials helper to the execution of the step', async () => {
await newRuntime().init(responseChannel());
await newRuntime().init(responseSender());
vi.mocked(credentialsClient.resolve).mockResolvedValue({});
const { credentialsHelper: helper } = (await additionalDataFactory()(stepContext)) as {
@@ -267,7 +267,7 @@ describe('EngineV2Runtime', () => {
});
it('serves the engine API on the configured address', async () => {
await newRuntime().init(responseChannel());
await newRuntime().init(responseSender());
expect(mocks.engine.app.listen).toHaveBeenCalledWith(3000, '0.0.0.0');
});
@@ -275,7 +275,7 @@ describe('EngineV2Runtime', () => {
it('closes the connection when migrations fail', async () => {
mocks.dataSource.runMigrations.mockRejectedValueOnce(new Error('migration failed'));
await expect(newRuntime().init(responseChannel())).rejects.toThrow('migration failed');
await expect(newRuntime().init(responseSender())).rejects.toThrow('migration failed');
expect(mocks.dataSource.destroy).toHaveBeenCalled();
expect(mocks.engine.stop).not.toHaveBeenCalled();
@@ -284,7 +284,7 @@ describe('EngineV2Runtime', () => {
it('stops the engine and closes the connection when the server fails to listen', async () => {
mocks.listen.error = new Error('listen failed');
await expect(newRuntime().init(responseChannel())).rejects.toThrow('listen failed');
await expect(newRuntime().init(responseSender())).rejects.toThrow('listen failed');
expect(mocks.server.close).not.toHaveBeenCalled();
expect(mocks.engine.stop).toHaveBeenCalled();
@@ -295,14 +295,14 @@ describe('EngineV2Runtime', () => {
mocks.listen.error = new Error('listen failed');
mocks.engine.stop.mockRejectedValueOnce(new Error('stop failed'));
await expect(newRuntime().init(responseChannel())).rejects.toThrow('listen failed');
await expect(newRuntime().init(responseSender())).rejects.toThrow('listen failed');
});
});
describe('shutdown', () => {
it('closes the server, stops the engine and closes the connection', async () => {
const runtime = newRuntime();
await runtime.init(responseChannel());
await runtime.init(responseSender());
await runtime.shutdown();
@@ -313,7 +313,7 @@ describe('EngineV2Runtime', () => {
it('stops the engine and closes the connection when the server fails to close', async () => {
const runtime = newRuntime();
await runtime.init(responseChannel());
await runtime.init(responseSender());
mocks.server.close.mockImplementationOnce((done) => done(new Error('close failed')));
const error = await runtime
@@ -328,7 +328,7 @@ describe('EngineV2Runtime', () => {
it('closes the connection when the engine fails to stop', async () => {
const runtime = newRuntime();
await runtime.init(responseChannel());
await runtime.init(responseSender());
mocks.engine.stop.mockRejectedValueOnce(new Error('stop failed'));
await expect(runtime.shutdown()).rejects.toThrow(AggregateError);
@@ -338,7 +338,7 @@ describe('EngineV2Runtime', () => {
it('aborts the credential requests of running steps', async () => {
const runtime = newRuntime();
await runtime.init(responseChannel());
await runtime.init(responseSender());
vi.mocked(credentialsClient.resolve).mockResolvedValue({});
const { credentialsHelper: helper } = (await additionalDataFactory()(stepContext)) as {
credentialsHelper: RemoteCredentialsHelper;
@@ -365,7 +365,7 @@ describe('EngineV2Runtime', () => {
it('releases a resource again when its first release failed', async () => {
const runtime = newRuntime();
await runtime.init(responseChannel());
await runtime.init(responseSender());
mocks.server.close.mockImplementationOnce((done) => done(new Error('close failed')));
await expect(runtime.shutdown()).rejects.toThrow(AggregateError);
@@ -380,7 +380,7 @@ describe('EngineV2Runtime', () => {
it('is safe to call twice', async () => {
const runtime = newRuntime();
await runtime.init(responseChannel());
await runtime.init(responseSender());
await runtime.shutdown();
await expect(runtime.shutdown()).resolves.toBeUndefined();
@@ -0,0 +1,127 @@
import { mockLogger } from '@n8n/backend-test-utils';
import type { ExecutionResponse } from '@n8n/engine';
import { InMemoryExecutionResponseChannel } from '../response-channel/in-memory-execution-response-channel';
import { InMemoryExecutionResponseReceiver } from '../response-channel/in-memory-execution-response-receiver';
import { InMemoryExecutionResponseSender } from '../response-channel/in-memory-execution-response-sender';
const ended = (executionId = 'exec-1', outputs: unknown = null): ExecutionResponse => ({
type: 'ended',
executionId,
workflowId: 'wf-1',
status: 'completed',
lastStep: { nodeId: 'a', nodeName: 'A', status: 'completed', outputs: outputs as never },
});
describe('in-memory execution responses', () => {
it('serializes a response before publishing it', () => {
const channel = new InMemoryExecutionResponseChannel();
const publish = vi.spyOn(channel, 'publish');
const sender = new InMemoryExecutionResponseSender(channel);
sender.send(ended('exec-1', [[{ at: new Date(0) }]]));
expect(publish).toHaveBeenCalledExactlyOnceWith(
'exec-1',
JSON.stringify(ended('exec-1', [[{ at: '1970-01-01T00:00:00.000Z' }]])),
);
});
it('validates a response before delivering it', () => {
const channel = new InMemoryExecutionResponseChannel();
const receiver = new InMemoryExecutionResponseReceiver(channel, mockLogger());
const seen: ExecutionResponse[] = [];
receiver.receive('exec-1', (response) => seen.push(response));
channel.publish('exec-1', JSON.stringify(ended()));
expect(seen).toEqual([ended()]);
});
it('discards malformed and unreadable responses', () => {
const channel = new InMemoryExecutionResponseChannel();
const receiver = new InMemoryExecutionResponseReceiver(channel, mockLogger());
const seen: ExecutionResponse[] = [];
receiver.receive('exec-1', (response) => seen.push(response));
channel.publish('exec-1', '{"type":"nonsense"}');
channel.publish('exec-1', 'not json at all');
expect(seen).toEqual([]);
});
it('isolates a handler that throws', () => {
const channel = new InMemoryExecutionResponseChannel();
const receiver = new InMemoryExecutionResponseReceiver(channel, mockLogger());
receiver.receive('exec-1', () => {
throw new Error('boom');
});
expect(() => channel.publish('exec-1', JSON.stringify(ended()))).not.toThrow();
});
it('delivers a response to every handler for that execution', () => {
const channel = new InMemoryExecutionResponseChannel();
const sender = new InMemoryExecutionResponseSender(channel);
const receiver = new InMemoryExecutionResponseReceiver(channel, mockLogger());
const first: ExecutionResponse[] = [];
const second: ExecutionResponse[] = [];
receiver.receive('exec-1', (response) => first.push(response));
receiver.receive('exec-1', (response) => second.push(response));
sender.send(ended());
expect(first).toEqual([ended()]);
expect(second).toEqual(first);
});
it('does not deliver a response to another execution', () => {
const channel = new InMemoryExecutionResponseChannel();
const sender = new InMemoryExecutionResponseSender(channel);
const receiver = new InMemoryExecutionResponseReceiver(channel, mockLogger());
const seen: ExecutionResponse[] = [];
receiver.receive('exec-1', (response) => seen.push(response));
sender.send(ended('exec-2'));
expect(seen).toEqual([]);
});
it('stops delivery after the handler is removed', () => {
const channel = new InMemoryExecutionResponseChannel();
const sender = new InMemoryExecutionResponseSender(channel);
const receiver = new InMemoryExecutionResponseReceiver(channel, mockLogger());
const seen: ExecutionResponse[] = [];
const unsubscribe = receiver.receive('exec-1', (response) => seen.push(response));
unsubscribe();
sender.send(ended());
expect(seen).toEqual([]);
});
it('stops publishing through the sender', async () => {
const channel = new InMemoryExecutionResponseChannel();
const sender = new InMemoryExecutionResponseSender(channel);
const seen: string[] = [];
channel.subscribe('exec-1', (frame) => seen.push(frame));
await sender.stop();
sender.send(ended());
expect(seen).toEqual([]);
});
it('stops delivery through the receiver', async () => {
const channel = new InMemoryExecutionResponseChannel();
const sender = new InMemoryExecutionResponseSender(channel);
const receiver = new InMemoryExecutionResponseReceiver(channel, mockLogger());
const seen: ExecutionResponse[] = [];
receiver.receive('exec-1', (response) => seen.push(response));
await receiver.stop();
sender.send(ended());
expect(seen).toEqual([]);
});
});
@@ -3,7 +3,7 @@ import { EngineConfig, ExecutionsConfig } from '@n8n/config';
import type { ModuleInterface } from '@n8n/decorators';
import { BackendModule, OnShutdown } from '@n8n/decorators';
import { Container } from '@n8n/di';
import type { ExecutionResponseChannel } from '@n8n/engine';
import type { ExecutionResponseSender } from '@n8n/engine';
import { UserError } from 'n8n-workflow';
import { randomBytes } from 'node:crypto';
@@ -19,7 +19,9 @@ import { randomBytes } from 'node:crypto';
*/
@BackendModule({ name: 'engine-v2', instanceTypes: ['main'] })
export class EngineV2Module implements ModuleInterface {
private responseChannel?: ExecutionResponseChannel;
private responseSender?: ExecutionResponseSender;
private responseReceiver?: { stop(): Promise<void> };
async init() {
if (Container.get(ExecutionsConfig).mode === 'queue') {
@@ -37,19 +39,30 @@ export class EngineV2Module implements ModuleInterface {
const { EngineControlPlaneServer } = await import('./engine-control-plane-server.js');
await Container.get(EngineControlPlaneServer).start();
// One channel, both planes. Handed over before the engine starts: a short
// run answers before `startExecution` returns, and nobody replays.
const { ExecutionResponseChannel, InMemoryResponseTransport } = await import('@n8n/engine');
// In-memory for now: both planes share this process. A transport that
// crosses one arrives with CAT-4572.
const responseChannel = new ExecutionResponseChannel(
new InMemoryResponseTransport(),
// Create both endpoints before the engine starts. A short run can answer
// before `startExecution` returns, and responses are not replayed.
const { InMemoryExecutionResponseChannel } = await import(
'./response-channel/in-memory-execution-response-channel.js'
);
const { InMemoryExecutionResponseSender } = await import(
'./response-channel/in-memory-execution-response-sender.js'
);
const { InMemoryExecutionResponseReceiver } = await import(
'./response-channel/in-memory-execution-response-receiver.js'
);
// In-memory for now because both planes share this process. Redis endpoints
// can use the same response contracts when the planes run separately.
const responseChannel = new InMemoryExecutionResponseChannel();
const responseSender = new InMemoryExecutionResponseSender(responseChannel);
const responseReceiver = new InMemoryExecutionResponseReceiver(
responseChannel,
Container.get(Logger).scoped('engine-v2'),
);
this.responseChannel = responseChannel;
this.responseSender = responseSender;
this.responseReceiver = responseReceiver;
const { EngineV2Runtime } = await import('./engine-v2.runtime.js');
await Container.get(EngineV2Runtime).init(responseChannel);
await Container.get(EngineV2Runtime).init(responseSender);
const { EngineDataPlaneClient } = await import('./engine-data-plane-client.js');
const { EngineDataPlaneProxyService } = await import(
@@ -67,8 +80,8 @@ export class EngineV2Module implements ModuleInterface {
await Container.get(EngineV2Runtime).shutdown();
} finally {
// After the engine, so a final response still has somewhere to go.
// In a `finally`, so a failed runtime shutdown still releases the channel.
await this.responseChannel?.stop();
// A failed runtime shutdown must still release both endpoints.
await Promise.all([this.responseSender?.stop(), this.responseReceiver?.stop()]);
}
// After the engine, so its final flush still has somewhere to land.
@@ -1,7 +1,7 @@
import { Logger } from '@n8n/backend-common';
import { EngineConfig } from '@n8n/config';
import { Service } from '@n8n/di';
import type { EngineRuntime, ExecutionResponseChannel } from '@n8n/engine';
import type { EngineRuntime, ExecutionResponseSender } from '@n8n/engine';
import {
AllowAllAdmittance,
createDataSource,
@@ -59,11 +59,11 @@ export class EngineV2Runtime {
this.logger = this.logger.scoped('engine-v2');
}
async init(responseChannel: ExecutionResponseChannel): Promise<void> {
async init(responseSender: ExecutionResponseSender): Promise<void> {
try {
await this.initDb();
this.initEngine(responseChannel);
this.initEngine(responseSender);
await this.initServer();
} catch (error) {
@@ -92,7 +92,7 @@ export class EngineV2Runtime {
await this.dataSource.runMigrations();
}
private initEngine(responseChannel: ExecutionResponseChannel): void {
private initEngine(responseSender: ExecutionResponseSender): void {
assert(this.dataSource, 'Engine 2.0 cannot start without a data source');
const stopping = new AbortController();
@@ -105,9 +105,7 @@ export class EngineV2Runtime {
admittance: new AllowAllAdmittance(),
identityVerifier: new SharedSecretIdentityVerifier(this.engineConfig.authSecret),
logger: this.logger,
// Whoever waits for a response subscribes to the same channel, so the
// host owns it and hands it in.
responseChannel,
responseSender,
externalDependencies: ({ executionStore, stepStore }) => ({
lifecycleEventCallback: async (events, signal) =>
await this.controlPlaneClient.sendLifecycleEvents(events, signal),
@@ -0,0 +1,12 @@
import type { ExecutionResponse } from '@n8n/engine';
export type UnsubscribeExecutionResponse = () => void;
/** Receives responses from executions and delivers them to their callers. */
export interface ExecutionResponseReceiver {
receive(
executionId: string,
handler: (response: ExecutionResponse) => void,
): UnsubscribeExecutionResponse;
stop(): Promise<void>;
}
@@ -0,0 +1,17 @@
import { EventEmitter } from 'node:events';
import type { UnsubscribeExecutionResponse } from './execution-response-receiver';
/** Carries serialized execution responses between endpoints in this process. */
export class InMemoryExecutionResponseChannel {
private readonly emitter = new EventEmitter();
publish(executionId: string, frame: string): void {
this.emitter.emit(executionId, frame);
}
subscribe(executionId: string, handler: (frame: string) => void): UnsubscribeExecutionResponse {
this.emitter.on(executionId, handler);
return () => this.emitter.off(executionId, handler);
}
}
@@ -0,0 +1,70 @@
import type { Logger } from '@n8n/backend-common';
import { executionResponseSchema, type ExecutionResponse } from '@n8n/engine';
import type {
ExecutionResponseReceiver,
UnsubscribeExecutionResponse,
} from './execution-response-receiver';
import type { InMemoryExecutionResponseChannel } from './in-memory-execution-response-channel';
export class InMemoryExecutionResponseReceiver implements ExecutionResponseReceiver {
private readonly subscriptions = new Set<UnsubscribeExecutionResponse>();
private stopped = false;
constructor(
private readonly channel: InMemoryExecutionResponseChannel,
private readonly logger: Logger,
) {}
receive(
executionId: string,
handler: (response: ExecutionResponse) => void,
): UnsubscribeExecutionResponse {
if (this.stopped) return () => {};
const unsubscribeFromChannel = this.channel.subscribe(executionId, (frame) => {
const response = this.fromFrame(frame);
if (response === undefined) return;
try {
handler(response);
} catch (error) {
this.logger.error('A response handler threw', {
executionId: response.executionId,
type: response.type,
error,
});
}
});
const unsubscribe = () => {
unsubscribeFromChannel();
this.subscriptions.delete(unsubscribe);
};
this.subscriptions.add(unsubscribe);
return unsubscribe;
}
async stop(): Promise<void> {
this.stopped = true;
for (const unsubscribe of this.subscriptions) unsubscribe();
}
private fromFrame(frame: string): ExecutionResponse | undefined {
try {
const parsed = executionResponseSchema.safeParse(JSON.parse(frame));
if (!parsed.success) {
this.logger.error('Discarding a malformed response', {
details: parsed.error.flatten(),
});
return undefined;
}
return parsed.data;
} catch (error) {
this.logger.error('Discarding an unreadable response', { error });
return undefined;
}
}
}
@@ -0,0 +1,19 @@
import type { ExecutionResponse, ExecutionResponseSender } from '@n8n/engine';
import type { InMemoryExecutionResponseChannel } from './in-memory-execution-response-channel';
export class InMemoryExecutionResponseSender implements ExecutionResponseSender {
private stopped = false;
constructor(private readonly channel: InMemoryExecutionResponseChannel) {}
send(response: ExecutionResponse): void {
if (this.stopped) return;
this.channel.publish(response.executionId, JSON.stringify(response));
}
async stop(): Promise<void> {
this.stopped = true;
}
}