fix(API): Only exclude running executions from the public executions list (#32657)

This commit is contained in:
Emilia 2026-07-03 08:25:20 +01:00 committed by GitHub
parent 2472df8e6a
commit ae50852162
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 102 additions and 1 deletions

View File

@ -174,9 +174,12 @@ const executionHandlers: ExecutionHandlers = {
return res.status(200).json({ data: [], nextCursor: null });
}
// get running executions so we exclude them from the result
// Collect genuinely running executions to exclude from the default listing.
// The active executions list also retains `waiting` executions (persisted and
// resumable); filter by status so waiting executions are still listed.
const runningExecutionsIds = Container.get(ActiveExecutions)
.getActiveExecutions()
.filter(({ status }) => status === 'running')
.map(({ id }) => id);
const filters: Parameters<

View File

@ -10,7 +10,9 @@ import {
import type { ExecutionEntity, User } from '@n8n/db';
import { Container } from '@n8n/di';
import { type ExecutionStatus } from 'n8n-workflow';
import type { MockInstance } from 'vitest';
import { ActiveExecutions } from '@/active-executions';
import type { ActiveWorkflowManager } from '@/active-workflow-manager';
import { AbortedExecutionRetryError } from '@/errors/aborted-execution-retry.error';
import { QueuedExecutionRetryError } from '@/errors/queued-execution-retry.error';
@ -532,6 +534,102 @@ describe('GET /executions', () => {
);
});
describe('with executions held in the active-executions map', () => {
// Mirrors a running instance: a `waiting` execution is persisted in the DB
// and its id is also held in the in-process active-executions map.
let activeExecutionsSpy: MockInstance | undefined;
const holdInActiveExecutions = (
stubs: Array<{ executionId: string; workflowId: string; status: ExecutionStatus }>,
) => {
activeExecutionsSpy = vi
.spyOn(Container.get(ActiveExecutions), 'getActiveExecutions')
.mockReturnValue(
stubs.map(({ executionId, workflowId, status }) => ({
id: executionId,
retryOf: undefined,
startedAt: new Date(),
mode: 'manual',
workflowId,
status,
})),
);
};
afterEach(() => {
activeExecutionsSpy?.mockRestore();
activeExecutionsSpy = undefined;
});
test.each`
query | description
${undefined} | ${'in the default list'}
${{ status: 'waiting' }} | ${'when filtering by status=waiting'}
`(
'should return a held waiting execution $description',
async ({ query }: { query?: { status: string } }) => {
const workflow = await createWorkflow({}, owner);
const waitingExecution = await createdExecutionWithStatus(workflow, 'waiting');
holdInActiveExecutions([
{ executionId: waitingExecution.id, workflowId: workflow.id, status: 'waiting' },
]);
const response = await authOwnerAgent.get('/executions').query(query ?? {});
expect(response.statusCode).toBe(200);
expect(response.body.data.map((e: { id: string }) => e.id)).toContain(waitingExecution.id);
},
);
test('should return both a held waiting execution and a finished one in the default list', async () => {
const workflow = await createWorkflow({}, owner);
const finishedExecution = await createdExecutionWithStatus(workflow, 'success');
const waitingExecution = await createdExecutionWithStatus(workflow, 'waiting');
holdInActiveExecutions([
{ executionId: waitingExecution.id, workflowId: workflow.id, status: 'waiting' },
]);
const response = await authOwnerAgent.get('/executions');
expect(response.statusCode).toBe(200);
const ids = response.body.data.map((e: { id: string }) => e.id);
expect(ids).toContain(finishedExecution.id);
expect(ids).toContain(waitingExecution.id);
});
test('should still exclude a genuinely running execution from the default list', async () => {
const workflow = await createWorkflow({}, owner);
const runningExecution = await createdExecutionWithStatus(workflow, 'running');
holdInActiveExecutions([
{ executionId: runningExecution.id, workflowId: workflow.id, status: 'running' },
]);
const response = await authOwnerAgent.get('/executions');
expect(response.statusCode).toBe(200);
expect(response.body.data.map((e: { id: string }) => e.id)).not.toContain(
runningExecution.id,
);
});
test('should return a held waiting execution and exclude a held running execution when both are active', async () => {
const workflow = await createWorkflow({}, owner);
const waitingExecution = await createdExecutionWithStatus(workflow, 'waiting');
const runningExecution = await createdExecutionWithStatus(workflow, 'running');
holdInActiveExecutions([
{ executionId: waitingExecution.id, workflowId: workflow.id, status: 'waiting' },
{ executionId: runningExecution.id, workflowId: workflow.id, status: 'running' },
]);
const response = await authOwnerAgent.get('/executions');
expect(response.statusCode).toBe(200);
const ids = response.body.data.map((e: { id: string }) => e.id);
expect(ids).toContain(waitingExecution.id);
expect(ids).not.toContain(runningExecution.id);
});
});
test('should retrieve all executions of specific workflow', async () => {
const [workflow, workflow2] = await createManyWorkflows(2, {}, owner);