fix(core): Handle cancellation of waiting executions correctly (#13051)

This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™ 2025-02-07 12:34:03 +01:00 committed by GitHub
parent be39d0a0f1
commit fc440eb68b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 300 additions and 206 deletions

View file

@ -1,28 +1,29 @@
import { mock } from 'jest-mock-extended';
import { captor, mock } from 'jest-mock-extended';
import type {
IDeferredPromise,
IExecuteResponsePromiseData,
IRun,
IWorkflowExecutionDataProcess,
} from 'n8n-workflow';
import { createDeferredPromise } from 'n8n-workflow';
import { ExecutionCancelledError, randomInt, sleep } from 'n8n-workflow';
import PCancelable from 'p-cancelable';
import { v4 as uuid } from 'uuid';
import { ActiveExecutions } from '@/active-executions';
import { ConcurrencyControlService } from '@/concurrency/concurrency-control.service';
import config from '@/config';
import type { ExecutionRepository } from '@/databases/repositories/execution.repository';
import { mockInstance } from '@test/mocking';
jest.mock('n8n-workflow', () => ({
...jest.requireActual('n8n-workflow'),
sleep: jest.fn(),
}));
const FAKE_EXECUTION_ID = '15';
const FAKE_SECOND_EXECUTION_ID = '20';
const updateExistingExecution = jest.fn();
const createNewExecution = jest.fn(async () => FAKE_EXECUTION_ID);
const executionRepository = mock<ExecutionRepository>({
updateExistingExecution,
createNewExecution,
});
const executionRepository = mock<ExecutionRepository>();
const concurrencyControl = mockInstance(ConcurrencyControlService, {
// @ts-expect-error Private property
@ -31,155 +32,22 @@ const concurrencyControl = mockInstance(ConcurrencyControlService, {
describe('ActiveExecutions', () => {
let activeExecutions: ActiveExecutions;
let responsePromise: IDeferredPromise<IExecuteResponsePromiseData>;
let workflowExecution: PCancelable<IRun>;
let postExecutePromise: Promise<IRun | undefined>;
beforeEach(() => {
activeExecutions = new ActiveExecutions(mock(), executionRepository, concurrencyControl);
});
const fullRunData: IRun = {
data: {
resultData: {
runData: {},
},
},
mode: 'manual',
startedAt: new Date(),
status: 'new',
};
afterEach(() => {
jest.clearAllMocks();
});
test('Should initialize activeExecutions with empty list', () => {
expect(activeExecutions.getActiveExecutions()).toHaveLength(0);
});
test('Should add execution to active execution list', async () => {
const newExecution = mockExecutionData();
const executionId = await activeExecutions.add(newExecution);
expect(executionId).toBe(FAKE_EXECUTION_ID);
expect(activeExecutions.getActiveExecutions()).toHaveLength(1);
expect(createNewExecution).toHaveBeenCalledTimes(1);
expect(updateExistingExecution).toHaveBeenCalledTimes(0);
});
test('Should update execution if add is called with execution ID', async () => {
const newExecution = mockExecutionData();
const executionId = await activeExecutions.add(newExecution, FAKE_SECOND_EXECUTION_ID);
expect(executionId).toBe(FAKE_SECOND_EXECUTION_ID);
expect(activeExecutions.getActiveExecutions()).toHaveLength(1);
expect(createNewExecution).toHaveBeenCalledTimes(0);
expect(updateExistingExecution).toHaveBeenCalledTimes(1);
});
test('Should fail attaching execution to invalid executionId', async () => {
const deferredPromise = mockCancelablePromise();
expect(() => {
activeExecutions.attachWorkflowExecution(FAKE_EXECUTION_ID, deferredPromise);
}).toThrow();
});
test('Should successfully attach execution to valid executionId', async () => {
const newExecution = mockExecutionData();
await activeExecutions.add(newExecution, FAKE_EXECUTION_ID);
const deferredPromise = mockCancelablePromise();
expect(() =>
activeExecutions.attachWorkflowExecution(FAKE_EXECUTION_ID, deferredPromise),
).not.toThrow();
});
test('Should attach and resolve response promise to existing execution', async () => {
const newExecution = mockExecutionData();
await activeExecutions.add(newExecution, FAKE_EXECUTION_ID);
const deferredPromise = mockDeferredPromise();
activeExecutions.attachResponsePromise(FAKE_EXECUTION_ID, deferredPromise);
const fakeResponse = { data: { resultData: { runData: {} } } };
activeExecutions.resolveResponsePromise(FAKE_EXECUTION_ID, fakeResponse);
await expect(deferredPromise.promise).resolves.toEqual(fakeResponse);
});
test('Should copy over startedAt and responsePromise when resuming a waiting execution', async () => {
const newExecution = mockExecutionData();
const executionId = await activeExecutions.add(newExecution);
activeExecutions.setStatus(executionId, 'waiting');
activeExecutions.attachResponsePromise(executionId, mockDeferredPromise());
const waitingExecution = activeExecutions.getExecution(executionId);
expect(waitingExecution.responsePromise).toBeDefined();
// Resume the execution
await activeExecutions.add(newExecution, executionId);
const resumedExecution = activeExecutions.getExecution(executionId);
expect(resumedExecution.startedAt).toBe(waitingExecution.startedAt);
expect(resumedExecution.responsePromise).toBe(waitingExecution.responsePromise);
});
test('Should not remove a waiting execution', async () => {
const newExecution = mockExecutionData();
const executionId = await activeExecutions.add(newExecution);
activeExecutions.setStatus(executionId, 'waiting');
activeExecutions.finalizeExecution(executionId);
// Wait until the next tick to ensure that the post-execution promise has settled
await new Promise(setImmediate);
// Execution should still be in activeExecutions
expect(activeExecutions.getActiveExecutions()).toHaveLength(1);
expect(activeExecutions.getStatus(executionId)).toBe('waiting');
});
test('Should remove an existing execution', async () => {
// ARRANGE
const newExecution = mockExecutionData();
const executionId = await activeExecutions.add(newExecution);
// ACT
activeExecutions.finalizeExecution(executionId);
// Wait until the next tick to ensure that the post-execution promise has settled
await new Promise(setImmediate);
// ASSERT
expect(activeExecutions.getActiveExecutions()).toHaveLength(0);
});
test('Should not try to resolve a post-execute promise for an inactive execution', async () => {
const getExecutionSpy = jest.spyOn(activeExecutions, 'getExecution');
activeExecutions.finalizeExecution('inactive-execution-id', mockFullRunData());
expect(getExecutionSpy).not.toHaveBeenCalled();
});
test('Should resolve post execute promise on removal', async () => {
const newExecution = mockExecutionData();
const executionId = await activeExecutions.add(newExecution);
const postExecutePromise = activeExecutions.getPostExecutePromise(executionId);
// Force the above to be executed since we cannot await it
await new Promise((res) => {
setTimeout(res, 100);
});
const fakeOutput = mockFullRunData();
activeExecutions.finalizeExecution(executionId, fakeOutput);
await expect(postExecutePromise).resolves.toEqual(fakeOutput);
});
test('Should throw error when trying to create a promise with invalid execution', async () => {
await expect(activeExecutions.getPostExecutePromise(FAKE_EXECUTION_ID)).rejects.toThrow();
});
test('Should call function to cancel execution when asked to stop', async () => {
const newExecution = mockExecutionData();
const executionId = await activeExecutions.add(newExecution);
const cancelExecution = jest.fn();
const cancellablePromise = mockCancelablePromise();
cancellablePromise.cancel = cancelExecution;
activeExecutions.attachWorkflowExecution(executionId, cancellablePromise);
activeExecutions.stopExecution(executionId);
expect(cancelExecution).toHaveBeenCalledTimes(1);
});
});
function mockExecutionData(): IWorkflowExecutionDataProcess {
return {
const executionData: IWorkflowExecutionDataProcess = {
executionMode: 'manual',
workflowData: {
id: '123',
@ -192,22 +60,235 @@ function mockExecutionData(): IWorkflowExecutionDataProcess {
},
userId: uuid(),
};
}
function mockFullRunData(): IRun {
return {
data: {
resultData: {
runData: {},
},
},
mode: 'manual',
startedAt: new Date(),
status: 'new',
};
}
beforeEach(() => {
activeExecutions = new ActiveExecutions(mock(), executionRepository, concurrencyControl);
// eslint-disable-next-line @typescript-eslint/promise-function-async
const mockCancelablePromise = () => new PCancelable<IRun>((resolve) => resolve());
executionRepository.createNewExecution.mockResolvedValue(FAKE_EXECUTION_ID);
const mockDeferredPromise = () => createDeferredPromise<IExecuteResponsePromiseData>();
workflowExecution = new PCancelable<IRun>((resolve) => resolve());
workflowExecution.cancel = jest.fn();
responsePromise = mock<IDeferredPromise<IExecuteResponsePromiseData>>();
});
afterEach(() => {
jest.clearAllMocks();
});
test('Should initialize activeExecutions with empty list', () => {
expect(activeExecutions.getActiveExecutions()).toHaveLength(0);
});
test('Should add execution to active execution list', async () => {
const executionId = await activeExecutions.add(executionData);
expect(executionId).toBe(FAKE_EXECUTION_ID);
expect(activeExecutions.getActiveExecutions()).toHaveLength(1);
expect(executionRepository.createNewExecution).toHaveBeenCalledTimes(1);
expect(executionRepository.updateExistingExecution).toHaveBeenCalledTimes(0);
});
test('Should update execution if add is called with execution ID', async () => {
const executionId = await activeExecutions.add(executionData, FAKE_SECOND_EXECUTION_ID);
expect(executionId).toBe(FAKE_SECOND_EXECUTION_ID);
expect(activeExecutions.getActiveExecutions()).toHaveLength(1);
expect(executionRepository.createNewExecution).toHaveBeenCalledTimes(0);
expect(executionRepository.updateExistingExecution).toHaveBeenCalledTimes(1);
});
describe('attachWorkflowExecution', () => {
test('Should fail attaching execution to invalid executionId', async () => {
expect(() => {
activeExecutions.attachWorkflowExecution(FAKE_EXECUTION_ID, workflowExecution);
}).toThrow();
});
test('Should successfully attach execution to valid executionId', async () => {
await activeExecutions.add(executionData, FAKE_EXECUTION_ID);
expect(() =>
activeExecutions.attachWorkflowExecution(FAKE_EXECUTION_ID, workflowExecution),
).not.toThrow();
});
});
test('Should attach and resolve response promise to existing execution', async () => {
await activeExecutions.add(executionData, FAKE_EXECUTION_ID);
activeExecutions.attachResponsePromise(FAKE_EXECUTION_ID, responsePromise);
const fakeResponse = { data: { resultData: { runData: {} } } };
activeExecutions.resolveResponsePromise(FAKE_EXECUTION_ID, fakeResponse);
expect(responsePromise.resolve).toHaveBeenCalledWith(fakeResponse);
});
test('Should copy over startedAt and responsePromise when resuming a waiting execution', async () => {
const executionId = await activeExecutions.add(executionData);
activeExecutions.setStatus(executionId, 'waiting');
activeExecutions.attachResponsePromise(executionId, responsePromise);
const waitingExecution = activeExecutions.getExecutionOrFail(executionId);
expect(waitingExecution.responsePromise).toBeDefined();
// Resume the execution
await activeExecutions.add(executionData, executionId);
const resumedExecution = activeExecutions.getExecutionOrFail(executionId);
expect(resumedExecution.startedAt).toBe(waitingExecution.startedAt);
expect(resumedExecution.responsePromise).toBe(responsePromise);
});
describe('finalizeExecution', () => {
test('Should not remove a waiting execution', async () => {
const executionId = await activeExecutions.add(executionData);
activeExecutions.setStatus(executionId, 'waiting');
activeExecutions.finalizeExecution(executionId);
// Wait until the next tick to ensure that the post-execution promise has settled
await new Promise(setImmediate);
// Execution should still be in activeExecutions
expect(activeExecutions.getActiveExecutions()).toHaveLength(1);
expect(activeExecutions.getStatus(executionId)).toBe('waiting');
});
test('Should remove an existing execution', async () => {
const executionId = await activeExecutions.add(executionData);
activeExecutions.finalizeExecution(executionId);
await new Promise(setImmediate);
expect(activeExecutions.getActiveExecutions()).toHaveLength(0);
});
test('Should not try to resolve a post-execute promise for an inactive execution', async () => {
const getExecutionSpy = jest.spyOn(activeExecutions, 'getExecutionOrFail');
activeExecutions.finalizeExecution('inactive-execution-id', fullRunData);
expect(getExecutionSpy).not.toHaveBeenCalled();
});
test('Should resolve post execute promise on removal', async () => {
const executionId = await activeExecutions.add(executionData);
const postExecutePromise = activeExecutions.getPostExecutePromise(executionId);
await new Promise(setImmediate);
activeExecutions.finalizeExecution(executionId, fullRunData);
await expect(postExecutePromise).resolves.toEqual(fullRunData);
});
});
describe('getPostExecutePromise', () => {
test('Should throw error when trying to create a promise with invalid execution', async () => {
await expect(activeExecutions.getPostExecutePromise(FAKE_EXECUTION_ID)).rejects.toThrow();
});
});
describe('stopExecution', () => {
let executionId: string;
beforeEach(async () => {
executionId = await activeExecutions.add(executionData);
postExecutePromise = activeExecutions.getPostExecutePromise(executionId);
activeExecutions.attachWorkflowExecution(executionId, workflowExecution);
activeExecutions.attachResponsePromise(executionId, responsePromise);
});
test('Should cancel ongoing executions', async () => {
activeExecutions.stopExecution(executionId);
expect(responsePromise.reject).toHaveBeenCalledWith(expect.any(ExecutionCancelledError));
expect(workflowExecution.cancel).toHaveBeenCalledTimes(1);
await expect(postExecutePromise).rejects.toThrow(ExecutionCancelledError);
});
test('Should cancel waiting executions', async () => {
activeExecutions.setStatus(executionId, 'waiting');
activeExecutions.stopExecution(executionId);
expect(responsePromise.reject).toHaveBeenCalledWith(expect.any(ExecutionCancelledError));
expect(workflowExecution.cancel).not.toHaveBeenCalled();
});
});
describe('shutdown', () => {
let newExecutionId1: string, newExecutionId2: string;
let waitingExecutionId1: string, waitingExecutionId2: string;
beforeEach(async () => {
config.set('executions.mode', 'regular');
executionRepository.createNewExecution.mockImplementation(async () =>
randomInt(1000, 2000).toString(),
);
(sleep as jest.Mock).mockImplementation(() => {
// @ts-expect-error private property
activeExecutions.activeExecutions = {};
});
newExecutionId1 = await activeExecutions.add(executionData);
activeExecutions.setStatus(newExecutionId1, 'new');
activeExecutions.attachResponsePromise(newExecutionId1, responsePromise);
newExecutionId2 = await activeExecutions.add(executionData);
activeExecutions.setStatus(newExecutionId2, 'new');
waitingExecutionId1 = await activeExecutions.add(executionData);
activeExecutions.setStatus(waitingExecutionId1, 'waiting');
activeExecutions.attachResponsePromise(waitingExecutionId1, responsePromise);
waitingExecutionId2 = await activeExecutions.add(executionData);
activeExecutions.setStatus(waitingExecutionId2, 'waiting');
});
test('Should cancel only executions with response-promises by default', async () => {
const stopExecutionSpy = jest.spyOn(activeExecutions, 'stopExecution');
expect(activeExecutions.getActiveExecutions()).toHaveLength(4);
await activeExecutions.shutdown();
expect(concurrencyControl.disable).toHaveBeenCalled();
const removeAllCaptor = captor<string[]>();
expect(concurrencyControl.removeAll).toHaveBeenCalledWith(removeAllCaptor);
expect(removeAllCaptor.value.sort()).toEqual([newExecutionId1, waitingExecutionId1].sort());
expect(stopExecutionSpy).toHaveBeenCalledTimes(2);
expect(stopExecutionSpy).toHaveBeenCalledWith(newExecutionId1);
expect(stopExecutionSpy).toHaveBeenCalledWith(waitingExecutionId1);
expect(stopExecutionSpy).not.toHaveBeenCalledWith(newExecutionId2);
expect(stopExecutionSpy).not.toHaveBeenCalledWith(waitingExecutionId2);
await new Promise(setImmediate);
// the other two executions aren't cancelled, but still removed from memory
expect(activeExecutions.getActiveExecutions()).toHaveLength(0);
});
test('Should cancel all executions when cancelAll is true', async () => {
const stopExecutionSpy = jest.spyOn(activeExecutions, 'stopExecution');
expect(activeExecutions.getActiveExecutions()).toHaveLength(4);
await activeExecutions.shutdown(true);
expect(concurrencyControl.disable).toHaveBeenCalled();
const removeAllCaptor = captor<string[]>();
expect(concurrencyControl.removeAll).toHaveBeenCalledWith(removeAllCaptor);
expect(removeAllCaptor.value.sort()).toEqual(
[newExecutionId1, newExecutionId2, waitingExecutionId1, waitingExecutionId2].sort(),
);
expect(stopExecutionSpy).toHaveBeenCalledTimes(4);
expect(stopExecutionSpy).toHaveBeenCalledWith(newExecutionId1);
expect(stopExecutionSpy).toHaveBeenCalledWith(waitingExecutionId1);
expect(stopExecutionSpy).toHaveBeenCalledWith(newExecutionId2);
expect(stopExecutionSpy).toHaveBeenCalledWith(waitingExecutionId2);
});
});
});

View file

@ -95,13 +95,14 @@ export class ActiveExecutions {
const resumingExecution = this.activeExecutions[executionId];
const postExecutePromise = createDeferredPromise<IRun | undefined>();
this.activeExecutions[executionId] = {
const execution: IExecutingWorkflowData = {
executionData,
startedAt: resumingExecution?.startedAt ?? new Date(),
postExecutePromise,
status: executionStatus,
responsePromise: resumingExecution?.responsePromise,
};
this.activeExecutions[executionId] = execution;
// Automatically remove execution once the postExecutePromise settles
void postExecutePromise.promise
@ -111,7 +112,10 @@ export class ActiveExecutions {
})
.finally(() => {
this.concurrencyControl.release({ mode: executionData.executionMode });
if (this.activeExecutions[executionId]?.status !== 'waiting') {
if (execution.status === 'waiting') {
// Do not hold on a reference to the previous WorkflowExecute instance, since a resuming execution will use a new instance
delete execution.workflowExecution;
} else {
delete this.activeExecutions[executionId];
this.logger.debug('Execution removed', { executionId });
}
@ -127,14 +131,14 @@ export class ActiveExecutions {
*/
attachWorkflowExecution(executionId: string, workflowExecution: PCancelable<IRun>) {
this.getExecution(executionId).workflowExecution = workflowExecution;
this.getExecutionOrFail(executionId).workflowExecution = workflowExecution;
}
attachResponsePromise(
executionId: string,
responsePromise: IDeferredPromise<IExecuteResponsePromiseData>,
): void {
this.getExecution(executionId).responsePromise = responsePromise;
this.getExecutionOrFail(executionId).responsePromise = responsePromise;
}
resolveResponsePromise(executionId: string, response: IExecuteResponsePromiseData): void {
@ -149,15 +153,23 @@ export class ActiveExecutions {
// There is no execution running with that id
return;
}
execution.workflowExecution?.cancel();
execution.postExecutePromise.reject(new ExecutionCancelledError(executionId));
const error = new ExecutionCancelledError(executionId);
execution.responsePromise?.reject(error);
if (execution.status === 'waiting') {
// A waiting execution will not have a valid workflowExecution or postExecutePromise
// So we can't rely on the `.finally` on the postExecutePromise for the execution removal
delete this.activeExecutions[executionId];
} else {
execution.workflowExecution?.cancel();
execution.postExecutePromise.reject(error);
}
this.logger.debug('Execution cancelled', { executionId });
}
/** Resolve the post-execution promise in an execution. */
finalizeExecution(executionId: string, fullRunData?: IRun) {
if (!this.has(executionId)) return;
const execution = this.getExecution(executionId);
const execution = this.getExecutionOrFail(executionId);
execution.postExecutePromise.resolve(fullRunData);
this.logger.debug('Execution finalized', { executionId });
}
@ -166,7 +178,7 @@ export class ActiveExecutions {
* Returns a promise which will resolve with the data of the execution with the given id
*/
async getPostExecutePromise(executionId: string): Promise<IRun | undefined> {
return await this.getExecution(executionId).postExecutePromise.promise;
return await this.getExecutionOrFail(executionId).postExecutePromise.promise;
}
/**
@ -193,32 +205,40 @@ export class ActiveExecutions {
}
setStatus(executionId: string, status: ExecutionStatus) {
this.getExecution(executionId).status = status;
this.getExecutionOrFail(executionId).status = status;
}
getStatus(executionId: string): ExecutionStatus {
return this.getExecution(executionId).status;
return this.getExecutionOrFail(executionId).status;
}
/** Wait for all active executions to finish */
async shutdown(cancelAll = false) {
let executionIds = Object.keys(this.activeExecutions);
if (config.getEnv('executions.mode') === 'regular') {
const isRegularMode = config.getEnv('executions.mode') === 'regular';
if (isRegularMode) {
// removal of active executions will no longer release capacity back,
// so that throttled executions cannot resume during shutdown
this.concurrencyControl.disable();
}
if (cancelAll) {
if (config.getEnv('executions.mode') === 'regular') {
await this.concurrencyControl.removeAll(this.activeExecutions);
let executionIds = Object.keys(this.activeExecutions);
const toCancel: string[] = [];
for (const executionId of executionIds) {
const { responsePromise, status } = this.activeExecutions[executionId];
if (!!responsePromise || (isRegularMode && cancelAll)) {
// Cancel all exectutions that have a response promise, because these promises can't be retained between restarts
this.stopExecution(executionId);
toCancel.push(executionId);
} else if (status === 'waiting' || status === 'new') {
// Remove waiting and new executions to not block shutdown
delete this.activeExecutions[executionId];
}
executionIds.forEach((executionId) => this.stopExecution(executionId));
}
await this.concurrencyControl.removeAll(toCancel);
let count = 0;
executionIds = Object.keys(this.activeExecutions);
while (executionIds.length !== 0) {
if (count++ % 4 === 0) {
this.logger.info(`Waiting for ${executionIds.length} active executions to finish...`);
@ -229,7 +249,7 @@ export class ActiveExecutions {
}
}
getExecution(executionId: string): IExecutingWorkflowData {
getExecutionOrFail(executionId: string): IExecutingWorkflowData {
const execution = this.activeExecutions[executionId];
if (!execution) {
throw new ExecutionNotFoundError(executionId);

View file

@ -11,7 +11,6 @@ import config from '@/config';
import type { ExecutionRepository } from '@/databases/repositories/execution.repository';
import { InvalidConcurrencyLimitError } from '@/errors/invalid-concurrency-limit.error';
import type { EventService } from '@/events/event.service';
import type { IExecutingWorkflowData } from '@/interfaces';
import type { Telemetry } from '@/telemetry';
import { mockLogger } from '@test/mocking';
@ -432,11 +431,7 @@ describe('ConcurrencyControlService', () => {
/**
* Act
*/
await service.removeAll({
'1': mock<IExecutingWorkflowData>(),
'2': mock<IExecutingWorkflowData>(),
'3': mock<IExecutingWorkflowData>(),
});
await service.removeAll(['1', '2', '3']);
/**
* Assert

View file

@ -8,7 +8,6 @@ import { ExecutionRepository } from '@/databases/repositories/execution.reposito
import { InvalidConcurrencyLimitError } from '@/errors/invalid-concurrency-limit.error';
import { UnknownExecutionModeError } from '@/errors/unknown-execution-mode.error';
import { EventService } from '@/events/event.service';
import type { IExecutingWorkflowData } from '@/interfaces';
import { Telemetry } from '@/telemetry';
import { ConcurrencyQueue } from './concurrency-queue';
@ -140,7 +139,7 @@ export class ConcurrencyControlService {
* enqueued executions that have response promises, as these cannot
* be re-run via `Start.runEnqueuedExecutions` during startup.
*/
async removeAll(activeExecutions: { [executionId: string]: IExecutingWorkflowData }) {
async removeAll(executionIdsToCancel: string[]) {
if (!this.isEnabled) return;
this.queues.forEach((queue) => {
@ -151,15 +150,13 @@ export class ConcurrencyControlService {
}
});
const executionIds = Object.entries(activeExecutions)
.filter(([_, execution]) => execution.status === 'new' && execution.responsePromise)
.map(([executionId, _]) => executionId);
if (executionIdsToCancel.length === 0) return;
if (executionIds.length === 0) return;
await this.executionRepository.cancelMany(executionIdsToCancel);
await this.executionRepository.cancelMany(executionIds);
this.logger.info('Canceled enqueued executions with response promises', { executionIds });
this.logger.info('Canceled enqueued executions with response promises', {
executionIds: executionIdsToCancel,
});
}
disable() {

View file

@ -530,6 +530,7 @@ export async function executeWebhook(
`Error with Webhook-Response for execution "${executionId}": "${error.message}"`,
{ executionId, workflowId: workflow.id },
);
responseCallback(error, {});
});
}