diff --git a/packages/@n8n/engine/src/database/__tests__/workflow-execution.integration.test.ts b/packages/@n8n/engine/src/database/__tests__/workflow-execution.integration.test.ts index 4305c7e146f..598cd07e8dc 100644 --- a/packages/@n8n/engine/src/database/__tests__/workflow-execution.integration.test.ts +++ b/packages/@n8n/engine/src/database/__tests__/workflow-execution.integration.test.ts @@ -205,10 +205,10 @@ describe('workflow_execution table (integration)', () => { const finished = await new TypeOrmExecutionStore(repo).finishExecution(created.id, 'failed'); - expect(finished).toBe(true); const row = await repo.findOneOrFail({ where: { id: created.id } }); expect(row.status).toBe('failed'); expect(row.finishedAt).toBeInstanceOf(Date); + expect(finished).toEqual({ finishedAt: row.finishedAt }); }); it.each(['completed', 'queued'])( @@ -233,7 +233,7 @@ describe('workflow_execution table (integration)', () => { const finished = await new TypeOrmExecutionStore(repo).finishExecution(created.id, 'failed'); - expect(finished).toBe(false); + expect(finished).toBeNull(); const row = await repo.findOneOrFail({ where: { id: created.id } }); expect(row.status).toBe(status); expect(row.finishedAt).toEqual(finishedAt); @@ -248,10 +248,11 @@ describe('workflow_execution table (integration)', () => { const cancelled = await new TypeOrmExecutionStore(repo).cancelExecution(id); - expect(cancelled).toBe(true); const row = await repo.findOneOrFail({ where: { id } }); expect(row.status).toBe('cancelled'); expect(row.finishedAt).toBeInstanceOf(Date); + // the caller reports the time the row records, not a second reading of the clock + expect(cancelled).toEqual({ finishedAt: row.finishedAt }); }, ); @@ -264,7 +265,7 @@ describe('workflow_execution table (integration)', () => { const cancelled = await new TypeOrmExecutionStore(repo).cancelExecution(id); - expect(cancelled).toBe(false); + expect(cancelled).toBeNull(); const row = await repo.findOneOrFail({ where: { id } }); expect(row.status).toBe(status); expect(row.finishedAt).toEqual(finishedAt); diff --git a/packages/@n8n/engine/src/database/typeorm-execution-store.ts b/packages/@n8n/engine/src/database/typeorm-execution-store.ts index 65395c43bf1..0b144bbb098 100644 --- a/packages/@n8n/engine/src/database/typeorm-execution-store.ts +++ b/packages/@n8n/engine/src/database/typeorm-execution-store.ts @@ -49,21 +49,26 @@ export class TypeOrmExecutionStore implements ExecutionStore { return result.affected === 1; } - async finishExecution(id: string, status: 'completed' | 'failed'): Promise { + async finishExecution( + id: string, + status: 'completed' | 'failed', + ): Promise<{ finishedAt: Date } | null> { // A waiting execution can end too: a failure elsewhere cancels its waits. + const finishedAt = new Date(); const result = await this.repo.update( { id, status: In([...LIVE_EXECUTION_STATUSES]) }, - { status, finishedAt: new Date() }, + { status, finishedAt }, ); - return result.affected === 1; + return result.affected === 1 ? { finishedAt } : null; } - async cancelExecution(id: string): Promise { + async cancelExecution(id: string): Promise<{ finishedAt: Date } | null> { + const finishedAt = new Date(); const result = await this.repo.update( { id, status: In(['queued', ...LIVE_EXECUTION_STATUSES] satisfies ExecutionStatus[]) }, - { status: 'cancelled', finishedAt: new Date() }, + { status: 'cancelled', finishedAt }, ); - return result.affected === 1; + return result.affected === 1 ? { finishedAt } : null; } async refreshLiveStatus(id: string): Promise { diff --git a/packages/@n8n/engine/src/execution/__tests__/cancel-execution.service.test.ts b/packages/@n8n/engine/src/execution/__tests__/cancel-execution.service.test.ts index ff0dafeb1a7..a8b141c7483 100644 --- a/packages/@n8n/engine/src/execution/__tests__/cancel-execution.service.test.ts +++ b/packages/@n8n/engine/src/execution/__tests__/cancel-execution.service.test.ts @@ -28,7 +28,9 @@ function makeExecutionStore(overrides: Partial = {}): ExecutionS loadExecution: vi.fn().mockResolvedValue({ ...execution, status: 'cancelled' }), transitionStatus: vi.fn(), finishExecution: vi.fn(), - cancelExecution: vi.fn().mockResolvedValue(true), + cancelExecution: vi + .fn() + .mockResolvedValue({ finishedAt: new Date('2026-09-30T08:00:00.000Z') }), refreshLiveStatus: vi.fn(), ...overrides, }; @@ -60,7 +62,7 @@ describe('CancelExecutionService', () => { type: 'execution:cancelled', executionId: 'exec-1', workflowId: 'wf-1', - at: expect.any(String), + at: '2026-09-30T08:00:00.000Z', }); }); @@ -68,7 +70,7 @@ describe('CancelExecutionService', () => { 'reports the status of an execution that already ended %s and announces nothing', async (status) => { const executionStore = makeExecutionStore({ - cancelExecution: vi.fn().mockResolvedValue(false), + cancelExecution: vi.fn().mockResolvedValue(null), loadExecution: vi.fn().mockResolvedValue({ ...execution, status }), }); const { service, stepStore, publisher } = makeService(executionStore); @@ -83,7 +85,7 @@ describe('CancelExecutionService', () => { it('throws for an unknown execution', async () => { const executionStore = makeExecutionStore({ - cancelExecution: vi.fn().mockResolvedValue(false), + cancelExecution: vi.fn().mockResolvedValue(null), loadExecution: vi.fn().mockRejectedValue(new ExecutionNotFoundError('exec-404')), }); const { service, stepStore } = makeService(executionStore); diff --git a/packages/@n8n/engine/src/execution/__tests__/execution-start-handler.test.ts b/packages/@n8n/engine/src/execution/__tests__/execution-start-handler.test.ts index aba8380f4a0..a437a181e11 100644 --- a/packages/@n8n/engine/src/execution/__tests__/execution-start-handler.test.ts +++ b/packages/@n8n/engine/src/execution/__tests__/execution-start-handler.test.ts @@ -27,8 +27,8 @@ function makeExecutionStore(overrides: Partial = {}): ExecutionS createExecution: vi.fn(), loadExecution: vi.fn(), transitionStatus: vi.fn().mockResolvedValue(true), - finishExecution: vi.fn().mockResolvedValue(true), - cancelExecution: vi.fn().mockResolvedValue(true), + finishExecution: vi.fn().mockResolvedValue(null), + cancelExecution: vi.fn().mockResolvedValue(null), refreshLiveStatus: vi.fn(), ...overrides, }; diff --git a/packages/@n8n/engine/src/execution/__tests__/start-execution.service.test.ts b/packages/@n8n/engine/src/execution/__tests__/start-execution.service.test.ts index 05b34f9550f..3c3ad60b32b 100644 --- a/packages/@n8n/engine/src/execution/__tests__/start-execution.service.test.ts +++ b/packages/@n8n/engine/src/execution/__tests__/start-execution.service.test.ts @@ -28,8 +28,8 @@ function makeStore(overrides: Partial = {}): ExecutionStore { createExecution: vi.fn(), loadExecution: vi.fn(), transitionStatus: vi.fn().mockResolvedValue(true), - finishExecution: vi.fn().mockResolvedValue(true), - cancelExecution: vi.fn().mockResolvedValue(true), + finishExecution: vi.fn().mockResolvedValue(null), + cancelExecution: vi.fn().mockResolvedValue(null), refreshLiveStatus: vi.fn(), ...overrides, }; diff --git a/packages/@n8n/engine/src/execution/__tests__/step-ready-handler.test.ts b/packages/@n8n/engine/src/execution/__tests__/step-ready-handler.test.ts index 8c25ccaccb2..cc6cfce96cc 100644 --- a/packages/@n8n/engine/src/execution/__tests__/step-ready-handler.test.ts +++ b/packages/@n8n/engine/src/execution/__tests__/step-ready-handler.test.ts @@ -88,8 +88,8 @@ function makeExecutionStore(overrides: Partial = {}): Execution createExecution: vi.fn(), loadExecution: vi.fn().mockResolvedValue(execution), transitionStatus: vi.fn().mockResolvedValue(true), - finishExecution: vi.fn().mockResolvedValue(true), - cancelExecution: vi.fn().mockResolvedValue(true), + finishExecution: vi.fn().mockResolvedValue(null), + cancelExecution: vi.fn().mockResolvedValue(null), refreshLiveStatus: vi.fn(), }; } diff --git a/packages/@n8n/engine/src/execution/__tests__/step-settled-handler.test.ts b/packages/@n8n/engine/src/execution/__tests__/step-settled-handler.test.ts index a8ffcaeb6ee..b7ce248efb4 100644 --- a/packages/@n8n/engine/src/execution/__tests__/step-settled-handler.test.ts +++ b/packages/@n8n/engine/src/execution/__tests__/step-settled-handler.test.ts @@ -65,8 +65,10 @@ function makeExecutionStore( createExecution: vi.fn(), loadExecution: vi.fn().mockResolvedValue(execution), transitionStatus: vi.fn().mockResolvedValue(true), - finishExecution: vi.fn().mockResolvedValue(true), - cancelExecution: vi.fn().mockResolvedValue(true), + finishExecution: vi + .fn() + .mockResolvedValue({ finishedAt: new Date('2026-09-30T08:00:00.000Z') }), + cancelExecution: vi.fn().mockResolvedValue(null), refreshLiveStatus: vi.fn(), ...storeOverrides, }; @@ -456,7 +458,7 @@ describe('StepSettledHandler', () => { const stepStore = makeStepStore({ status: 'failed' }); const executionStore = makeExecutionStore( {}, - { finishExecution: vi.fn().mockResolvedValue(false) }, + { finishExecution: vi.fn().mockResolvedValue(null) }, ); const { handler } = makeHandler(stepStore, { executionStore }); @@ -514,7 +516,7 @@ describe('StepSettledHandler', () => { }); describe('StepSettledHandler lifecycle events', () => { - const finished = { executionId: 'exec-1', workflowId: 'wf-1', at: expect.any(String) as string }; + const finished = { executionId: 'exec-1', workflowId: 'wf-1', at: '2026-09-30T08:00:00.000Z' }; it('announces execution:completed when it records the completion', async () => { const stepStore = makeStepStore( @@ -639,7 +641,7 @@ describe('StepSettledHandler lifecycle events', () => { // Another worker already wrote the outcome, so it announces it. const executionStore = makeExecutionStore( {}, - { finishExecution: vi.fn().mockResolvedValue(false) }, + { finishExecution: vi.fn().mockResolvedValue(null) }, ); const { handler, lifecycleEventPublisher, responseSender } = makeHandler( makeStepStore({ status: 'failed' }), diff --git a/packages/@n8n/engine/src/execution/cancel-execution.service.ts b/packages/@n8n/engine/src/execution/cancel-execution.service.ts index bb05a8a717b..38b210d5731 100644 --- a/packages/@n8n/engine/src/execution/cancel-execution.service.ts +++ b/packages/@n8n/engine/src/execution/cancel-execution.service.ts @@ -32,7 +32,7 @@ export class CancelExecutionService { type: 'execution:cancelled', executionId, workflowId: execution.workflowId, - at: new Date().toISOString(), + at: cancelled.finishedAt.toISOString(), }); } diff --git a/packages/@n8n/engine/src/execution/execution-store.ts b/packages/@n8n/engine/src/execution/execution-store.ts index a032daaeda6..1a1518733ed 100644 --- a/packages/@n8n/engine/src/execution/execution-store.ts +++ b/packages/@n8n/engine/src/execution/execution-store.ts @@ -59,12 +59,17 @@ export interface ExecutionStore { /** * Record an execution's outcome: writes the final status and the finish time * together, as a compare-and-set on the live statuses, so they can't be - * observed apart. + * observed apart. Returns the finish time it wrote, so the caller reports the + * time the row records, or `null` when the compare-and-set lost. */ - finishExecution(id: string, status: 'completed' | 'failed'): Promise; + finishExecution(id: string, status: 'completed' | 'failed'): Promise<{ finishedAt: Date } | null>; - /** End an execution on request, from any status that has not ended. */ - cancelExecution(id: string): Promise; + /** + * End an execution on request, from any status that has not ended. Returns + * the finish time it wrote, so the caller reports the time the row records, + * or `null` when the execution had already ended. + */ + cancelExecution(id: string): Promise<{ finishedAt: Date } | null>; /** * Sets a live execution's status from the state of its steps: `waiting` when diff --git a/packages/@n8n/engine/src/execution/step-settled-handler.ts b/packages/@n8n/engine/src/execution/step-settled-handler.ts index 56758b10e8c..02a0d467665 100644 --- a/packages/@n8n/engine/src/execution/step-settled-handler.ts +++ b/packages/@n8n/engine/src/execution/step-settled-handler.ts @@ -96,7 +96,7 @@ export class StepSettledHandler { type: 'execution:failed', executionId: execution.id, workflowId: execution.workflowId, - at: new Date().toISOString(), + at: finished.finishedAt.toISOString(), }); this.announceEnd(execution, step, node, 'failed'); } @@ -206,7 +206,7 @@ export class StepSettledHandler { type: failed ? 'execution:failed' : 'execution:completed', executionId: execution.id, workflowId: execution.workflowId, - at: new Date().toISOString(), + at: finished.finishedAt.toISOString(), }); this.announceEnd(execution, step, node, failed ? 'failed' : 'completed'); }