refactor(API): Route public API executions through shared execution services (no-changelog) (#35582)

This commit is contained in:
Ali Elkhateeb
2026-08-07 11:13:11 +03:00
committed by GitHub
parent 9c4a613b44
commit 8db0b48ee1
13 changed files with 488 additions and 286 deletions
@@ -32,43 +32,43 @@ describe('ExecutionRepository', () => {
vi.resetAllMocks();
});
describe('getExecutionsCountForPublicApi', () => {
describe('countInWorkflows', () => {
test('should get executions matching all filter parameters', async () => {
const mockCount = 20;
const params = {
const workflowIds = ['3', '4'];
const options = {
limit: 10,
lastId: '3',
workflowIds: ['3', '4'],
};
entityManager.count.mockResolvedValueOnce(mockCount);
const result = await executionRepository.getExecutionsCountForPublicApi(params);
const result = await executionRepository.countInWorkflows(workflowIds, options);
expect(entityManager.count).toHaveBeenCalledWith(ExecutionEntity, {
where: {
id: LessThan(params.lastId),
workflowId: In(params.workflowIds),
id: LessThan(options.lastId),
workflowId: In(workflowIds),
},
take: params.limit,
take: options.limit,
});
expect(result).toBe(mockCount);
});
test('should get executions matching the workflowIds filter', async () => {
const mockCount = 12;
const params = {
const workflowIds = ['7', '8'];
const options = {
limit: 10,
workflowIds: ['7', '8'],
};
entityManager.count.mockResolvedValueOnce(mockCount);
const result = await executionRepository.getExecutionsCountForPublicApi(params);
const result = await executionRepository.countInWorkflows(workflowIds, options);
expect(entityManager.count).toHaveBeenCalledWith(ExecutionEntity, {
where: {
workflowId: In(params.workflowIds),
workflowId: In(workflowIds),
},
take: params.limit,
take: options.limit,
});
expect(result).toBe(mockCount);
});
@@ -86,19 +86,21 @@ describe('ExecutionRepository', () => {
'should find with id less than "$lastId" and not in "$excludedExecutionsIds"',
async ({ lastId, excludedExecutionsIds, expectedIdCondition }) => {
const mockCount = 15;
const params = {
const workflowIds = ['wf-1'];
const options = {
limit: 10,
...(lastId ? { lastId } : {}),
...(excludedExecutionsIds ? { excludedExecutionsIds } : {}),
};
entityManager.count.mockResolvedValueOnce(mockCount);
const result = await executionRepository.getExecutionsCountForPublicApi(params);
const result = await executionRepository.countInWorkflows(workflowIds, options);
expect(entityManager.count).toHaveBeenCalledWith(ExecutionEntity, {
where: {
workflowId: In(workflowIds),
...(expectedIdCondition ? { id: expectedIdCondition } : {}),
},
take: params.limit,
take: options.limit,
});
expect(result).toBe(mockCount);
},
@@ -119,15 +121,16 @@ describe('ExecutionRepository', () => {
`('should retrieve all $filterStatus executions', async ({ filterStatus, entityStatus }) => {
const limit = 10;
const mockCount = 20;
const workflowIds = ['wf-1'];
entityManager.count.mockResolvedValueOnce(mockCount);
const result = await executionRepository.getExecutionsCountForPublicApi({
const result = await executionRepository.countInWorkflows(workflowIds, {
limit,
status: filterStatus,
});
expect(entityManager.count).toHaveBeenCalledWith(ExecutionEntity, {
where: { status: entityStatus },
where: { status: entityStatus, workflowId: In(workflowIds) },
take: limit,
});
@@ -137,12 +140,13 @@ describe('ExecutionRepository', () => {
test('should find all executions without status filter', async () => {
const limit = 10;
const mockCount = 20;
const workflowIds = ['wf-1'];
entityManager.count.mockResolvedValueOnce(mockCount);
const result = await executionRepository.getExecutionsCountForPublicApi({ limit });
const result = await executionRepository.countInWorkflows(workflowIds, { limit });
expect(entityManager.count).toHaveBeenCalledWith(ExecutionEntity, {
where: {},
where: { workflowId: In(workflowIds) },
take: limit,
});
@@ -16,6 +16,10 @@ export class AnnotationTagMappingRepository extends Repository<AnnotationTagMapp
return await this.manager.transaction(async (tx) => {
await tx.delete(AnnotationTagMapping, { annotationId });
if (tagIds.length === 0) {
return;
}
const tagMappings = tagIds.map((tagId) => ({
annotationId,
tagId,
@@ -628,19 +628,19 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
});
}
async getExecutionsCountForPublicApi(params: {
limit: number;
lastId?: string;
workflowIds?: string[];
status?: ExecutionStatus;
excludedExecutionsIds?: string[];
}): Promise<number> {
const executionsCount = await this.count({
where: this.getFindExecutionsForPublicApiCondition(params),
take: params.limit,
async countInWorkflows(
workflowIds: string[],
options: {
limit: number;
lastId?: string;
status?: ExecutionStatus;
excludedExecutionsIds?: string[];
},
): Promise<number> {
return await this.count({
where: this.getFindManyInWorkflowsCondition(workflowIds, options),
take: options.limit,
});
return executionsCount;
}
private getStatusCondition(status?: ExecutionStatus) {
@@ -667,19 +667,21 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
return condition;
}
getFindExecutionsForPublicApiCondition(params: {
lastId?: string;
workflowIds?: string[];
status?: ExecutionStatus;
excludedExecutionsIds?: string[];
}) {
getFindManyInWorkflowsCondition(
workflowIds: string[],
options: {
lastId?: string;
status?: ExecutionStatus;
excludedExecutionsIds?: string[];
} = {},
) {
const where: FindOptionsWhere<IExecutionFlattedDb> = {
...this.getIdCondition({
lastId: params.lastId,
excludedExecutionsIds: params.excludedExecutionsIds,
lastId: options.lastId,
excludedExecutionsIds: options.excludedExecutionsIds,
}),
...this.getStatusCondition(params.status),
...(params.workflowIds && { workflowId: In(params.workflowIds) }),
...this.getStatusCondition(options.status),
workflowId: In(workflowIds),
};
return where;
-3
View File
@@ -123,8 +123,6 @@ export default defineConfig(
'./src/public-api/v1/handlers/data-tables/data-tables.service.ts',
'./src/public-api/v1/handlers/discover/discover.handler.ts',
'./src/public-api/v1/handlers/evaluations/evaluations.handler.ts',
'./src/public-api/v1/handlers/executions/executions.handler.ts',
'./src/public-api/v1/handlers/executions/executions.service.ts',
'./src/public-api/v1/handlers/projects/projects.handler.ts',
'./src/public-api/v1/handlers/users/users.handler.ee.ts',
'./src/public-api/v1/handlers/users/users.service.ee.ts',
@@ -249,7 +247,6 @@ export default defineConfig(
'./src/eventbus/message-event-bus/message-event-bus.ts',
'./src/evaluation.ee/evaluation-collection.service.ts',
'./src/evaluation.ee/test-runner/test-runner.service.ee.ts',
'./src/public-api/v1/handlers/executions/executions.handler.ts',
'./src/public-api/v1/handlers/tags/tags.handler.ts',
'./src/public-api/v1/handlers/users/users.service.ee.ts',
'./src/public-api/v1/handlers/workflows/workflows.handler.ts',
@@ -420,12 +420,13 @@ describe('ExecutionPersistence', () => {
expect(execution?.data?.resultData?.runData).toHaveProperty('bigNode');
});
it('getExecutionsForPublicApi omits oversized data and flags it', async () => {
it('findManyInWorkflows omits oversized data and flags it', async () => {
const executionPersistence = Container.get(ExecutionPersistence);
const { workflow } = await createSizedExecution(2 * ONE_MB);
const executions = (await executionPersistence.getExecutionsForPublicApi(
{ limit: 10, includeData: true, workflowIds: [workflow.id] },
const executions = (await executionPersistence.findManyInWorkflows(
[workflow.id],
{ limit: 10, includeData: true },
ONE_MB,
)) as IExecutionResponse[];
@@ -1694,7 +1694,7 @@ describe('ExecutionPersistence', () => {
});
});
describe('getExecutionsForPublicApi', () => {
describe('findManyInWorkflows', () => {
const wf = 'wf-1';
const where = { workflowId: wf };
const publicApiSelect = [
@@ -1711,18 +1711,19 @@ describe('ExecutionPersistence', () => {
];
beforeEach(() => {
executionRepository.getFindExecutionsForPublicApiCondition.mockReturnValue(where);
executionRepository.getFindManyInWorkflowsCondition.mockReturnValue(where);
});
it('should query per the repository where condition, without data when not requested', async () => {
const executionPersistence = createPersistenceService('db');
executionRepository.findMultipleExecutions.mockResolvedValue([]);
const params = { limit: 10, workflowIds: [wf] };
const options = { limit: 10 };
await executionPersistence.getExecutionsForPublicApi(params);
await executionPersistence.findManyInWorkflows([wf], options);
expect(executionRepository.getFindExecutionsForPublicApiCondition).toHaveBeenCalledWith(
params,
expect(executionRepository.getFindManyInWorkflowsCondition).toHaveBeenCalledWith(
[wf],
options,
);
expect(executionRepository.findMultipleExecutions).toHaveBeenCalledWith(
{ select: publicApiSelect, where, order: { id: 'DESC' }, take: 10 },
@@ -1755,7 +1756,7 @@ describe('ExecutionPersistence', () => {
]),
);
const result = await executionPersistence.getExecutionsForPublicApi({
const result = await executionPersistence.findManyInWorkflows([wf], {
limit: 10,
includeData: true,
});
@@ -1,6 +1,9 @@
import { mockInstance } from '@n8n/backend-test-utils';
import { GlobalConfig } from '@n8n/config';
import type {
AnnotationTagMappingRepository,
ExecutionAnnotationRepository,
IExecutionBase,
IExecutionDb,
IExecutionResponse,
ExecutionRepository,
@@ -10,6 +13,7 @@ import type {
} from '@n8n/db';
import type { WorkflowHistory } from '@n8n/db';
import { Container } from '@n8n/di';
import { QueryFailedError } from '@n8n/typeorm';
import { mock } from 'vitest-mock-extended';
import type { IRun, IRunData, IRunExecutionData, ITaskData } from 'n8n-workflow';
import { ManualExecutionCancelledError, WorkflowOperationError } from 'n8n-workflow';
@@ -18,6 +22,7 @@ import type { ActiveExecutions } from '@/active-executions';
import type { ConcurrencyControlService } from '@/concurrency/concurrency-control.service';
import { AbortedExecutionRetryError } from '@/errors/aborted-execution-retry.error';
import { MissingExecutionStopError } from '@/errors/missing-execution-stop.error';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { MissingExecutionDataError } from '@/executions/execution-data/missing-execution-data.error';
import type { ExecutionPersistence } from '@/executions/execution-persistence';
@@ -34,6 +39,8 @@ import type { WorkflowRunner } from '@/workflow-runner';
describe('ExecutionService', () => {
const scalingService = mockInstance(ScalingService);
const activeExecutions = mock<ActiveExecutions>();
const executionAnnotationRepository = mock<ExecutionAnnotationRepository>();
const annotationTagMappingRepository = mock<AnnotationTagMappingRepository>();
const executionRepository = mock<ExecutionRepository>();
const executionPersistence = mock<ExecutionPersistence>();
const workflowHistoryRepository = mock<WorkflowHistoryRepository>();
@@ -48,8 +55,8 @@ describe('ExecutionService', () => {
globalConfig,
mock(),
activeExecutions,
mock(),
mock(),
executionAnnotationRepository,
annotationTagMappingRepository,
executionRepository,
executionPersistence,
workflowHistoryRepository,
@@ -81,7 +88,7 @@ describe('ExecutionService', () => {
* Arrange
*/
const execution = mock<IExecutionResponse>({ id: '123', data: { resultData: {} } });
executionPersistence.findIfSharedUnflatten.mockResolvedValue(execution);
executionPersistence.findOneInWorkflows.mockResolvedValue(execution);
executionRedactionServiceProxy.processExecution.mockResolvedValue(execution);
const req = mock<ExecutionRequest.GetOne>({
@@ -108,7 +115,7 @@ describe('ExecutionService', () => {
* Arrange
*/
const execution = mock<IExecutionResponse>({ id: '123', data: { resultData: {} } });
executionPersistence.findIfSharedUnflatten.mockResolvedValue(execution);
executionPersistence.findOneInWorkflows.mockResolvedValue(execution);
executionRedactionServiceProxy.processExecution.mockResolvedValue(execution);
const req = mock<ExecutionRequest.GetOne>({
@@ -131,7 +138,7 @@ describe('ExecutionService', () => {
});
it('should surface missing execution data as a user-facing not-found error', async () => {
executionPersistence.findIfSharedUnflatten.mockRejectedValue(
executionPersistence.findOneInWorkflows.mockRejectedValue(
new MissingExecutionDataError({ workflowId: 'workflow-1', executionId: '123' }),
);
@@ -147,7 +154,7 @@ describe('ExecutionService', () => {
it('should rethrow errors other than missing execution data unchanged', async () => {
const error = new Error('boom');
executionPersistence.findIfSharedUnflatten.mockRejectedValue(error);
executionPersistence.findOneInWorkflows.mockRejectedValue(error);
const req = mock<ExecutionRequest.GetOne>({ params: { id: '123' }, query: {} });
@@ -856,4 +863,160 @@ describe('ExecutionService', () => {
expect(result).toEqual([]);
});
});
describe('findManyAndCount', () => {
it('should exclude live running executions when excludeRunning is true', async () => {
activeExecutions.getActiveExecutions.mockReturnValue([
{ id: 'run-1', status: 'running' },
{ id: 'wait-1', status: 'waiting' },
] as never);
executionPersistence.findManyInWorkflows.mockResolvedValue([
mock<IExecutionBase>({ id: '10' }),
]);
executionRepository.countInWorkflows.mockResolvedValue(0);
await executionService.findManyAndCount(['wf-1'], {
limit: 10,
excludeRunning: true,
});
expect(executionPersistence.findManyInWorkflows).toHaveBeenCalledWith(
['wf-1'],
expect.objectContaining({ excludedExecutionsIds: ['run-1'] }),
undefined,
);
});
it('should not exclude running executions when excludeRunning is false', async () => {
executionPersistence.findManyInWorkflows.mockResolvedValue([]);
executionRepository.countInWorkflows.mockResolvedValue(0);
await executionService.findManyAndCount(['wf-1'], {
limit: 10,
excludeRunning: false,
});
expect(executionPersistence.findManyInWorkflows).toHaveBeenCalledWith(
['wf-1'],
expect.objectContaining({ excludedExecutionsIds: undefined }),
undefined,
);
expect(activeExecutions.getActiveExecutions).not.toHaveBeenCalled();
});
});
describe('deleteOne', () => {
it('should reject deleting a running execution', async () => {
executionPersistence.findOneInWorkflows.mockResolvedValue(
mock<IExecutionBase>({ id: '1', status: 'running', workflowId: 'wf-1' }),
);
await expect(executionService.deleteOne('1', ['wf-1'])).rejects.toThrow(BadRequestError);
expect(executionPersistence.hardDelete).not.toHaveBeenCalled();
});
it('should remove from concurrency control when deleting a new execution', async () => {
const execution = mock<IExecutionBase>({
id: '1',
status: 'new',
mode: 'manual',
workflowId: 'wf-1',
storedAt: 'db',
});
executionPersistence.findOneInWorkflows.mockResolvedValue(execution);
await executionService.deleteOne('1', ['wf-1']);
expect(concurrencyControl.remove).toHaveBeenCalledWith({
executionId: '1',
mode: 'manual',
});
expect(executionPersistence.hardDelete).toHaveBeenCalledWith({
workflowId: 'wf-1',
executionId: '1',
storedAt: 'db',
});
});
it('should throw NotFoundError when execution is inaccessible', async () => {
executionPersistence.findOneInWorkflows.mockResolvedValue(undefined);
await expect(executionService.deleteOne('1', ['wf-1'])).rejects.toThrow(NotFoundError);
});
});
describe('getExecutionTags', () => {
it('should return mapped tags for an accessible execution', async () => {
executionPersistence.findOneInWorkflows.mockResolvedValue(mock<IExecutionBase>({ id: '1' }));
executionAnnotationRepository.findOne.mockResolvedValue({
tags: [
{
id: 'tag-1',
name: 'Important',
createdAt: new Date('2025-01-01'),
updatedAt: new Date('2025-01-02'),
},
],
} as never);
const result = await executionService.getExecutionTags('1', ['wf-1']);
expect(result).toEqual([
{
id: 'tag-1',
name: 'Important',
createdAt: new Date('2025-01-01'),
updatedAt: new Date('2025-01-02'),
},
]);
});
it('should throw NotFoundError when execution is inaccessible', async () => {
executionPersistence.findOneInWorkflows.mockResolvedValue(undefined);
await expect(executionService.getExecutionTags('1', ['wf-1'])).rejects.toThrow(NotFoundError);
});
});
describe('updateExecutionTags', () => {
it('should overwrite tags and return the updated list', async () => {
executionPersistence.findOneInWorkflows.mockResolvedValue(mock<IExecutionBase>({ id: '1' }));
executionAnnotationRepository.findOneOrFail
.mockResolvedValueOnce({ id: 42 } as never)
.mockResolvedValueOnce({
tags: [
{
id: 'tag-1',
name: 'A',
createdAt: new Date('2025-01-01'),
updatedAt: new Date('2025-01-01'),
},
],
} as never);
const result = await executionService.updateExecutionTags('1', ['tag-1'], ['wf-1']);
expect(annotationTagMappingRepository.overwriteTags).toHaveBeenCalledWith(42, ['tag-1']);
expect(result).toEqual([
{
id: 'tag-1',
name: 'A',
createdAt: new Date('2025-01-01'),
updatedAt: new Date('2025-01-01'),
},
]);
});
it('should map QueryFailedError to NotFoundError for missing tags', async () => {
executionPersistence.findOneInWorkflows.mockResolvedValue(mock<IExecutionBase>({ id: '1' }));
executionAnnotationRepository.findOneOrFail.mockResolvedValue({ id: 42 } as never);
annotationTagMappingRepository.overwriteTags.mockRejectedValue(
new QueryFailedError('INSERT', [], new Error('FK')),
);
await expect(
executionService.updateExecutionTags('1', ['missing'], ['wf-1']),
).rejects.toThrow('Some tags not found');
});
});
});
@@ -521,43 +521,37 @@ export class ExecutionPersistence {
});
}
/** Find an execution scoped to shared workflows, with unflattened data and annotation (a display read). */
async findIfSharedUnflatten(
/**
* Find one execution scoped to the given workflow IDs (display read).
* Defaults: include data + annotation, unflattened.
*/
async findOneInWorkflows(
executionId: string,
sharedWorkflowIds: string[],
maxDataSizeBytes?: number,
) {
return await this.findSingleExecution(executionId, {
where: { workflowId: In(sharedWorkflowIds) },
includeData: true,
unflattenData: true,
includeAnnotation: true,
maxDataSizeBytes,
});
}
/** Find an execution scoped to the given workflows for the public API (a display read). */
async getExecutionInWorkflowsForPublicApi(
id: string,
workflowIds: string[],
includeData?: boolean,
maxDataSizeBytes?: number,
): Promise<IExecutionBase | undefined> {
return await this.findSingleExecution(id, {
options: {
includeData?: boolean;
includeAnnotation?: boolean;
maxDataSizeBytes?: number;
} = {},
): Promise<IExecutionResponse | IExecutionBase | undefined> {
const { includeData = true, includeAnnotation = true, maxDataSizeBytes } = options;
return await this.findSingleExecution(executionId, {
where: { workflowId: In(workflowIds) },
includeData,
unflattenData: true,
includeAnnotation,
maxDataSizeBytes,
});
}
/** Find executions scoped to the given workflows for the public API, with data per `storedAt`. */
async getExecutionsForPublicApi(
params: {
/** Find executions scoped to the given workflows, with data per `storedAt`. */
async findManyInWorkflows(
workflowIds: string[],
options: {
limit: number;
includeData?: boolean;
lastId?: string;
workflowIds?: string[];
status?: ExecutionStatus;
excludedExecutionsIds?: string[];
},
@@ -577,11 +571,11 @@ export class ExecutionPersistence {
'finished',
'status',
],
where: this.executionRepository.getFindExecutionsForPublicApiCondition(params),
where: this.executionRepository.getFindManyInWorkflowsCondition(workflowIds, options),
order: { id: 'DESC' },
take: params.limit,
take: options.limit,
},
{ includeData: params.includeData, unflattenData: true, maxDataSizeBytes },
{ includeData: options.includeData, unflattenData: true, maxDataSizeBytes },
);
}
@@ -4,6 +4,7 @@ import { GlobalConfig } from '@n8n/config';
import type {
CreateExecutionPayload,
ExecutionSummaries,
IExecutionBase,
IExecutionResponse,
IGetExecutionsQueryFilter,
User,
@@ -18,6 +19,7 @@ import {
} from '@n8n/db';
import { Service } from '@n8n/di';
import type { Scope } from '@n8n/permissions';
import { QueryFailedError } from '@n8n/typeorm';
import { ensureError } from '@n8n/utils/errors/ensure-error';
import { stringify } from 'flatted';
import { validate as jsonSchemaValidate } from 'jsonschema';
@@ -45,6 +47,7 @@ import { ConcurrencyControlService } from '@/concurrency/concurrency-control.ser
import { AbortedExecutionRetryError } from '@/errors/aborted-execution-retry.error';
import { MissingExecutionStopError } from '@/errors/missing-execution-stop.error';
import { QueuedExecutionRetryError } from '@/errors/queued-execution-retry.error';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ConflictError } from '@/errors/response-errors/conflict.error';
import { InternalServerError } from '@/errors/response-errors/internal-server.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
@@ -148,6 +151,13 @@ export class ExecutionService {
return { scopes: [scope], projectRoles, workflowRoles };
}
/**
* Editor/internal GET: load an execution for display, apply redaction, and
* return flatted `data` (`IExecutionFlattedResponse`).
*
* Prefer this for the private executions API. For a domain entity with
* caller-controlled options, use {@link findOneInWorkflows}.
*/
async findOne(
req: ExecutionRequest.GetOne | ExecutionRequest.Update,
sharedWorkflowIds: string[],
@@ -155,12 +165,12 @@ export class ExecutionService {
if (!sharedWorkflowIds.length) return undefined;
const { id: executionId } = req.params;
let execution: IExecutionResponse | undefined;
let execution: IExecutionResponse | IExecutionBase | undefined;
try {
execution = await this.executionPersistence.findIfSharedUnflatten(
execution = await this.executionPersistence.findOneInWorkflows(
executionId,
sharedWorkflowIds,
this.globalConfig.executions.maxDisplaySize,
{ maxDataSizeBytes: this.globalConfig.executions.maxDisplaySize },
);
} catch (error) {
if (error instanceof MissingExecutionDataError) {
@@ -179,6 +189,10 @@ export class ExecutionService {
return undefined;
}
if (!('data' in execution)) {
throw new UnexpectedError('Expected execution data for display read');
}
let redactExecutionData: boolean | undefined;
const redactQuery = ExecutionRedactionQueryDtoSchema.safeParse(req.query);
if (redactQuery.success) {
@@ -762,6 +776,159 @@ export class ExecutionService {
}
}
/**
* Load one execution scoped to `workflowIds` as a domain entity.
* Options control data/annotation inclusion; no redaction or flatting.
*
* Prefer this for public API and service-to-service loads. For the editor
* flatted response, use {@link findOne}.
*/
async findOneInWorkflows(
executionId: string,
workflowIds: string[],
options?: {
includeData?: boolean;
includeAnnotation?: boolean;
maxDataSizeBytes?: number;
},
) {
return await this.executionPersistence.findOneInWorkflows(executionId, workflowIds, options);
}
async findManyAndCount(
workflowIds: string[],
options: {
limit: number;
includeData?: boolean;
lastId?: string;
status?: ExecutionStatus;
excludeRunning?: boolean;
maxDataSizeBytes?: number;
},
): Promise<{ executions: IExecutionBase[]; count: number }> {
const excludedExecutionsIds = options.excludeRunning
? this.activeExecutions
.getActiveExecutions()
.filter(({ status }) => status === 'running')
.map(({ id }) => id)
: undefined;
const listOptions = {
limit: options.limit,
includeData: options.includeData,
lastId: options.lastId,
status: options.status,
excludedExecutionsIds,
};
const executions = await this.executionPersistence.findManyInWorkflows(
workflowIds,
listOptions,
options.maxDataSizeBytes,
);
const newLastId = executions.length === 0 ? '0' : executions.at(-1)!.id;
const count = await this.executionRepository.countInWorkflows(workflowIds, {
...listOptions,
lastId: newLastId,
});
return { executions, count };
}
async deleteOne(executionId: string, sharedWorkflowIds: string[]) {
const execution = await this.findOneInWorkflows(executionId, sharedWorkflowIds, {
includeData: false,
includeAnnotation: false,
});
if (!execution) {
throw new NotFoundError('Not Found');
}
if (execution.status === 'running') {
throw new BadRequestError('Cannot delete a running execution');
}
if (execution.status === 'new') {
this.concurrencyControl.remove({
executionId: execution.id,
mode: execution.mode,
});
}
await this.executionPersistence.hardDelete({
workflowId: execution.workflowId,
executionId: execution.id,
storedAt: execution.storedAt,
});
return execution;
}
async getExecutionTags(executionId: string, sharedWorkflowIds: string[]) {
const execution = await this.findOneInWorkflows(executionId, sharedWorkflowIds, {
includeData: false,
includeAnnotation: false,
});
if (!execution) {
throw new NotFoundError('Not Found');
}
const annotation = await this.executionAnnotationRepository.findOne({
where: { execution: { id: executionId } },
relations: ['tags'],
});
return (annotation?.tags ?? []).map(({ id, name, createdAt, updatedAt }) => ({
id,
name,
createdAt,
updatedAt,
}));
}
async updateExecutionTags(executionId: string, tagIds: string[], sharedWorkflowIds: string[]) {
const execution = await this.findOneInWorkflows(executionId, sharedWorkflowIds, {
includeData: false,
includeAnnotation: false,
});
if (!execution) {
throw new NotFoundError('Not Found');
}
await this.executionAnnotationRepository.upsert({ execution: { id: executionId } }, [
'execution',
]);
const annotation = await this.executionAnnotationRepository.findOneOrFail({
where: { execution: { id: executionId } },
});
try {
await this.annotationTagMappingRepository.overwriteTags(annotation.id, tagIds);
} catch (error) {
if (error instanceof QueryFailedError) {
throw new NotFoundError('Some tags not found');
}
throw error;
}
const updatedAnnotation = await this.executionAnnotationRepository.findOneOrFail({
where: { execution: { id: executionId } },
relations: ['tags'],
});
return (updatedAnnotation.tags ?? []).map(({ id, name, createdAt, updatedAt }) => ({
id,
name,
createdAt,
updatedAt,
}));
}
async getExecutedVersions(
workflowId: string,
): Promise<Array<{ versionId: string; name: string | null; createdAt: Date }>> {
@@ -1,27 +1,20 @@
import { ExecutionRedactionQueryDtoSchema } from '@n8n/api-types';
import { ExecutionsConfig } from '@n8n/config';
import type { IExecutionBase } from '@n8n/db';
import { ExecutionRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import { QueryFailedError } from '@n8n/typeorm';
import { type ExecutionStatus, replaceCircularReferences } from 'n8n-workflow';
import { ActiveExecutions } from '@/active-executions';
import { ConcurrencyControlService } from '@/concurrency/concurrency-control.service';
import { AbortedExecutionRetryError } from '@/errors/aborted-execution-retry.error';
import { MissingExecutionStopError } from '@/errors/missing-execution-stop.error';
import { QueuedExecutionRetryError } from '@/errors/queued-execution-retry.error';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ConflictError } from '@/errors/response-errors/conflict.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { EventService } from '@/events/event.service';
import { ExecutionPersistence } from '@/executions/execution-persistence';
import type { RedactableExecution } from '@/executions/execution-redaction';
import { ExecutionRedactionServiceProxy } from '@/executions/execution-redaction-proxy.service';
import { ExecutionService } from '@/executions/execution.service';
import { WorkflowSharingService } from '@/workflows/workflow-sharing.service';
import { getExecutionTags, mapAnnotationTags, updateExecutionTags } from './executions.service';
import type { ExecutionRequest } from '../../../types';
import type { PublicAPIEndpoint } from '../../shared/handler.types';
import { publicApiScope, validCursor } from '../../shared/middlewares/global.middleware';
@@ -63,43 +56,15 @@ const executionHandlers: ExecutionHandlers = {
WorkflowSharingService,
).getSharedWorkflowIdsForScopes(req.user, ['workflow:delete']);
// user does not have workflows hence no executions
// or the execution they are trying to access belongs to a workflow they do not own
if (!sharedWorkflowsIds.length) {
throw new NotFoundError('Not Found');
}
const { id } = req.params;
// look for the execution on the workflow the user owns
const execution = await Container.get(
ExecutionPersistence,
).getExecutionInWorkflowsForPublicApi(id, sharedWorkflowsIds, false);
const execution = await Container.get(ExecutionService).deleteOne(id, sharedWorkflowsIds);
if (!execution) {
throw new NotFoundError('Not Found');
}
if (execution.status === 'running') {
throw new BadRequestError('Cannot delete a running execution');
}
if (execution.status === 'new') {
Container.get(ConcurrencyControlService).remove({
executionId: execution.id,
mode: execution.mode,
});
}
await Container.get(ExecutionPersistence).hardDelete({
workflowId: execution.workflowId,
executionId: execution.id,
storedAt: execution.storedAt,
});
execution.id = id;
return res.json(replaceCircularReferences(execution));
return res.json(replaceCircularReferences({ ...execution, id }));
},
],
getExecution: [
@@ -109,8 +74,6 @@ const executionHandlers: ExecutionHandlers = {
WorkflowSharingService,
).getSharedWorkflowIdsForScopes(req.user, ['workflow:read']);
// user does not have workflows hence no executions
// or the execution they are trying to access belongs to a workflow they do not own
if (!sharedWorkflowsIds.length) {
throw new NotFoundError('Not Found');
}
@@ -124,10 +87,15 @@ const executionHandlers: ExecutionHandlers = {
? 0
: Container.get(ExecutionsConfig).maxDisplaySize;
// look for the execution on the workflow the user owns
const execution = await Container.get(
ExecutionPersistence,
).getExecutionInWorkflowsForPublicApi(id, sharedWorkflowsIds, includeData, maxDataSizeBytes);
const execution = await Container.get(ExecutionService).findOneInWorkflows(
id,
sharedWorkflowsIds,
{
includeData,
includeAnnotation: false,
maxDataSizeBytes,
},
);
if (!execution) {
throw new NotFoundError('Not Found');
@@ -173,45 +141,27 @@ const executionHandlers: ExecutionHandlers = {
WorkflowSharingService,
).getSharedWorkflowIdsForScopes(req.user, ['workflow:read'], projectId);
// user does not have workflows hence no executions
// or the execution they are trying to access belongs to a workflow they do not own
if (!sharedWorkflowsIds.length || (workflowId && !sharedWorkflowsIds.includes(workflowId))) {
return res.status(200).json({ data: [], nextCursor: null });
}
// 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<
typeof ExecutionPersistence.prototype.getExecutionsForPublicApi
>[0] = {
status,
limit,
lastId,
includeData,
workflowIds: workflowId ? [workflowId] : sharedWorkflowsIds,
// for backward compatibility `running` executions are always excluded
// unless the user explicitly filters by `running` status
excludedExecutionsIds: status !== 'running' ? runningExecutionsIds : undefined,
};
const executions = await Container.get(ExecutionPersistence).getExecutionsForPublicApi(
filters,
ignoreDataSizeLimit ? 0 : Container.get(ExecutionsConfig).maxDisplaySize,
const { executions, count } = await Container.get(ExecutionService).findManyAndCount(
workflowId ? [workflowId] : sharedWorkflowsIds,
{
status,
limit,
lastId,
includeData,
// for backward compatibility `running` executions are always excluded
// unless the user explicitly filters by `running` status
excludeRunning: status !== 'running',
maxDataSizeBytes: ignoreDataSizeLimit
? 0
: Container.get(ExecutionsConfig).maxDisplaySize,
},
);
const newLastId = !executions.length ? '0' : executions.slice(-1)[0].id;
filters.lastId = newLastId;
const count =
await Container.get(ExecutionRepository).getExecutionsCountForPublicApi(filters);
const newLastId = executions.length === 0 ? '0' : executions.at(-1)!.id;
if (includeData) {
const redactQuery = ExecutionRedactionQueryDtoSchema.safeParse(req.query);
@@ -253,8 +203,6 @@ const executionHandlers: ExecutionHandlers = {
WorkflowSharingService,
).getSharedWorkflowIdsForScopes(req.user, ['workflow:execute']);
// user does not have workflows hence no executions
// or the execution they are trying to access belongs to a workflow they do not own
if (!sharedWorkflowsIds.length) {
throw new NotFoundError('Not Found');
}
@@ -279,7 +227,6 @@ const executionHandlers: ExecutionHandlers = {
getExecutionTags: [
publicApiScope('executionTags:list'),
async (req, res) => {
const { id } = req.params;
const sharedWorkflowsIds = await Container.get(
WorkflowSharingService,
).getSharedWorkflowIdsForScopes(req.user, ['workflow:read']);
@@ -288,15 +235,10 @@ const executionHandlers: ExecutionHandlers = {
throw new NotFoundError('Not Found');
}
const execution = await Container.get(
ExecutionPersistence,
).getExecutionInWorkflowsForPublicApi(id, sharedWorkflowsIds, false);
if (!execution) {
throw new NotFoundError('Not Found');
}
const tags = await getExecutionTags(id);
const tags = await Container.get(ExecutionService).getExecutionTags(
req.params.id,
sharedWorkflowsIds,
);
return res.json(tags);
},
@@ -304,7 +246,6 @@ const executionHandlers: ExecutionHandlers = {
updateExecutionTags: [
publicApiScope('executionTags:update'),
async (req, res) => {
const { id } = req.params;
const newTagIds = req.body.map((tag) => tag.id);
const sharedWorkflowsIds = await Container.get(
WorkflowSharingService,
@@ -314,22 +255,14 @@ const executionHandlers: ExecutionHandlers = {
throw new NotFoundError('Not Found');
}
const execution = await Container.get(
ExecutionPersistence,
).getExecutionInWorkflowsForPublicApi(id, sharedWorkflowsIds, false);
if (!execution) {
throw new NotFoundError('Not Found');
}
try {
const updatedTags = await updateExecutionTags(id, newTagIds);
const tags = mapAnnotationTags(updatedTags);
const tags = await Container.get(ExecutionService).updateExecutionTags(
req.params.id,
newTagIds,
sharedWorkflowsIds,
);
return res.json(tags);
} catch (error) {
if (error instanceof QueryFailedError) {
throw new NotFoundError('Some tags not found');
}
return handleError(error);
}
},
@@ -341,8 +274,6 @@ const executionHandlers: ExecutionHandlers = {
WorkflowSharingService,
).getSharedWorkflowIdsForScopes(req.user, ['workflow:execute']);
// user does not have workflows hence no executions
// or the execution they are trying to access belongs to a workflow they do not own
if (!sharedWorkflowsIds.length) {
throw new NotFoundError('Not Found');
}
@@ -363,7 +294,6 @@ const executionHandlers: ExecutionHandlers = {
async (req, res) => {
const { status: rawStatus, workflowId, startedAfter, startedBefore } = req.body;
const status: ExecutionStatus[] = rawStatus.map((x) => (x === 'queued' ? 'new' : x));
// Validate that status is provided and not empty
if (!status || status.length === 0) {
return res.status(400).json({
message:
@@ -378,12 +308,10 @@ const executionHandlers: ExecutionHandlers = {
WorkflowSharingService,
).getSharedWorkflowIdsForScopes(req.user, ['workflow:execute']);
// Return early to avoid expensive db query
if (!sharedWorkflowsIds.length) {
return res.json({ stopped: 0 });
}
// If workflowId is provided, validate user has access to it
if (workflowId && workflowId !== 'all' && !sharedWorkflowsIds.includes(workflowId)) {
throw new NotFoundError('Workflow not found or not accessible');
}
@@ -1,63 +0,0 @@
import {
AnnotationTagMapping,
ExecutionAnnotation,
ExecutionAnnotationRepository,
type AnnotationTagEntity,
} from '@n8n/db';
import { Container } from '@n8n/di';
export function mapAnnotationTags(tags: AnnotationTagEntity[]) {
return tags.map(({ id: tagId, name, createdAt, updatedAt }) => ({
id: tagId,
name,
createdAt,
updatedAt,
}));
}
export async function getExecutionTags(executionId: string) {
const annotation = await Container.get(ExecutionAnnotationRepository).findOne({
where: { execution: { id: executionId } },
relations: ['tags'],
});
return mapAnnotationTags(annotation?.tags ?? []);
}
export async function updateExecutionTags(
executionId: string,
newTagIds: string[],
): Promise<AnnotationTagEntity[]> {
const { manager: dbManager } = Container.get(ExecutionAnnotationRepository);
return await dbManager.transaction(async (transactionManager) => {
// Upsert annotation (create if it doesn't exist)
await transactionManager.upsert(ExecutionAnnotation, { execution: { id: executionId } }, [
'execution',
]);
const annotation = await transactionManager.findOneOrFail(ExecutionAnnotation, {
where: { execution: { id: executionId } },
});
// Overwrite tags
await transactionManager.delete(AnnotationTagMapping, {
annotationId: annotation.id,
});
if (newTagIds.length > 0) {
const tagMappings = newTagIds.map((tagId) => ({
annotationId: annotation.id,
tagId,
}));
await transactionManager.insert(AnnotationTagMapping, tagMappings);
}
// Fetch updated tags to return
const updatedAnnotation = await transactionManager.findOneOrFail(ExecutionAnnotation, {
where: { execution: { id: executionId } },
relations: ['tags'],
});
return updatedAnnotation.tags ?? [];
});
}
@@ -274,6 +274,8 @@ describe('DELETE /executions/:id', () => {
} = response.body;
expect(id).toBeDefined();
expect(typeof id).toBe('number');
expect(id).toBe(Number(execution.id));
expect(finished).toBe(true);
expect(mode).toEqual(execution.mode);
expect(retrySuccessId).toBeNull();
@@ -298,7 +300,7 @@ describe('POST /executions/:id/retry', () => {
test('should retry an execution', async () => {
const mockedExecutionResponse = { status: 'waiting' } as any;
const executionServiceSpy = vi
.mocked(Container.get(ExecutionService).retry)
.spyOn(Container.get(ExecutionService), 'retry')
.mockResolvedValue(mockedExecutionResponse);
const workflow = await createWorkflow({}, user1);
@@ -323,7 +325,7 @@ describe('POST /executions/:id/retry', () => {
test('should return 409 when trying to retry a queued execution', async () => {
const executionServiceSpy = vi
.mocked(Container.get(ExecutionService).retry)
.spyOn(Container.get(ExecutionService), 'retry')
.mockRejectedValue(new QueuedExecutionRetryError());
const workflow = await createWorkflow({}, user1);
@@ -341,7 +343,7 @@ describe('POST /executions/:id/retry', () => {
test('should return 409 when trying to retry an aborted execution without execution data', async () => {
const executionServiceSpy = vi
.mocked(Container.get(ExecutionService).retry)
.spyOn(Container.get(ExecutionService), 'retry')
.mockRejectedValue(new AbortedExecutionRetryError());
const workflow = await createWorkflow({}, user1);
@@ -367,7 +369,7 @@ describe('POST /executions/:id/retry', () => {
test('should return 404 when user only has read access to the workflow via project viewer role', async () => {
testServer.license.enable('feat:sharing');
const executionServiceSpy = vi.mocked(Container.get(ExecutionService).retry);
const executionServiceSpy = vi.spyOn(Container.get(ExecutionService), 'retry');
const project = await createTeamProject('project with viewer', owner);
await linkUserToProject(user1, project, 'project:viewer');
@@ -389,7 +391,7 @@ describe('POST /executions/:id/retry', () => {
const mockedExecutionResponse = { status: 'waiting' } as any;
const executionServiceSpy = vi
.mocked(Container.get(ExecutionService).retry)
.spyOn(Container.get(ExecutionService), 'retry')
.mockResolvedValue(mockedExecutionResponse);
const project = await createTeamProject('project with editor', owner);
@@ -408,7 +410,7 @@ describe('POST /executions/:id/retry', () => {
test('should return 409 when trying to retry a finished execution', async () => {
const executionServiceSpy = vi
.mocked(Container.get(ExecutionService).retry)
.spyOn(Container.get(ExecutionService), 'retry')
.mockRejectedValue(new ConflictError('The execution succeeded, so it cannot be retried.'));
const workflow = await createWorkflow({}, user1);
@@ -916,11 +918,13 @@ describe('POST /executions/:id/stop', () => {
finished: false,
status: 'canceled',
} as any;
const executionServiceSpy = vi.mocked(Container.get(ExecutionService).stop).mockResolvedValue({
...mockedStopResponse,
startedAt: new Date(mockedStopResponse.startedAt),
stoppedAt: new Date(mockedStopResponse.stoppedAt),
});
const executionServiceSpy = vi
.spyOn(Container.get(ExecutionService), 'stop')
.mockResolvedValue({
...mockedStopResponse,
startedAt: new Date(mockedStopResponse.startedAt),
stoppedAt: new Date(mockedStopResponse.stoppedAt),
});
const workflow = await createWorkflow({}, user1);
const execution = await createExecution({ status: 'running', finished: false }, workflow);
@@ -967,11 +971,13 @@ describe('POST /executions/:id/stop', () => {
finished: false,
status: 'canceled',
} as any;
const executionServiceSpy = vi.mocked(Container.get(ExecutionService).stop).mockResolvedValue({
...mockedStopResponse,
startedAt: new Date(mockedStopResponse.startedAt),
stoppedAt: new Date(mockedStopResponse.stoppedAt),
});
const executionServiceSpy = vi
.spyOn(Container.get(ExecutionService), 'stop')
.mockResolvedValue({
...mockedStopResponse,
startedAt: new Date(mockedStopResponse.startedAt),
stoppedAt: new Date(mockedStopResponse.stoppedAt),
});
const workflow = await createWorkflow({}, user1);
const execution = await createExecution({ status: 'running', finished: false }, workflow);
@@ -1010,7 +1016,7 @@ describe('POST /executions/stop', () => {
test('should stop multiple running executions', async () => {
const executionServiceSpy = vi
.mocked(Container.get(ExecutionService).stopMany)
.spyOn(Container.get(ExecutionService), 'stopMany')
.mockResolvedValue(3);
await createWorkflow({}, user1);
@@ -1036,7 +1042,7 @@ describe('POST /executions/stop', () => {
test('should stop executions filtered by workflowId', async () => {
const executionServiceSpy = vi
.mocked(Container.get(ExecutionService).stopMany)
.spyOn(Container.get(ExecutionService), 'stopMany')
.mockResolvedValue(2);
const workflow = await createWorkflow({}, user1);
@@ -1062,7 +1068,7 @@ describe('POST /executions/stop', () => {
test('should stop executions with date filters', async () => {
const executionServiceSpy = vi
.mocked(Container.get(ExecutionService).stopMany)
.spyOn(Container.get(ExecutionService), 'stopMany')
.mockResolvedValue(1);
await createWorkflow({}, user1);
@@ -1095,7 +1101,7 @@ describe('POST /executions/stop', () => {
const workflow = await createWorkflow({}, user1);
const executionServiceSpy = vi
.mocked(Container.get(ExecutionService).stopMany)
.spyOn(Container.get(ExecutionService), 'stopMany')
.mockResolvedValue(1);
// User1 should be able to stop executions in their own workflow
@@ -1111,7 +1117,7 @@ describe('POST /executions/stop', () => {
});
test('should return 0 stopped when user has no workflows', async () => {
const executionServiceSpy = vi.mocked(Container.get(ExecutionService).stopMany);
const executionServiceSpy = vi.spyOn(Container.get(ExecutionService), 'stopMany');
// Create a new user with no workflows
const userWithNoWorkflows = await createMemberWithApiKey();
@@ -1134,7 +1140,7 @@ describe('POST /executions/stop', () => {
await createManyWorkflows(2, {}, owner);
const executionServiceSpy = vi
.mocked(Container.get(ExecutionService).stopMany)
.spyOn(Container.get(ExecutionService), 'stopMany')
.mockResolvedValue(5);
const response = await authOwnerAgent
@@ -1151,7 +1157,7 @@ describe('POST /executions/stop', () => {
testServer.license.enable('feat:sharing');
const executionServiceSpy = vi
.mocked(Container.get(ExecutionService).stopMany)
.spyOn(Container.get(ExecutionService), 'stopMany')
.mockResolvedValue(2);
const [workflow1, workflow2] = await createManyWorkflows(2, {}, user1);
@@ -27,7 +27,6 @@ import { v4 as uuid } from 'uuid';
import { mock } from 'vitest-mock-extended';
import { AUTH_COOKIE_NAME } from '@/constants';
import { ExecutionService } from '@/executions/execution.service';
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import { Push } from '@/push';
@@ -48,7 +47,6 @@ export async function initActiveWorkflowManager() {
});
mockInstance(Push);
mockInstance(ExecutionService);
const { ActiveWorkflowManager } = await import('@/active-workflow-manager.js');
const activeWorkflowManager = Container.get(ActiveWorkflowManager);
await activeWorkflowManager.init();