feat(core): Declare what on main awaits a queued run's outcome (no-changelog) (#40567)

This commit is contained in:
Mike Repeć
2026-10-09 09:05:07 +00:00
committed by GitHub
parent 503092ccca
commit 24b9db41b3
17 changed files with 284 additions and 3 deletions
@@ -955,6 +955,7 @@ describe('ActiveWorkflowManager', () => {
mode,
undefined,
undefined,
'none',
);
expect(eventService.emit).toHaveBeenCalledWith('workflow-executed', {
@@ -998,6 +999,7 @@ describe('ActiveWorkflowManager', () => {
mode,
undefined,
'wf-1:node-1:1700000000000',
'none',
);
});
@@ -215,6 +215,7 @@ describe('WaitTracker', () => {
projectId: project.id,
pushRef: execution.data.pushRef,
userId: undefined,
callerAwaitsOutcome: 'none',
},
false,
false,
@@ -375,6 +376,7 @@ describe('WaitTracker', () => {
workflowData: execution.workflowData,
projectId: project.id,
pushRef: execution.data.pushRef,
callerAwaitsOutcome: 'none',
},
false,
false,
@@ -395,6 +397,7 @@ describe('WaitTracker', () => {
workflowData: parentExecution.workflowData,
projectId: project.id,
pushRef: parentExecution.data.pushRef,
callerAwaitsOutcome: 'none',
startedAt: parentExecution.startedAt,
},
false,
@@ -1258,6 +1258,7 @@ describe('enqueueExecution', () => {
executionData: undefined,
pushRef: 'push-ref',
streamingEnabled: true,
callerAwaitsOutcome: 'response',
});
const error = new Error('stop for test purposes');
@@ -1277,6 +1278,7 @@ describe('enqueueExecution', () => {
workflowId: 'workflow-xyz',
executionId: '1',
restartExecutionId,
callerAwaitsOutcome: 'response',
}),
expect.any(Object),
);
@@ -396,6 +396,19 @@ describe('ExecutionService', () => {
const erroredRun = (error: boolean) =>
[{ error: error ? new Error('boom') : undefined }] as unknown as ITaskData[];
it('should mark the retry as awaited to completion', async () => {
const { service, workflowRunner } = buildRetryService();
executionPersistence.findWithUnflattenedData.mockResolvedValue(
buildCrashedExecution({ lastNodeExecuted: 'Some Node', runData: {} }),
);
await service.retry(retryArgs());
expect(workflowRunner.run).toHaveBeenCalledWith(
expect.objectContaining({ callerAwaitsOutcome: 'completion' }),
);
});
it('should not throw when retrying a crashed execution whose runData is undefined', async () => {
const { service, workflowRunner } = buildRetryService();
executionPersistence.findWithUnflattenedData.mockResolvedValue(
@@ -280,6 +280,8 @@ export class ExecutionService {
retryOf: executionId,
workflowData: execution.workflowData,
userId: user.id,
// The retry caller awaits the result below, so a worker must not park it.
callerAwaitsOutcome: 'completion',
};
const { lastNodeExecuted } = data.executionData!.resultData;
@@ -5289,6 +5289,28 @@ describe('createExecutionAdapter run()', () => {
});
});
it('marks a trigger-mode run as awaited so a worker never parks it at shutdown', async () => {
const { adapter, mockWorkflowRunner } = createRunAdapterForTests({
id: 'wf-1',
nodes: [
{
id: 'n1',
name: 'Schedule',
type: 'n8n-nodes-base.scheduleTrigger',
typeVersion: 1,
position: [0, 0],
},
],
connections: {},
});
await adapter.run('wf-1');
const runData = mockWorkflowRunner.run.mock.calls[0][0];
expect(runData.executionMode).toBe('trigger');
expect(runData.callerAwaitsOutcome).toBe('completion');
});
it('still applies overrides when the workflow has no settings', async () => {
const { adapter, mockWorkflowRunner } = createRunAdapterForTests({
id: 'wf-1',
@@ -2153,6 +2153,9 @@ export class InstanceAiAdapterService {
},
userId: user.id,
pushRef,
// The agent awaits this run's result and prunes its pins afterwards, so
// a worker must not park a trigger-mode run at shutdown.
callerAwaitsOutcome: 'completion',
// A verification run picks a production execution mode above so the
// trigger behaves realistically. Without this opt-out, a failed build
// attempt would dispatch the user's error workflow as if production
@@ -22,6 +22,8 @@ export type JobData = {
loadStaticData: boolean;
pushRef?: string;
streamingEnabled?: boolean;
/** What on main awaits this run's outcome, see IWorkflowExecutionDataProcess. */
callerAwaitsOutcome?: 'none' | 'response' | 'completion';
restartExecutionId?: string;
projectId?: string;
projectName?: string;
+3
View File
@@ -174,6 +174,9 @@ export class WaitTracker {
projectId: project.id,
pushRef: fullExecutionData.data.pushRef,
startedAt: fullExecutionData.startedAt,
// Whatever awaited this run on main was answered when the run paused, so
// a resumed segment owes nothing and may be suspended again.
callerAwaitsOutcome: 'none',
// Not a stored field, so a resume has to derive it again — otherwise the
// run comes back without an identity and a credential only its publisher
// may use is refused halfway through.
@@ -1,7 +1,7 @@
import { Logger } from '@n8n/backend-common';
import { EventService } from '@n8n/backend-services';
import { mockInstance } from '@n8n/backend-test-utils';
import { EngineConfig } from '@n8n/config';
import { EngineConfig, ExecutionsConfig } from '@n8n/config';
import type { Project, User } from '@n8n/db';
import { UserRepository } from '@n8n/db';
import { Container } from '@n8n/di';
@@ -29,6 +29,7 @@ import type {
INodeType,
IDataObject,
IWebhookResponseData,
WebhookResponseMode,
IN8nHttpFullResponse,
IWorkflowBase,
IRunExecutionData,
@@ -1632,6 +1633,8 @@ describe('executeWebhook credential-status gate', () => {
authentication: string;
gateResult?: CredentialCheckResult;
webhookResult?: IWebhookResponseData;
responseMode?: WebhookResponseMode;
startNodeType?: string;
}) => {
const checkCredentialStatus = vi.fn().mockResolvedValue(options.gateResult);
@@ -1649,7 +1652,7 @@ describe('executeWebhook credential-status gate', () => {
const workflowStartNode = mock<INode>({
name: 'Webhook',
type: WEBHOOK_NODE_TYPE,
type: options.startNodeType ?? WEBHOOK_NODE_TYPE,
typeVersion: 2,
parameters: { authentication: options.authentication },
});
@@ -1669,7 +1672,7 @@ describe('executeWebhook credential-status gate', () => {
.mockReturnValue(mock<INodeType>({ description: { name: 'webhook' } })),
},
expression: {
getSimpleParameterValue: vi.fn().mockReturnValue('onReceived'),
getSimpleParameterValue: vi.fn().mockReturnValue(options.responseMode ?? 'onReceived'),
getComplexParameterValue: vi.fn().mockReturnValue('firstEntryJson'),
},
});
@@ -1747,6 +1750,54 @@ describe('executeWebhook credential-status gate', () => {
expect(workflowRunner.run).toHaveBeenCalled();
});
it.each(['onReceived', 'hostedChat'] as const)(
'marks a %s run as owing no webhook response, since main answers at enqueue',
async (responseMode) => {
// Not awaited: non-onReceived modes then wait on a post-execute promise that never settles here.
void runGate({ authentication: 'none', gateResult: missingGateResult, responseMode });
await vi.waitFor(() => expect(workflowRunner.run).toHaveBeenCalled());
const [runData] = vi.mocked(workflowRunner.run).mock.calls[0];
expect(runData.callerAwaitsOutcome).toBe('none');
},
);
describe('in queue mode', () => {
const executionsConfig = Container.get(ExecutionsConfig);
beforeEach(() => {
executionsConfig.mode = 'queue';
});
afterEach(() => {
executionsConfig.mode = 'regular';
});
it('marks an MCP Trigger run as awaited to completion, since the worker relays the MCP response', async () => {
void runGate({
authentication: 'none',
gateResult: missingGateResult,
startNodeType: MCP_TRIGGER_NODE_TYPE,
});
await vi.waitFor(() => expect(workflowRunner.run).toHaveBeenCalled());
const [runData] = vi.mocked(workflowRunner.run).mock.calls[0];
expect(runData.callerAwaitsOutcome).toBe('completion');
});
});
it('marks a lastNode run as owing a webhook response', async () => {
void runGate({
authentication: 'none',
gateResult: missingGateResult,
responseMode: 'lastNode',
});
await vi.waitFor(() => expect(workflowRunner.run).toHaveBeenCalled());
const [runData] = vi.mocked(workflowRunner.run).mock.calls[0];
expect(runData.callerAwaitsOutcome).toBe('response');
});
it('does not gate webhooks that do not establish a triggering identity', async () => {
const { checkCredentialStatus, responseCallback } = await runGate({
authentication: 'none',
@@ -121,6 +121,13 @@ const SUPPORTED_RESPONSE_MODES = new Set<WebhookResponseMode>([
'hostedChat',
]);
/** Modes where the caller is answered as soon as the run is enqueued, so the run itself owes no response. */
const IMMEDIATE_RESPONSE_MODES = new Set<WebhookResponseMode>([
'onReceived',
'formPage',
'hostedChat',
]);
interface WebhookInvocationResult {
webhookResultData: IWebhookResponseData;
runExecutionDataChanges: WebhookExecutionDataChanges;
@@ -222,6 +229,8 @@ async function prepareMcpQueueExecution(
runData.isMcpExecution = true;
runData.mcpType = 'trigger';
// The worker relays the MCP response when the run ends, so a paused segment would be taken for the result.
runData.callerAwaitsOutcome = 'completion';
runData.mcpSessionId = mcpSessionId;
runData.mcpMessageId = mcpMessageId;
@@ -1187,6 +1196,7 @@ export async function executeWebhook(
projectName: project?.name,
userId: webhookData.userId,
encryptedRunnerIdentity: additionalData.encryptedRunnerIdentity,
callerAwaitsOutcome: IMMEDIATE_RESPONSE_MODES.has(responseMode) ? 'none' : 'response',
// v1 reads this from `executionData.startData`, which `prepareExecutionData`
// sets, so carrying it here changes nothing for v1. Engine v2 has no way to
// stop at a node, and its dispatcher refuses the run on this field.
+1
View File
@@ -764,6 +764,7 @@ export class WorkflowRunner {
loadStaticData: !!loadStaticData,
pushRef: data.pushRef,
streamingEnabled: data.streamingEnabled,
callerAwaitsOutcome: data.callerAwaitsOutcome,
restartExecutionId,
projectId: data.projectId,
projectName: data.projectName,
@@ -320,6 +320,50 @@ describe('WorkflowExecutionService', () => {
);
});
test('marks a fire-and-forget polled run as suspendable', async () => {
await workflowExecutionService.runPolledWorkflow(
workflow,
node,
pollItems,
additionalData,
'trigger',
cursor,
);
expect(workflowRunner.run).toHaveBeenCalledWith(
expect.objectContaining({ callerAwaitsOutcome: 'none' }),
false,
undefined,
{ executionId: 'exec-9', expectedStatus: 'new' },
undefined,
);
});
test.each(['response', 'completion'] as const)(
'forwards the %s caller state for a polled run',
async (callerAwaitsOutcome) => {
await workflowExecutionService.runPolledWorkflow(
workflow,
node,
pollItems,
additionalData,
'trigger',
cursor,
responsePromise,
undefined,
callerAwaitsOutcome,
);
expect(workflowRunner.run).toHaveBeenCalledWith(
expect.objectContaining({ callerAwaitsOutcome }),
false,
undefined,
{ executionId: 'exec-9', expectedStatus: 'new' },
responsePromise,
);
},
);
test('prepares the new execution before commit, then starts the run without reloading static data', async () => {
const callOrder: string[] = [];
workflowRunner.prepareNewExecution.mockImplementation(async () => {
@@ -159,6 +159,7 @@ describe('TriggerExecutionContextFactory', () => {
mode,
undefined,
undefined,
'none',
);
expect(eventService.emit).toHaveBeenCalledWith('workflow-executed', {
workflowId: workflowData.id,
@@ -222,6 +223,7 @@ describe('TriggerExecutionContextFactory', () => {
'trigger',
undefined,
undefined,
'none',
);
});
@@ -264,6 +266,7 @@ describe('TriggerExecutionContextFactory', () => {
'trigger',
undefined,
undefined,
'none',
);
});
@@ -297,6 +300,7 @@ describe('TriggerExecutionContextFactory', () => {
mode,
undefined,
'wf-1:node-1:1700000000000',
'none',
);
});
@@ -327,6 +331,52 @@ describe('TriggerExecutionContextFactory', () => {
expect(activeExecutions.getPostExecutePromise).toHaveBeenCalledWith('exec-123');
await expect(donePromise.promise).resolves.toBe(runResult);
// The node waits for the run's end, so the run must not be parked at worker shutdown.
expect(workflowExecutionService.runWorkflow).toHaveBeenCalledWith(
workflowData,
node,
[[]],
expect.anything(),
mode,
undefined,
undefined,
'completion',
);
});
test('marks a run as awaited for its response when the trigger passes only a response promise', async () => {
const workflowData = mock<WorkflowEntity>({ id: 'wf-1', name: 'Test Workflow' });
const additionalData = mock<IWorkflowExecuteAdditionalData>();
const mode: WorkflowExecuteMode = 'trigger';
const activation: WorkflowActivateMode = 'activate';
const workflow = mock<Workflow>({ name: 'Test Workflow' });
const node = mock<INode>({ name: 'Trigger Node' });
const getTriggerFunctions = factory.getExecuteTriggerFunctions(
workflowData,
additionalData,
mode,
activation,
async () => workflowData,
vi.fn(),
scheduleCollectionSession,
);
const context = getTriggerFunctions(workflow, node, additionalData, mode, activation);
const responsePromise = createDeferredPromise<IExecuteResponsePromiseData>();
context.emit([[]], responsePromise);
await sleep(0);
expect(workflowExecutionService.runWorkflow).toHaveBeenCalledWith(
workflowData,
node,
[[]],
expect.anything(),
mode,
responsePromise,
undefined,
'response',
);
});
test('does not emit workflow-executed on DuplicateExecutionError', async () => {
@@ -720,6 +770,8 @@ describe('TriggerExecutionContextFactory', () => {
expect.objectContaining({ userId: undefined }),
mode,
undefined,
undefined,
'none',
);
});
@@ -769,6 +821,8 @@ describe('TriggerExecutionContextFactory', () => {
expect.objectContaining({ userId: 'publisher-of-live-version' }),
'trigger',
undefined,
undefined,
'none',
);
});
@@ -808,6 +862,8 @@ describe('TriggerExecutionContextFactory', () => {
expect.objectContaining({ userId: undefined }),
'trigger',
undefined,
undefined,
'none',
);
});
@@ -1132,10 +1188,43 @@ describe('TriggerExecutionContextFactory', () => {
{ lastItemId: 'a' },
responsePromise,
undefined,
'response',
);
expect(workflowExecutionService.runWorkflow).not.toHaveBeenCalled();
});
test.each([
{ hasResponse: false, hasDone: false, callerAwaitsOutcome: 'none' },
{ hasResponse: false, hasDone: true, callerAwaitsOutcome: 'completion' },
{ hasResponse: true, hasDone: true, callerAwaitsOutcome: 'completion' },
])(
'passes $callerAwaitsOutcome for a cursor-backed poll with response=$hasResponse and done=$hasDone',
async ({ hasResponse, hasDone, callerAwaitsOutcome }) => {
const responsePromise = hasResponse
? createDeferredPromise<IExecuteResponsePromiseData>()
: undefined;
const donePromise = hasDone ? createDeferredPromise<IRun>() : undefined;
await context.__runPoll(async () => {
context.getWorkflowStaticData('node').lastItemId = 'a';
context.__emit(pollData, responsePromise, donePromise);
});
await sleep(0);
expect(workflowExecutionService.runPolledWorkflow).toHaveBeenCalledWith(
expect.anything(),
node,
pollData,
expect.anything(),
mode,
{ lastItemId: 'a' },
responsePromise,
undefined,
callerAwaitsOutcome,
);
},
);
test('routes to runPolledWorkflowV2, not the transactional runPolledWorkflow, on engine v2', async () => {
engineV2Dispatcher.handlesWorkflow.mockReturnValue(true);
workflowExecutionService.runPolledWorkflowV2.mockResolvedValue('exec-v2');
@@ -1371,6 +1460,7 @@ describe('TriggerExecutionContextFactory', () => {
{ lastItemId: 'first-only' },
undefined,
undefined,
'none',
);
});
@@ -1602,6 +1692,7 @@ describe('TriggerExecutionContextFactory', () => {
{ lastItemId: 'a' },
undefined,
fence,
'none',
);
await fencedContext.__runPoll(async () => {
@@ -25,6 +25,7 @@ import type {
IRun,
IWorkflowBase,
IWorkflowExecuteAdditionalData,
IWorkflowExecutionDataProcess,
PollCursor,
WorkflowActivateMode,
WorkflowExecuteMode,
@@ -50,6 +51,16 @@ import { WorkflowPublishedDataService } from '@/workflows/workflow-published-dat
import { WorkflowPublisherService } from '@/workflows/workflow-publisher.service';
import { WorkflowStaticDataService } from '@/workflows/workflow-static-data.service';
/** What the trigger node waits on from the run it starts. A done promise outranks a response. */
function awaitedByTrigger(
responsePromise: IDeferredPromise<IExecuteResponsePromiseData> | undefined,
donePromise: IDeferredPromise<IRun | undefined> | undefined,
): NonNullable<IWorkflowExecutionDataProcess['callerAwaitsOutcome']> {
if (donePromise) return 'completion';
if (responsePromise) return 'response';
return 'none';
}
export type TriggerFailureHandler = (opts: {
error: Error;
node: INode;
@@ -218,6 +229,8 @@ export class TriggerExecutionContextFactory {
mode,
responsePromise,
deduplicationKey,
// A node awaiting the run's end, or a response from it, must not get a paused segment instead.
awaitedByTrigger(responsePromise, donePromise),
);
})
.catch((error: unknown) => {
@@ -390,6 +403,8 @@ export class TriggerExecutionContextFactory {
runAdditionalData,
mode,
responsePromise,
undefined,
awaitedByTrigger(responsePromise, donePromise),
);
}
@@ -413,6 +428,7 @@ export class TriggerExecutionContextFactory {
cursor,
responsePromise,
fence,
awaitedByTrigger(responsePromise, donePromise),
);
});
@@ -89,6 +89,7 @@ export class WorkflowExecutionService {
mode: WorkflowExecuteMode,
responsePromise?: IDeferredPromise<IExecuteResponsePromiseData>,
deduplicationKey?: string,
callerAwaitsOutcome: IWorkflowExecutionDataProcess['callerAwaitsOutcome'] = 'none',
) {
const nodeExecutionStack: IExecuteData[] = [
{
@@ -120,6 +121,7 @@ export class WorkflowExecutionService {
deduplicationKey,
projectId,
projectName,
callerAwaitsOutcome,
};
return await this.workflowRunner.run(runData, true, undefined, undefined, responsePromise);
@@ -138,6 +140,7 @@ export class WorkflowExecutionService {
cursor: PollCursor,
responsePromise?: IDeferredPromise<IExecuteResponsePromiseData>,
fence?: PollLeaseFence,
callerAwaitsOutcome: IWorkflowExecutionDataProcess['callerAwaitsOutcome'] = 'none',
): Promise<string | undefined> {
const nodeExecutionStack: IExecuteData[] = [
{
@@ -167,6 +170,7 @@ export class WorkflowExecutionService {
workflowData,
projectId,
projectName,
callerAwaitsOutcome,
};
// Mask the trigger items before the payload is committed, so the persisted row
+12
View File
@@ -3801,6 +3801,18 @@ export interface IWorkflowExecutionDataProcess {
agentRequest?: AiAgentRequest;
httpResponse?: express.Response; // Used for streaming responses
streamingEnabled?: boolean;
/**
* What, if anything, on the main that started this run awaits its outcome
* and would act on a paused segment as if it were the result. A worker must
* not park such a run at shutdown.
* - `'response'`: a webhook caller is owed a response; satisfied once the run
* relays one.
* - `'completion'`: a trigger node's done promise (Kafka, RabbitMQ, MQTT), an
* Instance AI run or a retry awaits the end of the run; never satisfied early.
* - `'none'`: nothing waits, the run may be parked.
* Absent (older main): treated as `'completion'`, the strictest reading.
*/
callerAwaitsOutcome?: 'none' | 'response' | 'completion';
/**
* Only engine v2 reads this. The data-plane execution id, set by a caller that
* minted it before the run starts: to subscribe to the run's answer, or because