mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-28 17:22:01 +08:00
feat(engine): Push engine 2.0 execution progress to the editor (#37142)
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
export { V1WorkflowConverter } from './v1-workflow-converter';
|
export { V1WorkflowConverter } from './v1-workflow-converter';
|
||||||
export { V1StepExecutor } from './v1-step-executor';
|
export { V1StepExecutor } from './v1-step-executor';
|
||||||
export { createEngineStepDataLoader } from './engine-step-data-loader';
|
export { createEngineStepDataLoader } from './engine-step-data-loader';
|
||||||
export { toStepOutputs } from './io';
|
export { fromStepInputs, toStepOutputs } from './io';
|
||||||
export { UnsupportedTriggerError, UnsupportedWorkflowError } from './errors';
|
export { UnsupportedTriggerError, UnsupportedWorkflowError } from './errors';
|
||||||
export type { StepData, StepDataLoader, V1StepExecutorDeps } from './types';
|
export type { StepData, StepDataLoader, V1StepExecutorDeps } from './types';
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import request from 'supertest';
|
|||||||
import { mock } from 'vitest-mock-extended';
|
import { mock } from 'vitest-mock-extended';
|
||||||
|
|
||||||
import { EngineControlPlaneServer } from '../engine-control-plane-server';
|
import { EngineControlPlaneServer } from '../engine-control-plane-server';
|
||||||
|
import type { EngineLifecycleEventPushRelay } from '../engine-lifecycle-event-push-relay';
|
||||||
import { EngineLifecycleEventController } from '../engine-lifecycle-event.controller';
|
import { EngineLifecycleEventController } from '../engine-lifecycle-event.controller';
|
||||||
|
|
||||||
const authSecret = 'a'.repeat(32);
|
const authSecret = 'a'.repeat(32);
|
||||||
@@ -21,8 +22,8 @@ const events: LifecycleEvent[] = [
|
|||||||
/** Binds for real, so these exercise the wiring rather than a mock app. */
|
/** Binds for real, so these exercise the wiring rather than a mock app. */
|
||||||
describe('EngineControlPlaneServer', () => {
|
describe('EngineControlPlaneServer', () => {
|
||||||
let server: EngineControlPlaneServer;
|
let server: EngineControlPlaneServer;
|
||||||
let logger: Logger;
|
|
||||||
let serverLogger: Logger;
|
let serverLogger: Logger;
|
||||||
|
let pushRelay: EngineLifecycleEventPushRelay;
|
||||||
let baseUrl: string;
|
let baseUrl: string;
|
||||||
|
|
||||||
const engineConfig = (overrides: Partial<EngineConfig> = {}) =>
|
const engineConfig = (overrides: Partial<EngineConfig> = {}) =>
|
||||||
@@ -35,11 +36,9 @@ describe('EngineControlPlaneServer', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
logger = mock<Logger>();
|
|
||||||
serverLogger = mock<Logger>();
|
serverLogger = mock<Logger>();
|
||||||
const controller = new EngineLifecycleEventController(
|
pushRelay = mock<EngineLifecycleEventPushRelay>();
|
||||||
mock<Logger>({ scoped: vi.fn().mockReturnValue(logger) }),
|
const controller = new EngineLifecycleEventController(pushRelay);
|
||||||
);
|
|
||||||
server = new EngineControlPlaneServer(
|
server = new EngineControlPlaneServer(
|
||||||
engineConfig(),
|
engineConfig(),
|
||||||
controller,
|
controller,
|
||||||
@@ -92,10 +91,8 @@ describe('EngineControlPlaneServer', () => {
|
|||||||
const response = await post({ events }, mintActionToken(authSecret, 'lifecycle-events:write'));
|
const response = await post({ events }, mintActionToken(authSecret, 'lifecycle-events:write'));
|
||||||
|
|
||||||
expect(response.status).toBe(204);
|
expect(response.status).toBe(204);
|
||||||
expect(logger.debug).toHaveBeenCalledExactlyOnceWith(
|
// Confirms the request reached the relay.
|
||||||
'Engine lifecycle event: execution:completed',
|
expect(pushRelay.relay).toHaveBeenCalledExactlyOnceWith(events);
|
||||||
events[0],
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
|
|||||||
+379
@@ -0,0 +1,379 @@
|
|||||||
|
import type { Logger } from '@n8n/backend-common';
|
||||||
|
import type { LifecycleEvent } from '@n8n/engine';
|
||||||
|
import type { PushMessage } from '@n8n/api-types';
|
||||||
|
import { mock } from 'vitest-mock-extended';
|
||||||
|
|
||||||
|
import type { Push } from '@/push';
|
||||||
|
import { EngineV2PushRegistry } from '@/services/engine-v2-push-registry.service';
|
||||||
|
|
||||||
|
import { EngineLifecycleEventPushRelay } from '../engine-lifecycle-event-push-relay';
|
||||||
|
|
||||||
|
const EXECUTION_ID = 'exec-1';
|
||||||
|
const PUSH_REF = 'push-1';
|
||||||
|
const WORKFLOW_ID = 'wf-1';
|
||||||
|
const TRIGGER_NAME = 'When clicking Execute';
|
||||||
|
|
||||||
|
const stepFields = {
|
||||||
|
executionId: EXECUTION_ID,
|
||||||
|
stepId: 'step-1',
|
||||||
|
nodeId: 'node-a',
|
||||||
|
nodeName: 'Edit Fields',
|
||||||
|
iteration: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const executionStarted: LifecycleEvent = {
|
||||||
|
type: 'execution:started',
|
||||||
|
executionId: EXECUTION_ID,
|
||||||
|
workflowId: WORKFLOW_ID,
|
||||||
|
mode: 'manual',
|
||||||
|
at: '2026-08-25T10:00:00.000Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
const stepStarted: LifecycleEvent = {
|
||||||
|
...stepFields,
|
||||||
|
type: 'step:started',
|
||||||
|
at: '2026-08-25T10:00:01.000Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
const stepCompleted: LifecycleEvent = {
|
||||||
|
...stepFields,
|
||||||
|
type: 'step:completed',
|
||||||
|
outputs: [[{ json: { greeting: 'hi' } }]],
|
||||||
|
at: '2026-08-25T10:00:01.500Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
const stepFailed: LifecycleEvent = {
|
||||||
|
...stepFields,
|
||||||
|
type: 'step:failed',
|
||||||
|
at: '2026-08-25T10:00:01.500Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('EngineLifecycleEventPushRelay', () => {
|
||||||
|
let push: Push;
|
||||||
|
let logger: Logger;
|
||||||
|
let registry: EngineV2PushRegistry;
|
||||||
|
let relay: EngineLifecycleEventPushRelay;
|
||||||
|
|
||||||
|
/** Sent push messages, in order. */
|
||||||
|
const sent = () => vi.mocked(push.send).mock.calls.map(([message]) => message);
|
||||||
|
|
||||||
|
const sentOfType = <T extends PushMessage['type']>(type: T) =>
|
||||||
|
sent().filter((message): message is Extract<PushMessage, { type: T }> => message.type === type);
|
||||||
|
|
||||||
|
const register = (overrides: { trigger?: { nodeName: string; outputs: never } } = {}) =>
|
||||||
|
registry.register(EXECUTION_ID, {
|
||||||
|
pushRef: PUSH_REF,
|
||||||
|
workflowId: WORKFLOW_ID,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
push = mock<Push>();
|
||||||
|
logger = mock<Logger>();
|
||||||
|
registry = new EngineV2PushRegistry();
|
||||||
|
relay = new EngineLifecycleEventPushRelay(
|
||||||
|
registry,
|
||||||
|
push,
|
||||||
|
mock<Logger>({ scoped: vi.fn().mockReturnValue(logger) }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends nothing for an execution it has no session for', () => {
|
||||||
|
relay.relay([executionStarted, stepStarted, stepCompleted]);
|
||||||
|
|
||||||
|
expect(push.send).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes every message to the session that started the run', () => {
|
||||||
|
register();
|
||||||
|
|
||||||
|
relay.relay([stepStarted, stepCompleted]);
|
||||||
|
|
||||||
|
for (const [, pushRef] of vi.mocked(push.send).mock.calls) {
|
||||||
|
expect(pushRef).toBe(PUSH_REF);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never sends executionStarted', () => {
|
||||||
|
// Would overwrite the editor's existing run data with an empty scaffold.
|
||||||
|
register();
|
||||||
|
|
||||||
|
relay.relay([executionStarted, stepStarted, stepCompleted]);
|
||||||
|
|
||||||
|
expect(sentOfType('executionStarted')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the trigger', () => {
|
||||||
|
const triggerOutputs = [[{ json: { first: true } }]];
|
||||||
|
|
||||||
|
const registerWithTrigger = () =>
|
||||||
|
registry.register(EXECUTION_ID, {
|
||||||
|
pushRef: PUSH_REF,
|
||||||
|
workflowId: WORKFLOW_ID,
|
||||||
|
trigger: { nodeName: TRIGGER_NAME, outputs: triggerOutputs },
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports its run, because the engine never announces it', () => {
|
||||||
|
registerWithTrigger();
|
||||||
|
|
||||||
|
relay.relay([executionStarted]);
|
||||||
|
|
||||||
|
expect(sent().map((message) => message.type)).toEqual([
|
||||||
|
'nodeExecuteBefore',
|
||||||
|
'nodeExecuteAfter',
|
||||||
|
'nodeExecuteAfterData',
|
||||||
|
]);
|
||||||
|
const [before] = sentOfType('nodeExecuteBefore');
|
||||||
|
expect(before.data.nodeName).toBe(TRIGGER_NAME);
|
||||||
|
expect(before.data.data.executionIndex).toBe(0);
|
||||||
|
expect(sentOfType('nodeExecuteAfterData')[0].data.data.data).toEqual({
|
||||||
|
main: triggerOutputs,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports it only once, however often the update is redelivered', () => {
|
||||||
|
registerWithTrigger();
|
||||||
|
|
||||||
|
relay.relay([executionStarted, executionStarted]);
|
||||||
|
|
||||||
|
expect(sentOfType('nodeExecuteBefore')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports nothing when the run named no trigger', () => {
|
||||||
|
register();
|
||||||
|
|
||||||
|
relay.relay([executionStarted]);
|
||||||
|
|
||||||
|
expect(push.send).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('step:started', () => {
|
||||||
|
it('sends nodeExecuteBefore with the step start time', () => {
|
||||||
|
register();
|
||||||
|
|
||||||
|
relay.relay([stepStarted]);
|
||||||
|
|
||||||
|
const [before] = sentOfType('nodeExecuteBefore');
|
||||||
|
expect(before.data).toEqual({
|
||||||
|
executionId: EXECUTION_ID,
|
||||||
|
nodeName: 'Edit Fields',
|
||||||
|
sequenceNumber: 0,
|
||||||
|
data: {
|
||||||
|
startTime: Date.parse(stepStarted.at),
|
||||||
|
executionIndex: 0,
|
||||||
|
source: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a redelivered start', () => {
|
||||||
|
register();
|
||||||
|
|
||||||
|
relay.relay([stepStarted, stepStarted]);
|
||||||
|
|
||||||
|
expect(sentOfType('nodeExecuteBefore')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('step:completed', () => {
|
||||||
|
it('sends nodeExecuteAfter without the data, then nodeExecuteAfterData with it', () => {
|
||||||
|
register();
|
||||||
|
|
||||||
|
relay.relay([stepStarted, stepCompleted]);
|
||||||
|
|
||||||
|
const [after] = sentOfType('nodeExecuteAfter');
|
||||||
|
expect(after.data.data).not.toHaveProperty('data');
|
||||||
|
expect(after.data.data.executionStatus).toBe('success');
|
||||||
|
expect(after.data.itemCountByConnectionType).toEqual({ main: [1] });
|
||||||
|
|
||||||
|
const [afterData] = sentOfType('nodeExecuteAfterData');
|
||||||
|
expect(afterData.data.data.data).toEqual({ main: [[{ json: { greeting: 'hi' } }]] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends the output data as a binary frame', () => {
|
||||||
|
register();
|
||||||
|
|
||||||
|
relay.relay([stepStarted, stepCompleted]);
|
||||||
|
|
||||||
|
const call = vi
|
||||||
|
.mocked(push.send)
|
||||||
|
.mock.calls.find(([message]) => message.type === 'nodeExecuteAfterData');
|
||||||
|
expect(call?.[2]).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reuses the executionIndex allocated at the start, so the two messages pair up', () => {
|
||||||
|
register();
|
||||||
|
|
||||||
|
relay.relay([stepStarted, stepCompleted]);
|
||||||
|
|
||||||
|
const [before] = sentOfType('nodeExecuteBefore');
|
||||||
|
const [after] = sentOfType('nodeExecuteAfter');
|
||||||
|
const [afterData] = sentOfType('nodeExecuteAfterData');
|
||||||
|
expect(after.data.data.executionIndex).toBe(before.data.data.executionIndex);
|
||||||
|
expect(afterData.data.data.executionIndex).toBe(before.data.data.executionIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports the time between the two updates', () => {
|
||||||
|
register();
|
||||||
|
|
||||||
|
relay.relay([stepStarted, stepCompleted]);
|
||||||
|
|
||||||
|
expect(sentOfType('nodeExecuteAfter')[0].data.data.executionTime).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turns a slot the step did not fire into an empty branch', () => {
|
||||||
|
register();
|
||||||
|
|
||||||
|
relay.relay([stepStarted, { ...stepCompleted, outputs: [[{ json: { a: 1 } }], null] }]);
|
||||||
|
|
||||||
|
const [after] = sentOfType('nodeExecuteAfter');
|
||||||
|
expect(after.data.itemCountByConnectionType).toEqual({ main: [1, 0] });
|
||||||
|
expect(sentOfType('nodeExecuteAfterData')[0].data.data.data).toEqual({
|
||||||
|
main: [[{ json: { a: 1 } }], []],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still reports the outcome when the start was lost', () => {
|
||||||
|
register();
|
||||||
|
|
||||||
|
relay.relay([stepCompleted]);
|
||||||
|
|
||||||
|
expect(sentOfType('nodeExecuteAfter')).toHaveLength(1);
|
||||||
|
expect(sentOfType('nodeExecuteAfterData')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a redelivered completion', () => {
|
||||||
|
register();
|
||||||
|
|
||||||
|
relay.relay([stepStarted, stepCompleted, stepCompleted]);
|
||||||
|
|
||||||
|
expect(sentOfType('nodeExecuteAfter')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports the outcome on redelivery when the first send failed', () => {
|
||||||
|
register();
|
||||||
|
vi.mocked(push.send).mockImplementationOnce(() => {
|
||||||
|
throw new Error('socket gone');
|
||||||
|
});
|
||||||
|
|
||||||
|
relay.relay([stepCompleted, stepCompleted]);
|
||||||
|
|
||||||
|
// The failed attempt sent nothing, so only the redelivery reported the run.
|
||||||
|
expect(sentOfType('nodeExecuteAfterData')).toHaveLength(1);
|
||||||
|
// The retry reuses the run, so the editor replaces it instead of appending.
|
||||||
|
const indexes = sentOfType('nodeExecuteAfter').map((m) => m.data.data.executionIndex);
|
||||||
|
expect(indexes).toEqual([0, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives each step its own executionIndex and a rising sequenceNumber', () => {
|
||||||
|
register();
|
||||||
|
const second = { ...stepFields, stepId: 'step-2', nodeName: 'Edit Fields 2' };
|
||||||
|
|
||||||
|
relay.relay([
|
||||||
|
stepStarted,
|
||||||
|
stepCompleted,
|
||||||
|
{ ...second, type: 'step:started', at: '2026-08-25T10:00:02.000Z' },
|
||||||
|
{ ...second, type: 'step:completed', outputs: [[]], at: '2026-08-25T10:00:03.000Z' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(sentOfType('nodeExecuteBefore').map((m) => m.data.data.executionIndex)).toEqual([
|
||||||
|
0, 1,
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
[
|
||||||
|
...sentOfType('nodeExecuteBefore').map((m) => m.data.sequenceNumber),
|
||||||
|
...sentOfType('nodeExecuteAfter').map((m) => m.data.sequenceNumber),
|
||||||
|
].sort(),
|
||||||
|
).toEqual([0, 1, 2, 3]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('step:failed', () => {
|
||||||
|
it('marks the node failed and sends no data message', () => {
|
||||||
|
register();
|
||||||
|
|
||||||
|
relay.relay([stepStarted, stepFailed]);
|
||||||
|
|
||||||
|
const [after] = sentOfType('nodeExecuteAfter');
|
||||||
|
expect(after.data.data.executionStatus).toBe('error');
|
||||||
|
// The editor keys failure display on `error`, not the status.
|
||||||
|
expect(after.data.data.error).toBeDefined();
|
||||||
|
expect(after.data.itemCountByConnectionType).toEqual({});
|
||||||
|
expect(sentOfType('nodeExecuteAfterData')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends an error that survives the wire', () => {
|
||||||
|
// A plain `Error` would serialize to `{}`.
|
||||||
|
register();
|
||||||
|
|
||||||
|
relay.relay([stepStarted, stepFailed]);
|
||||||
|
|
||||||
|
const { error } = sentOfType('nodeExecuteAfter')[0].data.data;
|
||||||
|
expect(JSON.stringify(error)).toContain('Node execution failed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the end of an execution', () => {
|
||||||
|
it.each([
|
||||||
|
['execution:completed', 'success'],
|
||||||
|
['execution:failed', 'error'],
|
||||||
|
] as const)('maps %s to executionFinished %s', (type, status) => {
|
||||||
|
register();
|
||||||
|
|
||||||
|
relay.relay([
|
||||||
|
{
|
||||||
|
type,
|
||||||
|
executionId: EXECUTION_ID,
|
||||||
|
workflowId: WORKFLOW_ID,
|
||||||
|
at: '2026-08-25T10:00:04.000Z',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(sentOfType('executionFinished')[0].data).toEqual({
|
||||||
|
executionId: EXECUTION_ID,
|
||||||
|
workflowId: WORKFLOW_ID,
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('releases the session, so later updates are ignored', () => {
|
||||||
|
register();
|
||||||
|
|
||||||
|
relay.relay([
|
||||||
|
{
|
||||||
|
type: 'execution:completed',
|
||||||
|
executionId: EXECUTION_ID,
|
||||||
|
workflowId: WORKFLOW_ID,
|
||||||
|
at: '2026-08-25T10:00:04.000Z',
|
||||||
|
},
|
||||||
|
stepStarted,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(registry.get(EXECUTION_ID)).toBeUndefined();
|
||||||
|
expect(sentOfType('nodeExecuteBefore')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps two concurrent runs apart', () => {
|
||||||
|
register();
|
||||||
|
registry.register('exec-2', { pushRef: 'push-2', workflowId: 'wf-2' });
|
||||||
|
|
||||||
|
relay.relay([stepStarted, { ...stepStarted, executionId: 'exec-2', stepId: 'other-step' }]);
|
||||||
|
|
||||||
|
const refs = vi.mocked(push.send).mock.calls.map(([, pushRef]) => pushRef);
|
||||||
|
expect(refs).toEqual([PUSH_REF, 'push-2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs a failing update and relays the rest of the batch', () => {
|
||||||
|
register();
|
||||||
|
vi.mocked(push.send).mockImplementationOnce(() => {
|
||||||
|
throw new Error('socket gone');
|
||||||
|
});
|
||||||
|
|
||||||
|
relay.relay([stepStarted, stepCompleted]);
|
||||||
|
|
||||||
|
expect(logger.error).toHaveBeenCalledTimes(1);
|
||||||
|
expect(sentOfType('nodeExecuteAfter')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
+8
-34
@@ -1,4 +1,3 @@
|
|||||||
import type { Logger } from '@n8n/backend-common';
|
|
||||||
import type { LifecycleEvent } from '@n8n/engine';
|
import type { LifecycleEvent } from '@n8n/engine';
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import type { Mocked } from 'vitest';
|
import type { Mocked } from 'vitest';
|
||||||
@@ -6,6 +5,7 @@ import { mock } from 'vitest-mock-extended';
|
|||||||
|
|
||||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||||
|
|
||||||
|
import type { EngineLifecycleEventPushRelay } from '../engine-lifecycle-event-push-relay';
|
||||||
import { EngineLifecycleEventController } from '../engine-lifecycle-event.controller';
|
import { EngineLifecycleEventController } from '../engine-lifecycle-event.controller';
|
||||||
|
|
||||||
const events: LifecycleEvent[] = [
|
const events: LifecycleEvent[] = [
|
||||||
@@ -29,8 +29,7 @@ const events: LifecycleEvent[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
describe('EngineLifecycleEventController', () => {
|
describe('EngineLifecycleEventController', () => {
|
||||||
// The controller scopes its logger, so assert on the scoped one.
|
let pushRelay: EngineLifecycleEventPushRelay;
|
||||||
let logger: Logger;
|
|
||||||
let controller: EngineLifecycleEventController;
|
let controller: EngineLifecycleEventController;
|
||||||
|
|
||||||
const newResponse = () => {
|
const newResponse = () => {
|
||||||
@@ -47,10 +46,8 @@ describe('EngineLifecycleEventController', () => {
|
|||||||
const newRequest = (body: unknown = { events }) => ({ body }) as unknown as Request;
|
const newRequest = (body: unknown = { events }) => ({ body }) as unknown as Request;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
logger = mock<Logger>();
|
pushRelay = mock<EngineLifecycleEventPushRelay>();
|
||||||
controller = new EngineLifecycleEventController(
|
controller = new EngineLifecycleEventController(pushRelay);
|
||||||
mock<Logger>({ scoped: vi.fn().mockReturnValue(logger) }),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('receiveLifecycleEvents', () => {
|
describe('receiveLifecycleEvents', () => {
|
||||||
@@ -63,35 +60,10 @@ describe('EngineLifecycleEventController', () => {
|
|||||||
expect(res.end).toHaveBeenCalled();
|
expect(res.end).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('logs every event in the batch, not just a count', async () => {
|
it('hands the whole batch to the push relay, in order', async () => {
|
||||||
await controller.receiveLifecycleEvents(newRequest(), newResponse());
|
await controller.receiveLifecycleEvents(newRequest(), newResponse());
|
||||||
|
|
||||||
expect(logger.debug).toHaveBeenCalledTimes(2);
|
expect(pushRelay.relay).toHaveBeenCalledExactlyOnceWith(events);
|
||||||
expect(logger.debug).toHaveBeenNthCalledWith(
|
|
||||||
1,
|
|
||||||
'Engine lifecycle event: execution:started',
|
|
||||||
events[0],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('logs a completed step by its output slot count, never its contents', async () => {
|
|
||||||
// A log must not become a copy of a user's execution data.
|
|
||||||
await controller.receiveLifecycleEvents(newRequest(), newResponse());
|
|
||||||
|
|
||||||
const [message, metadata] = vi.mocked(logger.debug).mock.calls[1];
|
|
||||||
|
|
||||||
expect(message).toBe('Engine lifecycle event: step:completed');
|
|
||||||
expect(metadata).toEqual({
|
|
||||||
type: 'step:completed',
|
|
||||||
executionId: 'exec-1',
|
|
||||||
stepId: 'step-1',
|
|
||||||
nodeId: 'node-a',
|
|
||||||
nodeName: 'Edit Fields',
|
|
||||||
iteration: 0,
|
|
||||||
at: '2026-08-24T10:00:01.000Z',
|
|
||||||
outputSlots: 1,
|
|
||||||
});
|
|
||||||
expect(JSON.stringify(metadata)).not.toContain('greeting');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
@@ -105,6 +77,8 @@ describe('EngineLifecycleEventController', () => {
|
|||||||
BadRequestError,
|
BadRequestError,
|
||||||
);
|
);
|
||||||
expect(res.status).not.toHaveBeenCalled();
|
expect(res.status).not.toHaveBeenCalled();
|
||||||
|
// Unvalidated input must never reach the relay.
|
||||||
|
expect(pushRelay.relay).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
import { Logger } from '@n8n/backend-common';
|
||||||
|
import { Service } from '@n8n/di';
|
||||||
|
import type { LifecycleEvent } from '@n8n/engine';
|
||||||
|
import { fromStepInputs } from '@n8n/node-engine-compatibility';
|
||||||
|
import type { ExecutionStatus, INodeExecutionData, ITaskData } from 'n8n-workflow';
|
||||||
|
import { WorkflowOperationError } from 'n8n-workflow';
|
||||||
|
|
||||||
|
import { Push } from '@/push';
|
||||||
|
import { EngineV2PushRegistry } from '@/services/engine-v2-push-registry.service';
|
||||||
|
import type { EngineV2PushSession } from '@/services/engine-v2-push-session';
|
||||||
|
import { EngineV2StepRun } from '@/services/engine-v2-push-session';
|
||||||
|
import { getItemCountByConnectionType } from '@/utils/get-item-count-by-connection-type';
|
||||||
|
|
||||||
|
/** A lifecycle event scoped to one step. */
|
||||||
|
type StepUpdate = Extract<LifecycleEvent, { stepId: string }>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Placeholder error: no failure detail is available yet, and without an
|
||||||
|
* `error` the editor would show a failed step as successful.
|
||||||
|
*
|
||||||
|
* TODO(CAT-2878 follow-up): carry the real failure through and drop this.
|
||||||
|
*/
|
||||||
|
const STEP_FAILURE_DESCRIPTION = 'Engine 2.0 does not report error detail yet.';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relays engine lifecycle events to the editor as push messages.
|
||||||
|
*
|
||||||
|
* Reuses the push messages v1 runs already send, so the frontend needs no
|
||||||
|
* engine-specific code.
|
||||||
|
*/
|
||||||
|
@Service()
|
||||||
|
export class EngineLifecycleEventPushRelay {
|
||||||
|
constructor(
|
||||||
|
private readonly registry: EngineV2PushRegistry,
|
||||||
|
private readonly push: Push,
|
||||||
|
private readonly logger: Logger,
|
||||||
|
) {
|
||||||
|
this.logger = this.logger.scoped('engine-v2');
|
||||||
|
}
|
||||||
|
|
||||||
|
relay(events: LifecycleEvent[]): void {
|
||||||
|
for (const update of events) {
|
||||||
|
const session = this.registry.get(update.executionId);
|
||||||
|
// No session means nothing is watching this run — safe to drop.
|
||||||
|
if (!session) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.relayOne(update, session);
|
||||||
|
} catch (error) {
|
||||||
|
// Isolate failures so one bad event doesn't drop the rest of the batch.
|
||||||
|
this.logger.error('Failed to relay engine lifecycle event to the editor', {
|
||||||
|
executionId: update.executionId,
|
||||||
|
type: update.type,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private relayOne(update: LifecycleEvent, session: EngineV2PushSession): void {
|
||||||
|
switch (update.type) {
|
||||||
|
case 'execution:started':
|
||||||
|
return this.onExecutionStarted(update.executionId, update.at, session);
|
||||||
|
case 'step:started':
|
||||||
|
return this.onStepStarted(update, session);
|
||||||
|
case 'step:completed':
|
||||||
|
return this.onStepSettled(update, session, fromStepInputs(update.outputs));
|
||||||
|
case 'step:failed':
|
||||||
|
return this.onStepSettled(update, session, undefined);
|
||||||
|
case 'execution:completed':
|
||||||
|
return this.onExecutionFinished(update.executionId, update.workflowId, 'success', session);
|
||||||
|
case 'execution:failed':
|
||||||
|
return this.onExecutionFinished(update.executionId, update.workflowId, 'error', session);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reports the trigger's run, since the engine never announces it as a step.
|
||||||
|
*
|
||||||
|
* Sends no `executionStarted`: the editor already promoted the run from the
|
||||||
|
* dispatch response, and the message would overwrite its run data with an
|
||||||
|
* empty scaffold. Runs the editor did not start need it — TODO(CAT-4258).
|
||||||
|
*/
|
||||||
|
private onExecutionStarted(executionId: string, at: string, session: EngineV2PushSession): void {
|
||||||
|
const { trigger } = session;
|
||||||
|
// Clear before use so a redelivery can't re-emit the run or hold onto
|
||||||
|
// the (possibly large) pinned data.
|
||||||
|
session.trigger = undefined;
|
||||||
|
// No trigger, no node to report outputs for.
|
||||||
|
if (!trigger) return;
|
||||||
|
|
||||||
|
// The trigger has no step id, so it isn't tracked in `steps`.
|
||||||
|
const run = new EngineV2StepRun(session.nextExecutionIndex++, Date.parse(at));
|
||||||
|
|
||||||
|
this.sendNodeExecuteBefore(executionId, trigger.nodeName, run, session);
|
||||||
|
this.sendNodeExecuteAfter(executionId, trigger.nodeName, run, session, {
|
||||||
|
executionTime: 0,
|
||||||
|
outputs: trigger.outputs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private onStepStarted(update: StepUpdate, session: EngineV2PushSession): void {
|
||||||
|
if (session.steps.has(update.stepId)) return;
|
||||||
|
|
||||||
|
const run = new EngineV2StepRun(session.nextExecutionIndex++, Date.parse(update.at));
|
||||||
|
session.steps.set(update.stepId, run);
|
||||||
|
|
||||||
|
this.sendNodeExecuteBefore(update.executionId, update.nodeName, run, session);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param outputs The step's output slots, or `undefined` when it failed.
|
||||||
|
*/
|
||||||
|
private onStepSettled(
|
||||||
|
update: StepUpdate,
|
||||||
|
session: EngineV2PushSession,
|
||||||
|
outputs: INodeExecutionData[][] | undefined,
|
||||||
|
): void {
|
||||||
|
const started = session.steps.get(update.stepId);
|
||||||
|
if (started?.settled) return;
|
||||||
|
|
||||||
|
// A missing `step:started` must not lose the outcome too.
|
||||||
|
const run = started ?? new EngineV2StepRun(session.nextExecutionIndex++, Date.parse(update.at));
|
||||||
|
session.steps.set(update.stepId, run);
|
||||||
|
|
||||||
|
this.sendNodeExecuteAfter(update.executionId, update.nodeName, run, session, {
|
||||||
|
// Same clock, same process — the difference is safe to trust.
|
||||||
|
executionTime: Math.max(0, Date.parse(update.at) - run.startTime),
|
||||||
|
outputs,
|
||||||
|
});
|
||||||
|
|
||||||
|
run.settled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private onExecutionFinished(
|
||||||
|
executionId: string,
|
||||||
|
workflowId: string,
|
||||||
|
status: ExecutionStatus,
|
||||||
|
session: EngineV2PushSession,
|
||||||
|
): void {
|
||||||
|
this.push.send(
|
||||||
|
{ type: 'executionFinished', data: { executionId, workflowId, status } },
|
||||||
|
session.pushRef,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Releasing here makes a redelivered terminal event a no-op.
|
||||||
|
this.registry.release(executionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sendNodeExecuteBefore(
|
||||||
|
executionId: string,
|
||||||
|
nodeName: string,
|
||||||
|
run: EngineV2StepRun,
|
||||||
|
session: EngineV2PushSession,
|
||||||
|
): void {
|
||||||
|
this.push.send(
|
||||||
|
{
|
||||||
|
type: 'nodeExecuteBefore',
|
||||||
|
data: {
|
||||||
|
executionId,
|
||||||
|
nodeName,
|
||||||
|
sequenceNumber: session.sequenceNumber++,
|
||||||
|
// No input lineage to report, so `source` is empty. See CAT-4265.
|
||||||
|
data: { startTime: run.startTime, executionIndex: run.executionIndex, source: [] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
session.pushRef,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sendNodeExecuteAfter(
|
||||||
|
executionId: string,
|
||||||
|
nodeName: string,
|
||||||
|
run: EngineV2StepRun,
|
||||||
|
session: EngineV2PushSession,
|
||||||
|
result: { executionTime: number; outputs: INodeExecutionData[][] | undefined },
|
||||||
|
): void {
|
||||||
|
const { executionTime, outputs } = result;
|
||||||
|
// Every output slot is `main`: no other connection type exists here.
|
||||||
|
const data = outputs ? { main: outputs } : undefined;
|
||||||
|
|
||||||
|
const taskData: ITaskData = {
|
||||||
|
startTime: run.startTime,
|
||||||
|
executionIndex: run.executionIndex,
|
||||||
|
// No input lineage to report, so `source` is empty. See CAT-4265.
|
||||||
|
source: [],
|
||||||
|
executionTime,
|
||||||
|
executionStatus: outputs ? 'success' : 'error',
|
||||||
|
data,
|
||||||
|
...(outputs
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
error: new WorkflowOperationError(
|
||||||
|
'Node execution failed',
|
||||||
|
undefined,
|
||||||
|
STEP_FAILURE_DESCRIPTION,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const itemCountByConnectionType = getItemCountByConnectionType(data);
|
||||||
|
const { data: _withheld, ...trimmed } = taskData;
|
||||||
|
|
||||||
|
this.push.send(
|
||||||
|
{
|
||||||
|
type: 'nodeExecuteAfter',
|
||||||
|
data: {
|
||||||
|
executionId,
|
||||||
|
nodeName,
|
||||||
|
sequenceNumber: session.sequenceNumber++,
|
||||||
|
data: trimmed,
|
||||||
|
itemCountByConnectionType,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
session.pushRef,
|
||||||
|
);
|
||||||
|
|
||||||
|
// A failed step has no data, so there's no second message to send.
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
// TODO(CAT-2878 follow-up): redact this before sending; needs a resolved
|
||||||
|
// user, which isn't available here yet.
|
||||||
|
|
||||||
|
// Binary avoids a copy: the editor hands it straight to a worker.
|
||||||
|
const asBinary = true;
|
||||||
|
this.push.send(
|
||||||
|
{
|
||||||
|
type: 'nodeExecuteAfterData',
|
||||||
|
data: { executionId, nodeName, data: taskData, itemCountByConnectionType },
|
||||||
|
},
|
||||||
|
session.pushRef,
|
||||||
|
asBinary,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,15 @@
|
|||||||
import { Logger } from '@n8n/backend-common';
|
|
||||||
import { Service } from '@n8n/di';
|
import { Service } from '@n8n/di';
|
||||||
import type { LifecycleEvent } from '@n8n/engine';
|
|
||||||
import { lifecycleEventBatchSchema } from '@n8n/engine';
|
import { lifecycleEventBatchSchema } from '@n8n/engine';
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
|
|
||||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||||
|
|
||||||
|
import { EngineLifecycleEventPushRelay } from './engine-lifecycle-event-push-relay';
|
||||||
|
|
||||||
/** Handles `LifecycleEvent` batches from the engine 2.0 data plane. */
|
/** Handles `LifecycleEvent` batches from the engine 2.0 data plane. */
|
||||||
@Service()
|
@Service()
|
||||||
export class EngineLifecycleEventController {
|
export class EngineLifecycleEventController {
|
||||||
constructor(private readonly logger: Logger) {
|
constructor(private readonly pushRelay: EngineLifecycleEventPushRelay) {}
|
||||||
this.logger = this.logger.scoped('engine-v2');
|
|
||||||
}
|
|
||||||
|
|
||||||
async receiveLifecycleEvents(req: Request, res: Response): Promise<void> {
|
async receiveLifecycleEvents(req: Request, res: Response): Promise<void> {
|
||||||
const parsed = lifecycleEventBatchSchema.safeParse(req.body);
|
const parsed = lifecycleEventBatchSchema.safeParse(req.body);
|
||||||
@@ -19,23 +17,14 @@ export class EngineLifecycleEventController {
|
|||||||
// stays out of the response: only a data plane calls this.
|
// stays out of the response: only a data plane calls this.
|
||||||
if (!parsed.success) throw new BadRequestError('Invalid lifecycle event batch');
|
if (!parsed.success) throw new BadRequestError('Invalid lifecycle event batch');
|
||||||
|
|
||||||
// TODO(CAT-2878): forward these to the editor and dispatch the error workflow.
|
// TODO(CAT-2877 follow-up): dispatch the error workflow on
|
||||||
for (const event of parsed.data.events) {
|
// `execution:failed` once the failure reason and run data are available.
|
||||||
this.logger.debug(`Engine lifecycle event: ${event.type}`, toLogMetadata(event));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nothing to return, and re-delivery is harmless while this only logs.
|
// Never throws: a bad event is logged and skipped inside.
|
||||||
|
this.pushRelay.relay(parsed.data.events);
|
||||||
|
|
||||||
|
// Re-delivering a batch is harmless: the relay ignores events it has
|
||||||
|
// already reported.
|
||||||
res.status(204).end();
|
res.status(204).end();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* An event's identifiers, ready to log. Outputs become a slot count, so a log
|
|
||||||
* never becomes a copy of a user's execution data.
|
|
||||||
*/
|
|
||||||
function toLogMetadata(event: LifecycleEvent): Record<string, unknown> {
|
|
||||||
if (event.type !== 'step:completed') return { ...event };
|
|
||||||
|
|
||||||
const { outputs, ...rest } = event;
|
|
||||||
return { ...rest, outputSlots: outputs.length };
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import type { CredentialsPermissionChecker } from '@/executions/pre-execution-ch
|
|||||||
import type { ResumableExecution } from '@/interfaces';
|
import type { ResumableExecution } from '@/interfaces';
|
||||||
import type { EngineDataPlaneProxyService } from '@/services/engine-data-plane-proxy.service';
|
import type { EngineDataPlaneProxyService } from '@/services/engine-data-plane-proxy.service';
|
||||||
import { EngineV2Dispatcher } from '@/services/engine-v2-dispatcher.service';
|
import { EngineV2Dispatcher } from '@/services/engine-v2-dispatcher.service';
|
||||||
|
import type { EngineV2PushRegistry } from '@/services/engine-v2-push-registry.service';
|
||||||
|
|
||||||
const node = (id: string, name: string, type: string): INode => ({
|
const node = (id: string, name: string, type: string): INode => ({
|
||||||
id,
|
id,
|
||||||
@@ -72,6 +73,7 @@ function runData(
|
|||||||
describe('EngineV2Dispatcher', () => {
|
describe('EngineV2Dispatcher', () => {
|
||||||
const proxy = mock<EngineDataPlaneProxyService>();
|
const proxy = mock<EngineDataPlaneProxyService>();
|
||||||
const credentialsPermissionChecker = mock<CredentialsPermissionChecker>();
|
const credentialsPermissionChecker = mock<CredentialsPermissionChecker>();
|
||||||
|
const pushRegistry = mock<EngineV2PushRegistry>();
|
||||||
|
|
||||||
let dispatcher: EngineV2Dispatcher;
|
let dispatcher: EngineV2Dispatcher;
|
||||||
|
|
||||||
@@ -79,7 +81,7 @@ describe('EngineV2Dispatcher', () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
proxy.isAvailable.mockReturnValue(true);
|
proxy.isAvailable.mockReturnValue(true);
|
||||||
proxy.startExecution.mockResolvedValue({ executionId: 'dp-uuid' });
|
proxy.startExecution.mockResolvedValue({ executionId: 'dp-uuid' });
|
||||||
dispatcher = new EngineV2Dispatcher(proxy, credentialsPermissionChecker);
|
dispatcher = new EngineV2Dispatcher(proxy, credentialsPermissionChecker, pushRegistry);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('routesToEngineV2', () => {
|
describe('routesToEngineV2', () => {
|
||||||
@@ -317,5 +319,48 @@ describe('EngineV2Dispatcher', () => {
|
|||||||
expect(startedWith()).toBeNull();
|
expect(startedWith()).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('the push session', () => {
|
||||||
|
it('records the run against the data plane execution id', async () => {
|
||||||
|
await dispatcher.start(runData({ pushRef: 'push-1' }));
|
||||||
|
|
||||||
|
expect(pushRegistry.register).toHaveBeenCalledExactlyOnceWith('dp-uuid', {
|
||||||
|
pushRef: 'push-1',
|
||||||
|
workflowId: 'wf-1',
|
||||||
|
trigger: { nodeName: MANUAL_TRIGGER.name, outputs: [[{ json: {} }]] },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records the trigger payload the engine was given', async () => {
|
||||||
|
const data = runData({
|
||||||
|
pushRef: 'push-1',
|
||||||
|
triggerToStartFrom: {
|
||||||
|
name: MANUAL_TRIGGER.name,
|
||||||
|
data: taskData([[{ json: { from: 'trigger' } }]]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await dispatcher.start(data);
|
||||||
|
|
||||||
|
expect(pushRegistry.register.mock.calls[0][1].trigger).toEqual({
|
||||||
|
nodeName: MANUAL_TRIGGER.name,
|
||||||
|
outputs: [[{ json: { from: 'trigger' } }]],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records nothing when nothing is watching the run', async () => {
|
||||||
|
await dispatcher.start(runData());
|
||||||
|
|
||||||
|
expect(pushRegistry.register).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records nothing when the data plane refused the run', async () => {
|
||||||
|
proxy.startExecution.mockRejectedValueOnce(new Error('down'));
|
||||||
|
|
||||||
|
await expect(dispatcher.start(runData({ pushRef: 'push-1' }))).rejects.toThrow('down');
|
||||||
|
|
||||||
|
expect(pushRegistry.register).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { EngineV2PushRegistry } from '@/services/engine-v2-push-registry.service';
|
||||||
|
|
||||||
|
const HOUR_MS = 60 * 60 * 1000;
|
||||||
|
const TTL_MS = 12 * HOUR_MS;
|
||||||
|
|
||||||
|
describe('EngineV2PushRegistry', () => {
|
||||||
|
let registry: EngineV2PushRegistry;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
registry = new EngineV2PushRegistry();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns nothing for an execution it never saw', () => {
|
||||||
|
expect(registry.get('unknown')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts a session with zeroed counters and no steps', () => {
|
||||||
|
registry.register('exec-1', { pushRef: 'push-1', workflowId: 'wf-1' });
|
||||||
|
|
||||||
|
expect(registry.get('exec-1')).toMatchObject({
|
||||||
|
pushRef: 'push-1',
|
||||||
|
workflowId: 'wf-1',
|
||||||
|
sequenceNumber: 0,
|
||||||
|
nextExecutionIndex: 0,
|
||||||
|
});
|
||||||
|
expect(registry.get('exec-1')?.steps.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the trigger the run started from', () => {
|
||||||
|
const outputs = [[{ json: { x: 1 } }]];
|
||||||
|
registry.register('exec-1', {
|
||||||
|
pushRef: 'push-1',
|
||||||
|
workflowId: 'wf-1',
|
||||||
|
trigger: { nodeName: 'When clicking Execute', outputs },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(registry.get('exec-1')?.trigger).toEqual({
|
||||||
|
nodeName: 'When clicking Execute',
|
||||||
|
outputs,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('releases a session, and releasing twice is a no-op', () => {
|
||||||
|
registry.register('exec-1', { pushRef: 'push-1', workflowId: 'wf-1' });
|
||||||
|
|
||||||
|
registry.release('exec-1');
|
||||||
|
registry.release('exec-1');
|
||||||
|
|
||||||
|
expect(registry.get('exec-1')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps sessions apart', () => {
|
||||||
|
registry.register('exec-1', { pushRef: 'push-1', workflowId: 'wf-1' });
|
||||||
|
registry.register('exec-2', { pushRef: 'push-2', workflowId: 'wf-2' });
|
||||||
|
|
||||||
|
registry.release('exec-1');
|
||||||
|
|
||||||
|
expect(registry.get('exec-2')?.pushRef).toBe('push-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('eviction', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops a session whose terminal update never arrived', () => {
|
||||||
|
// No `cancelled` event exists, so an unreleased session must expire.
|
||||||
|
vi.setSystemTime(new Date('2026-08-25T10:00:00.000Z'));
|
||||||
|
registry.register('stale', { pushRef: 'push-1', workflowId: 'wf-1' });
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(TTL_MS + 1000);
|
||||||
|
registry.register('fresh', { pushRef: 'push-2', workflowId: 'wf-2' });
|
||||||
|
|
||||||
|
expect(registry.get('stale')).toBeUndefined();
|
||||||
|
expect(registry.get('fresh')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a session that is still inside the retention window', () => {
|
||||||
|
vi.setSystemTime(new Date('2026-08-25T10:00:00.000Z'));
|
||||||
|
registry.register('exec-1', { pushRef: 'push-1', workflowId: 'wf-1' });
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(TTL_MS - 1000);
|
||||||
|
registry.register('exec-2', { pushRef: 'push-2', workflowId: 'wf-2' });
|
||||||
|
|
||||||
|
expect(registry.get('exec-1')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('measures the window from the last event, not from registration', () => {
|
||||||
|
vi.setSystemTime(new Date('2026-08-25T10:00:00.000Z'));
|
||||||
|
registry.register('long-run', { pushRef: 'push-1', workflowId: 'wf-1' });
|
||||||
|
|
||||||
|
// A run that keeps reporting stays alive past the raw TTL.
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
vi.advanceTimersByTime(TTL_MS - 1000);
|
||||||
|
expect(registry.get('long-run')).toBeDefined();
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(TTL_MS + 1000);
|
||||||
|
registry.register('other', { pushRef: 'push-2', workflowId: 'wf-2' });
|
||||||
|
|
||||||
|
expect(registry.get('long-run')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps the number of sessions, dropping the least recently seen', () => {
|
||||||
|
vi.setSystemTime(new Date('2026-08-25T10:00:00.000Z'));
|
||||||
|
for (let i = 0; i < 1000; i++) {
|
||||||
|
registry.register(`exec-${i}`, { pushRef: `push-${i}`, workflowId: 'wf-1' });
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
// Touching the oldest session makes the next one the eviction target.
|
||||||
|
expect(registry.get('exec-0')).toBeDefined();
|
||||||
|
|
||||||
|
registry.register('exec-1000', { pushRef: 'push-1000', workflowId: 'wf-1' });
|
||||||
|
|
||||||
|
expect(registry.get('exec-1')).toBeUndefined();
|
||||||
|
expect(registry.get('exec-0')).toBeDefined();
|
||||||
|
expect(registry.get('exec-1000')).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,6 +10,7 @@ import { getChildNodes, NodeConnectionTypes, UserError } from 'n8n-workflow';
|
|||||||
import { CredentialsPermissionChecker } from '@/executions/pre-execution-checks';
|
import { CredentialsPermissionChecker } from '@/executions/pre-execution-checks';
|
||||||
import type { ResumableExecution } from '@/interfaces';
|
import type { ResumableExecution } from '@/interfaces';
|
||||||
import { EngineDataPlaneProxyService } from '@/services/engine-data-plane-proxy.service';
|
import { EngineDataPlaneProxyService } from '@/services/engine-data-plane-proxy.service';
|
||||||
|
import { EngineV2PushRegistry } from '@/services/engine-v2-push-registry.service';
|
||||||
|
|
||||||
type ToStepOutputs = (outputs: INodeExecutionData[][]) => StepSlots;
|
type ToStepOutputs = (outputs: INodeExecutionData[][]) => StepSlots;
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ export class EngineV2Dispatcher {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly proxy: EngineDataPlaneProxyService,
|
private readonly proxy: EngineDataPlaneProxyService,
|
||||||
private readonly credentialsPermissionChecker: CredentialsPermissionChecker,
|
private readonly credentialsPermissionChecker: CredentialsPermissionChecker,
|
||||||
|
private readonly pushRegistry: EngineV2PushRegistry,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -64,17 +66,47 @@ export class EngineV2Dispatcher {
|
|||||||
const { V1WorkflowConverter, toStepOutputs } = await import('@n8n/node-engine-compatibility');
|
const { V1WorkflowConverter, toStepOutputs } = await import('@n8n/node-engine-compatibility');
|
||||||
|
|
||||||
const graph = new V1WorkflowConverter().convert(this.selectTriggerSubgraph(data));
|
const graph = new V1WorkflowConverter().convert(this.selectTriggerSubgraph(data));
|
||||||
|
const triggerMain = this.triggerMainOutputs(data);
|
||||||
|
|
||||||
const { executionId } = await this.proxy.startExecution({
|
const { executionId } = await this.proxy.startExecution({
|
||||||
workflowId: workflowData.id,
|
workflowId: workflowData.id,
|
||||||
graph,
|
graph,
|
||||||
triggerOutputs: this.toTriggerOutputs(data, toStepOutputs),
|
triggerOutputs: this.toTriggerOutputs(triggerMain, toStepOutputs),
|
||||||
mode: 'manual',
|
mode: 'manual',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// TODO(CAT-4255): the engine can publish lifecycle events before this line
|
||||||
|
// runs, and the relay drops them because no session exists yet. Let the
|
||||||
|
// control plane mint the execution id so this can register before dispatch.
|
||||||
|
this.registerPushSession(executionId, data, triggerMain);
|
||||||
|
|
||||||
return executionId;
|
return executionId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lifecycle events carry no session id, so the push ref is recorded here,
|
||||||
|
* keyed by execution id, before any events can arrive.
|
||||||
|
*/
|
||||||
|
private registerPushSession(
|
||||||
|
executionId: string,
|
||||||
|
data: IWorkflowExecutionDataProcess,
|
||||||
|
triggerMain: INodeExecutionData[][],
|
||||||
|
): void {
|
||||||
|
const { pushRef, workflowData, triggerToStartFrom } = data;
|
||||||
|
// No push ref means nothing is watching this run.
|
||||||
|
if (!pushRef) return;
|
||||||
|
|
||||||
|
this.pushRegistry.register(executionId, {
|
||||||
|
pushRef,
|
||||||
|
workflowId: workflowData.id,
|
||||||
|
// The engine never announces the trigger, so save its outputs for the relay.
|
||||||
|
trigger: triggerToStartFrom && {
|
||||||
|
nodeName: triggerToStartFrom.name,
|
||||||
|
outputs: triggerMain,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Keep only the branch that starts at the trigger selected for this manual run. */
|
/** Keep only the branch that starts at the trigger selected for this manual run. */
|
||||||
private selectTriggerSubgraph(data: IWorkflowExecutionDataProcess): IWorkflowBase {
|
private selectTriggerSubgraph(data: IWorkflowExecutionDataProcess): IWorkflowBase {
|
||||||
const { triggerToStartFrom, workflowData } = data;
|
const { triggerToStartFrom, workflowData } = data;
|
||||||
@@ -152,10 +184,7 @@ export class EngineV2Dispatcher {
|
|||||||
* record the trigger with no slots, so every successor edge reads as dead and
|
* record the trigger with no slots, so every successor edge reads as dead and
|
||||||
* the execution completes having run nothing.
|
* the execution completes having run nothing.
|
||||||
*/
|
*/
|
||||||
private toTriggerOutputs(
|
private triggerMainOutputs(data: IWorkflowExecutionDataProcess): INodeExecutionData[][] {
|
||||||
data: IWorkflowExecutionDataProcess,
|
|
||||||
toStepOutputs: ToStepOutputs,
|
|
||||||
): TriggerOutputs | null {
|
|
||||||
const triggerName = data.triggerToStartFrom?.name;
|
const triggerName = data.triggerToStartFrom?.name;
|
||||||
// `IPinData` values are a flat item array; the Manual Trigger has one output.
|
// `IPinData` values are a flat item array; the Manual Trigger has one output.
|
||||||
const pinned = triggerName ? data.pinData?.[triggerName] : undefined;
|
const pinned = triggerName ? data.pinData?.[triggerName] : undefined;
|
||||||
@@ -166,7 +195,14 @@ export class EngineV2Dispatcher {
|
|||||||
|
|
||||||
// v1 uses `null` for a slot it has no data for; an empty slot says the same
|
// v1 uses `null` for a slot it has no data for; an empty slot says the same
|
||||||
// thing to the engine, which `toStepOutputs` collapses back to a dead edge.
|
// thing to the engine, which `toStepOutputs` collapses back to a dead edge.
|
||||||
const slots = toStepOutputs(main.map((slot) => slot ?? []));
|
return main.map((slot) => slot ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
private toTriggerOutputs(
|
||||||
|
main: INodeExecutionData[][],
|
||||||
|
toStepOutputs: ToStepOutputs,
|
||||||
|
): TriggerOutputs | null {
|
||||||
|
const slots = toStepOutputs(main);
|
||||||
|
|
||||||
// The wire schema rejects an empty array; `null` is how "no slots" is sent.
|
// The wire schema rejects an empty array; `null` is how "no slots" is sent.
|
||||||
return slots.length === 0 ? null : slots;
|
return slots.length === 0 ? null : slots;
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { Time } from '@n8n/constants';
|
||||||
|
import { Service } from '@n8n/di';
|
||||||
|
|
||||||
|
import { EngineV2PushSession } from '@/services/engine-v2-push-session';
|
||||||
|
|
||||||
|
/** Long enough to outlive a run that idles between steps, e.g. on a wait. */
|
||||||
|
const SESSION_TTL_MS = 12 * Time.hours.toMilliseconds;
|
||||||
|
|
||||||
|
/** Hard ceiling on the map, in case many runs stall inside the TTL. */
|
||||||
|
const MAX_SESSIONS = 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Correlates a data-plane execution id with the editor session that started it.
|
||||||
|
*
|
||||||
|
* Lifecycle events carry no session id, so the push ref is recorded here at
|
||||||
|
* dispatch and read back as events arrive.
|
||||||
|
*/
|
||||||
|
@Service()
|
||||||
|
export class EngineV2PushRegistry {
|
||||||
|
private readonly sessions = new Map<string, EngineV2PushSession>();
|
||||||
|
|
||||||
|
register(
|
||||||
|
executionId: string,
|
||||||
|
init: Pick<EngineV2PushSession, 'pushRef' | 'workflowId' | 'trigger'>,
|
||||||
|
): void {
|
||||||
|
this.evict();
|
||||||
|
this.sessions.set(
|
||||||
|
executionId,
|
||||||
|
new EngineV2PushSession(init.pushRef, init.workflowId, init.trigger),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get(executionId: string): EngineV2PushSession | undefined {
|
||||||
|
const session = this.sessions.get(executionId);
|
||||||
|
if (session) session.lastSeenAt = Date.now();
|
||||||
|
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
release(executionId: string): void {
|
||||||
|
this.sessions.delete(executionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No `cancelled` event exists, so a session whose terminal event never
|
||||||
|
* arrives would live forever. Swept on write instead of on a timer, so
|
||||||
|
* there's no interval to manage.
|
||||||
|
*/
|
||||||
|
private evict(): void {
|
||||||
|
const cutoff = Date.now() - SESSION_TTL_MS;
|
||||||
|
for (const [executionId, session] of this.sessions) {
|
||||||
|
if (session.lastSeenAt < cutoff) this.sessions.delete(executionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leave room for the caller's session, so the cap holds after the insert.
|
||||||
|
const excess = this.sessions.size - MAX_SESSIONS + 1;
|
||||||
|
if (excess <= 0) return;
|
||||||
|
|
||||||
|
const oldestFirst = [...this.sessions].sort((a, b) => a[1].lastSeenAt - b[1].lastSeenAt);
|
||||||
|
for (const [executionId] of oldestFirst.slice(0, excess)) {
|
||||||
|
this.sessions.delete(executionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { INodeExecutionData } from 'n8n-workflow';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A step run reported to the editor. Kept after it settles so a redelivered
|
||||||
|
* event is ignored instead of appending a duplicate.
|
||||||
|
*/
|
||||||
|
export class EngineV2StepRun {
|
||||||
|
/** Whether the step's outcome has been reported. */
|
||||||
|
settled = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
/** Pairs this run's `nodeExecuteAfter` with its `nodeExecuteAfterData`. */
|
||||||
|
readonly executionIndex: number,
|
||||||
|
readonly startTime: number,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** State needed to relay one execution's lifecycle events to the editor. */
|
||||||
|
export class EngineV2PushSession {
|
||||||
|
/** Ordering counter for `nodeExecuteBefore`/`nodeExecuteAfter`; starts at 0. */
|
||||||
|
sequenceNumber = 0;
|
||||||
|
/** Next `ITaskData.executionIndex` to hand out. */
|
||||||
|
nextExecutionIndex = 0;
|
||||||
|
/** Step runs keyed by the engine's step id. */
|
||||||
|
readonly steps = new Map<string, EngineV2StepRun>();
|
||||||
|
/** When the last lifecycle event for this execution arrived. */
|
||||||
|
lastSeenAt = Date.now();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
/** The only routing key {@link Push.send} accepts. */
|
||||||
|
readonly pushRef: string,
|
||||||
|
readonly workflowId: string,
|
||||||
|
/**
|
||||||
|
* The trigger's outputs, since the engine never announces it as a step.
|
||||||
|
* Cleared once emitted — pinned data can be large.
|
||||||
|
*/
|
||||||
|
public trigger?: { nodeName: string; outputs: INodeExecutionData[][] },
|
||||||
|
) {}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user