feat(editor): Refactor and unify executions views (no-changelog) (#8538)

This commit is contained in:
Alex Grozav
2024-04-19 07:50:18 +02:00
committed by GitHub
parent eab01876ab
commit a3eea3ac5e
65 changed files with 3601 additions and 2960 deletions

View File

@@ -21,6 +21,9 @@ import { Logger } from '@/Logger';
@Service()
export class ActiveExecutions {
/**
* Active executions in the current process, not globally.
*/
private activeExecutions: {
[executionId: string]: IExecutingWorkflowData;
} = {};

View File

@@ -171,7 +171,7 @@ export interface IExecutionsListResponse {
estimated: boolean;
}
export interface IExecutionsStopData {
export interface ExecutionStopResult {
finished?: boolean;
mode: WorkflowExecuteMode;
startedAt: Date;

View File

@@ -4,7 +4,7 @@ import {
WorkflowOperationError,
} from 'n8n-workflow';
import { Container, Service } from 'typedi';
import type { IExecutionsStopData, IWorkflowExecutionDataProcess } from '@/Interfaces';
import type { ExecutionStopResult, IWorkflowExecutionDataProcess } from '@/Interfaces';
import { WorkflowRunner } from '@/WorkflowRunner';
import { ExecutionRepository } from '@db/repositories/execution.repository';
import { OwnershipService } from '@/services/ownership.service';
@@ -99,7 +99,7 @@ export class WaitTracker {
}
}
async stopExecution(executionId: string): Promise<IExecutionsStopData> {
async stopExecution(executionId: string): Promise<ExecutionStopResult> {
if (this.waitingExecutions[executionId] !== undefined) {
// The waiting execution was already scheduled to execute.
// So stop timer and remove.

View File

@@ -41,7 +41,22 @@ import { ExecutionEntity } from '../entities/ExecutionEntity';
import { ExecutionMetadata } from '../entities/ExecutionMetadata';
import { ExecutionDataRepository } from './executionData.repository';
import { Logger } from '@/Logger';
import type { GetManyActiveFilter } from '@/executions/execution.types';
import type { ExecutionSummaries } from '@/executions/execution.types';
import { PostgresLiveRowsRetrievalError } from '@/errors/postgres-live-rows-retrieval.error';
export interface IGetExecutionsQueryFilter {
id?: FindOperator<string> | string;
finished?: boolean;
mode?: string;
retryOf?: string;
retrySuccessId?: string;
status?: ExecutionStatus[];
workflowId?: string;
waitTill?: FindOperator<any> | boolean;
metadata?: Array<{ key: string; value: string }>;
startedAfter?: string;
startedBefore?: string;
}
function parseFiltersToQueryBuilder(
qb: SelectQueryBuilder<ExecutionEntity>,
@@ -82,6 +97,14 @@ function parseFiltersToQueryBuilder(
}
}
const lessThanOrEqual = (date: string): unknown => {
return LessThanOrEqual(DateUtils.mixedDateToUtcDatetimeString(new Date(date)));
};
const moreThanOrEqual = (date: string): unknown => {
return MoreThanOrEqual(DateUtils.mixedDateToUtcDatetimeString(new Date(date)));
};
@Service()
export class ExecutionRepository extends Repository<ExecutionEntity> {
private hardDeletionBatchSize = 100;
@@ -284,114 +307,6 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
}
}
async countExecutions(
filters: IGetExecutionsQueryFilter | undefined,
accessibleWorkflowIds: string[],
currentlyRunningExecutions: string[],
hasGlobalRead: boolean,
): Promise<{ count: number; estimated: boolean }> {
const dbType = config.getEnv('database.type');
if (dbType !== 'postgresdb' || (filters && Object.keys(filters).length > 0) || !hasGlobalRead) {
const query = this.createQueryBuilder('execution').andWhere(
'execution.workflowId IN (:...accessibleWorkflowIds)',
{ accessibleWorkflowIds },
);
if (currentlyRunningExecutions.length > 0) {
query.andWhere('execution.id NOT IN (:...currentlyRunningExecutions)', {
currentlyRunningExecutions,
});
}
parseFiltersToQueryBuilder(query, filters);
const count = await query.getCount();
return { count, estimated: false };
}
try {
// Get an estimate of rows count.
const estimateRowsNumberSql =
"SELECT n_live_tup FROM pg_stat_all_tables WHERE relname = 'execution_entity';";
const rows = (await this.query(estimateRowsNumberSql)) as Array<{ n_live_tup: string }>;
const estimate = parseInt(rows[0].n_live_tup, 10);
// If over 100k, return just an estimate.
if (estimate > 100_000) {
// if less than 100k, we get the real count as even a full
// table scan should not take so long.
return { count: estimate, estimated: true };
}
} catch (error) {
if (error instanceof Error) {
this.logger.warn(`Failed to get executions count from Postgres: ${error.message}`, {
error,
});
}
}
const count = await this.count({
where: {
workflowId: In(accessibleWorkflowIds),
},
});
return { count, estimated: false };
}
async searchExecutions(
filters: IGetExecutionsQueryFilter | undefined,
limit: number,
excludedExecutionIds: string[],
accessibleWorkflowIds: string[],
additionalFilters?: { lastId?: string; firstId?: string },
): Promise<ExecutionSummary[]> {
if (accessibleWorkflowIds.length === 0) {
return [];
}
const query = this.createQueryBuilder('execution')
.select([
'execution.id',
'execution.finished',
'execution.mode',
'execution.retryOf',
'execution.retrySuccessId',
'execution.status',
'execution.startedAt',
'execution.stoppedAt',
'execution.workflowId',
'execution.waitTill',
'workflow.name',
])
.innerJoin('execution.workflow', 'workflow')
.limit(limit)
// eslint-disable-next-line @typescript-eslint/naming-convention
.orderBy({ 'execution.id': 'DESC' })
.andWhere('execution.workflowId IN (:...accessibleWorkflowIds)', { accessibleWorkflowIds });
if (excludedExecutionIds.length > 0) {
query.andWhere('execution.id NOT IN (:...excludedExecutionIds)', { excludedExecutionIds });
}
if (additionalFilters?.lastId) {
query.andWhere('execution.id < :lastId', { lastId: additionalFilters.lastId });
}
if (additionalFilters?.firstId) {
query.andWhere('execution.id > :firstId', { firstId: additionalFilters.firstId });
}
parseFiltersToQueryBuilder(query, filters);
const executions = await query.getMany();
return executions.map((execution) => {
const { workflow, waitTill, ...rest } = execution;
return {
...rest,
waitTill: waitTill ?? undefined,
workflowName: workflow.name,
};
});
}
async deleteExecutionsByFilter(
filters: IGetExecutionsQueryFilter | undefined,
accessibleWorkflowIds: string[],
@@ -682,52 +597,151 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
});
}
async getManyActive(
activeExecutionIds: string[],
accessibleWorkflowIds: string[],
filter?: GetManyActiveFilter,
) {
const where: FindOptionsWhere<ExecutionEntity> = {
id: In(activeExecutionIds),
status: Not(In(['finished', 'stopped', 'error', 'crashed'])),
};
// ----------------------------------
// new API
// ----------------------------------
if (filter) {
const { workflowId, status, finished } = filter;
if (workflowId && accessibleWorkflowIds.includes(workflowId)) {
where.workflowId = workflowId;
} else {
where.workflowId = In(accessibleWorkflowIds);
}
if (status) {
// @ts-ignore
where.status = In(status);
}
if (finished !== undefined) {
where.finished = finished;
}
} else {
where.workflowId = In(accessibleWorkflowIds);
/**
* Fields to include in the summary of an execution when querying for many.
*/
private summaryFields = {
id: true,
workflowId: true,
mode: true,
retryOf: true,
status: true,
startedAt: true,
stoppedAt: true,
};
async findManyByRangeQuery(query: ExecutionSummaries.RangeQuery): Promise<ExecutionSummary[]> {
if (query?.accessibleWorkflowIds?.length === 0) {
throw new ApplicationError('Expected accessible workflow IDs');
}
return await this.findMultipleExecutions({
select: ['id', 'workflowId', 'mode', 'retryOf', 'startedAt', 'stoppedAt', 'status'],
order: { id: 'DESC' },
where,
});
const executions: ExecutionSummary[] = await this.toQueryBuilder(query).getRawMany();
return executions.map((execution) => this.toSummary(execution));
}
// @tech_debt: These transformations should not be needed
private toSummary(execution: {
id: number | string;
startedAt?: Date | string;
stoppedAt?: Date | string;
waitTill?: Date | string | null;
}): ExecutionSummary {
execution.id = execution.id.toString();
const normalizeDateString = (date: string) => {
if (date.includes(' ')) return date.replace(' ', 'T') + 'Z';
return date;
};
if (execution.startedAt) {
execution.startedAt =
execution.startedAt instanceof Date
? execution.startedAt.toISOString()
: normalizeDateString(execution.startedAt);
}
if (execution.waitTill) {
execution.waitTill =
execution.waitTill instanceof Date
? execution.waitTill.toISOString()
: normalizeDateString(execution.waitTill);
}
if (execution.stoppedAt) {
execution.stoppedAt =
execution.stoppedAt instanceof Date
? execution.stoppedAt.toISOString()
: normalizeDateString(execution.stoppedAt);
}
return execution as ExecutionSummary;
}
async fetchCount(query: ExecutionSummaries.CountQuery) {
return await this.toQueryBuilder(query).getCount();
}
async getLiveExecutionRowsOnPostgres() {
const tableName = `${config.getEnv('database.tablePrefix')}execution_entity`;
const pgSql = `SELECT n_live_tup as result FROM pg_stat_all_tables WHERE relname = '${tableName}';`;
try {
const rows = (await this.query(pgSql)) as Array<{ result: string }>;
if (rows.length !== 1) throw new PostgresLiveRowsRetrievalError(rows);
const [row] = rows;
return parseInt(row.result, 10);
} catch (error) {
if (error instanceof Error) this.logger.error(error.message, { error });
return -1;
}
}
private toQueryBuilder(query: ExecutionSummaries.Query) {
const {
accessibleWorkflowIds,
status,
finished,
workflowId,
startedBefore,
startedAfter,
metadata,
} = query;
const fields = Object.keys(this.summaryFields)
.concat(['waitTill', 'retrySuccessId'])
.map((key) => `execution.${key} AS "${key}"`)
.concat('workflow.name AS "workflowName"');
const qb = this.createQueryBuilder('execution')
.select(fields)
.innerJoin('execution.workflow', 'workflow')
.where('execution.workflowId IN (:...accessibleWorkflowIds)', { accessibleWorkflowIds });
if (query.kind === 'range') {
const { limit, firstId, lastId } = query.range;
qb.limit(limit);
if (firstId) qb.andWhere('execution.id > :firstId', { firstId });
if (lastId) qb.andWhere('execution.id < :lastId', { lastId });
if (query.order?.stoppedAt === 'DESC') {
qb.orderBy({ 'execution.stoppedAt': 'DESC' });
} else {
qb.orderBy({ 'execution.id': 'DESC' });
}
}
if (status) qb.andWhere('execution.status IN (:...status)', { status });
if (finished) qb.andWhere({ finished });
if (workflowId) qb.andWhere({ workflowId });
if (startedBefore) qb.andWhere({ startedAt: lessThanOrEqual(startedBefore) });
if (startedAfter) qb.andWhere({ startedAt: moreThanOrEqual(startedAfter) });
if (metadata) {
qb.leftJoin(ExecutionMetadata, 'md', 'md.executionId = execution.id');
for (const item of metadata) {
qb.andWhere('md.key = :key AND md.value = :value', item);
}
}
return qb;
}
async getAllIds() {
const executions = await this.find({ select: ['id'], order: { id: 'ASC' } });
return executions.map(({ id }) => id);
}
}
export interface IGetExecutionsQueryFilter {
id?: FindOperator<string> | string;
finished?: boolean;
mode?: string;
retryOf?: string;
retrySuccessId?: string;
status?: ExecutionStatus[];
workflowId?: string;
waitTill?: FindOperator<any> | boolean;
metadata?: Array<{ key: string; value: string }>;
startedAfter?: string;
startedBefore?: string;
}

View File

@@ -0,0 +1,7 @@
import { ApplicationError } from 'n8n-workflow';
export class PostgresLiveRowsRetrievalError extends ApplicationError {
constructor(rows: unknown) {
super('Failed to retrieve live execution rows in Postgres', { extra: { rows } });
}
}

View File

@@ -1,134 +0,0 @@
import { Service } from 'typedi';
import { ActiveExecutions } from '@/ActiveExecutions';
import { Logger } from '@/Logger';
import { Queue } from '@/Queue';
import { WaitTracker } from '@/WaitTracker';
import { ExecutionRepository } from '@db/repositories/execution.repository';
import { getStatusUsingPreviousExecutionStatusMethod } from '@/executions/executionHelpers';
import config from '@/config';
import type { ExecutionSummary } from 'n8n-workflow';
import type { IExecutionBase, IExecutionsCurrentSummary } from '@/Interfaces';
import type { GetManyActiveFilter } from './execution.types';
@Service()
export class ActiveExecutionService {
constructor(
private readonly logger: Logger,
private readonly queue: Queue,
private readonly activeExecutions: ActiveExecutions,
private readonly executionRepository: ExecutionRepository,
private readonly waitTracker: WaitTracker,
) {}
private readonly isRegularMode = config.getEnv('executions.mode') === 'regular';
async findOne(executionId: string, accessibleWorkflowIds: string[]) {
return await this.executionRepository.findIfAccessible(executionId, accessibleWorkflowIds);
}
private toSummary(execution: IExecutionsCurrentSummary | IExecutionBase): ExecutionSummary {
return {
id: execution.id,
workflowId: execution.workflowId ?? '',
mode: execution.mode,
retryOf: execution.retryOf !== null ? execution.retryOf : undefined,
startedAt: new Date(execution.startedAt),
status: execution.status,
stoppedAt: 'stoppedAt' in execution ? execution.stoppedAt : undefined,
};
}
// ----------------------------------
// regular mode
// ----------------------------------
async findManyInRegularMode(
filter: GetManyActiveFilter,
accessibleWorkflowIds: string[],
): Promise<ExecutionSummary[]> {
return this.activeExecutions
.getActiveExecutions()
.filter(({ workflowId }) => {
if (filter.workflowId && filter.workflowId !== workflowId) return false;
if (workflowId && !accessibleWorkflowIds.includes(workflowId)) return false;
return true;
})
.map((execution) => this.toSummary(execution))
.sort((a, b) => Number(b.id) - Number(a.id));
}
// ----------------------------------
// queue mode
// ----------------------------------
async findManyInQueueMode(filter: GetManyActiveFilter, accessibleWorkflowIds: string[]) {
const activeManualExecutionIds = this.activeExecutions
.getActiveExecutions()
.map((execution) => execution.id);
const activeJobs = await this.queue.getJobs(['active', 'waiting']);
const activeProductionExecutionIds = activeJobs.map((job) => job.data.executionId);
const activeExecutionIds = activeProductionExecutionIds.concat(activeManualExecutionIds);
if (activeExecutionIds.length === 0) return [];
const activeExecutions = await this.executionRepository.getManyActive(
activeExecutionIds,
accessibleWorkflowIds,
filter,
);
return activeExecutions.map((execution) => {
if (!execution.status) {
// @tech-debt Status should never be nullish
execution.status = getStatusUsingPreviousExecutionStatusMethod(execution);
}
return this.toSummary(execution);
});
}
async stop(execution: IExecutionBase) {
const result = await this.activeExecutions.stopExecution(execution.id);
if (result) {
return {
mode: result.mode,
startedAt: new Date(result.startedAt),
stoppedAt: result.stoppedAt ? new Date(result.stoppedAt) : undefined,
finished: result.finished,
status: result.status,
};
}
if (this.isRegularMode) return await this.waitTracker.stopExecution(execution.id);
// queue mode
try {
return await this.waitTracker.stopExecution(execution.id);
} catch {}
const activeJobs = await this.queue.getJobs(['active', 'waiting']);
const job = activeJobs.find(({ data }) => data.executionId === execution.id);
if (!job) {
this.logger.debug('Could not stop job because it is no longer in queue', {
jobId: execution.id,
});
} else {
await this.queue.stopJob(job);
}
return {
mode: execution.mode,
startedAt: new Date(execution.startedAt),
stoppedAt: execution.stoppedAt ? new Date(execution.stoppedAt) : undefined,
finished: execution.finished,
status: execution.status,
};
}
}

View File

@@ -2,16 +2,19 @@ import { Service } from 'typedi';
import { validate as jsonSchemaValidate } from 'jsonschema';
import type {
IWorkflowBase,
JsonObject,
ExecutionError,
INode,
IRunExecutionData,
WorkflowExecuteMode,
ExecutionStatus,
} from 'n8n-workflow';
import {
ApplicationError,
ExecutionStatusList,
Workflow,
WorkflowOperationError,
} from 'n8n-workflow';
import { ApplicationError, jsonParse, Workflow, WorkflowOperationError } from 'n8n-workflow';
import { ActiveExecutions } from '@/ActiveExecutions';
import config from '@/config';
import type {
ExecutionPayload,
IExecutionFlattedResponse,
@@ -21,9 +24,8 @@ import type {
} from '@/Interfaces';
import { NodeTypes } from '@/NodeTypes';
import { Queue } from '@/Queue';
import type { ExecutionRequest } from './execution.types';
import type { ExecutionRequest, ExecutionSummaries } from './execution.types';
import { WorkflowRunner } from '@/WorkflowRunner';
import * as GenericHelpers from '@/GenericHelpers';
import { getStatusUsingPreviousExecutionStatusMethod } from './executionHelpers';
import type { IGetExecutionsQueryFilter } from '@db/repositories/execution.repository';
import { ExecutionRepository } from '@db/repositories/execution.repository';
@@ -31,8 +33,11 @@ import { WorkflowRepository } from '@db/repositories/workflow.repository';
import { Logger } from '@/Logger';
import { InternalServerError } from '@/errors/response-errors/internal-server.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import config from '@/config';
import { WaitTracker } from '@/WaitTracker';
import type { ExecutionEntity } from '@/databases/entities/ExecutionEntity';
const schemaGetExecutionsQueryFilter = {
export const schemaGetExecutionsQueryFilter = {
$id: '/IGetExecutionsQueryFilter',
type: 'object',
properties: {
@@ -65,7 +70,9 @@ const schemaGetExecutionsQueryFilter = {
},
};
const allowedExecutionsQueryFilterFields = Object.keys(schemaGetExecutionsQueryFilter.properties);
export const allowedExecutionsQueryFilterFields = Object.keys(
schemaGetExecutionsQueryFilter.properties,
);
@Service()
export class ExecutionService {
@@ -76,83 +83,10 @@ export class ExecutionService {
private readonly executionRepository: ExecutionRepository,
private readonly workflowRepository: WorkflowRepository,
private readonly nodeTypes: NodeTypes,
private readonly waitTracker: WaitTracker,
private readonly workflowRunner: WorkflowRunner,
) {}
async findMany(req: ExecutionRequest.GetMany, sharedWorkflowIds: string[]) {
// parse incoming filter object and remove non-valid fields
let filter: IGetExecutionsQueryFilter | undefined = undefined;
if (req.query.filter) {
try {
const filterJson: JsonObject = jsonParse(req.query.filter);
if (filterJson) {
Object.keys(filterJson).map((key) => {
if (!allowedExecutionsQueryFilterFields.includes(key)) delete filterJson[key];
});
if (jsonSchemaValidate(filterJson, schemaGetExecutionsQueryFilter).valid) {
filter = filterJson as IGetExecutionsQueryFilter;
}
}
} catch (error) {
this.logger.error('Failed to parse filter', {
userId: req.user.id,
filter: req.query.filter,
});
throw new InternalServerError('Parameter "filter" contained invalid JSON string.');
}
}
// safeguard against querying workflowIds not shared with the user
const workflowId = filter?.workflowId?.toString();
if (workflowId !== undefined && !sharedWorkflowIds.includes(workflowId)) {
this.logger.verbose(
`User ${req.user.id} attempted to query non-shared workflow ${workflowId}`,
);
return {
count: 0,
estimated: false,
results: [],
};
}
const limit = req.query.limit
? parseInt(req.query.limit, 10)
: GenericHelpers.DEFAULT_EXECUTIONS_GET_ALL_LIMIT;
const executingWorkflowIds: string[] = [];
if (config.getEnv('executions.mode') === 'queue') {
const currentJobs = await this.queue.getJobs(['active', 'waiting']);
executingWorkflowIds.push(...currentJobs.map(({ data }) => data.executionId));
}
// We may have manual executions even with queue so we must account for these.
executingWorkflowIds.push(...this.activeExecutions.getActiveExecutions().map(({ id }) => id));
const { count, estimated } = await this.executionRepository.countExecutions(
filter,
sharedWorkflowIds,
executingWorkflowIds,
req.user.hasGlobalScope('workflow:list'),
);
const formattedExecutions = await this.executionRepository.searchExecutions(
filter,
limit,
executingWorkflowIds,
sharedWorkflowIds,
{
lastId: req.query.lastId,
firstId: req.query.firstId,
},
);
return {
count,
results: formattedExecutions,
estimated,
};
}
async findOne(
req: ExecutionRequest.GetOne,
sharedWorkflowIds: string[],
@@ -384,4 +318,112 @@ export class ExecutionService {
await this.executionRepository.createNewExecution(fullExecutionData);
}
// ----------------------------------
// new API
// ----------------------------------
private readonly isRegularMode = config.getEnv('executions.mode') === 'regular';
/**
* Find summaries of executions that satisfy a query.
*
* Return also the total count of all executions that satisfy the query,
* and whether the total is an estimate or not.
*/
async findRangeWithCount(query: ExecutionSummaries.RangeQuery) {
const results = await this.executionRepository.findManyByRangeQuery(query);
if (config.getEnv('database.type') === 'postgresdb') {
const liveRows = await this.executionRepository.getLiveExecutionRowsOnPostgres();
if (liveRows === -1) return { count: -1, estimated: false, results };
if (liveRows > 100_000) {
// likely too high to fetch exact count fast
return { count: liveRows, estimated: true, results };
}
}
const { range: _, ...countQuery } = query;
const count = await this.executionRepository.fetchCount({ ...countQuery, kind: 'count' });
return { results, count, estimated: false };
}
/**
* Find summaries of active and finished executions that satisfy a query.
*
* Return also the total count of all finished executions that satisfy the query,
* and whether the total is an estimate or not. Active executions are excluded
* from the total and count for pagination purposes.
*/
async findAllRunningAndLatest(query: ExecutionSummaries.RangeQuery) {
const currentlyRunningStatuses: ExecutionStatus[] = ['new', 'running'];
const allStatuses = new Set(ExecutionStatusList);
currentlyRunningStatuses.forEach((status) => allStatuses.delete(status));
const notRunningStatuses: ExecutionStatus[] = Array.from(allStatuses);
const [activeResult, finishedResult] = await Promise.all([
this.findRangeWithCount({ ...query, status: currentlyRunningStatuses }),
this.findRangeWithCount({
...query,
status: notRunningStatuses,
order: { stoppedAt: 'DESC' },
}),
]);
return {
results: activeResult.results.concat(finishedResult.results),
count: finishedResult.count,
estimated: finishedResult.estimated,
};
}
/**
* Stop an active execution.
*/
async stop(executionId: string) {
const execution = await this.executionRepository.findOneBy({ id: executionId });
if (!execution) throw new NotFoundError('Execution not found');
const stopResult = await this.activeExecutions.stopExecution(execution.id);
if (stopResult) return this.toExecutionStopResult(execution);
if (this.isRegularMode) {
return await this.waitTracker.stopExecution(execution.id);
}
// queue mode
try {
return await this.waitTracker.stopExecution(execution.id);
} catch {
// @TODO: Why are we swallowing this error in queue mode?
}
const activeJobs = await this.queue.getJobs(['active', 'waiting']);
const job = activeJobs.find(({ data }) => data.executionId === execution.id);
if (job) {
await this.queue.stopJob(job);
} else {
this.logger.debug('Job to stop no longer in queue', { jobId: execution.id });
}
return this.toExecutionStopResult(execution);
}
private toExecutionStopResult(execution: ExecutionEntity) {
return {
mode: execution.mode,
startedAt: new Date(execution.startedAt),
stoppedAt: execution.stoppedAt ? new Date(execution.stoppedAt) : undefined,
finished: execution.finished,
status: execution.status,
};
}
}

View File

@@ -5,7 +5,7 @@ import type { ExecutionStatus, IDataObject } from 'n8n-workflow';
export declare namespace ExecutionRequest {
namespace QueryParams {
type GetMany = {
filter: string; // '{ waitTill: string; finished: boolean, [other: string]: string }'
filter: string; // stringified `FilterFields`
limit: string;
lastId: string;
firstId: string;
@@ -28,7 +28,9 @@ export declare namespace ExecutionRequest {
};
}
type GetMany = AuthenticatedRequest<{}, {}, {}, QueryParams.GetMany>;
type GetMany = AuthenticatedRequest<{}, {}, {}, QueryParams.GetMany> & {
rangeQuery: ExecutionSummaries.RangeQuery; // parsed from query params
};
type GetOne = AuthenticatedRequest<RouteParams.ExecutionId, {}, {}, QueryParams.GetOne>;
@@ -37,12 +39,47 @@ export declare namespace ExecutionRequest {
type Retry = AuthenticatedRequest<RouteParams.ExecutionId, {}, { loadWorkflow: boolean }, {}>;
type Stop = AuthenticatedRequest<RouteParams.ExecutionId>;
type GetManyActive = AuthenticatedRequest<{}, {}, {}, { filter?: string }>;
}
export type GetManyActiveFilter = {
workflowId?: string;
status?: ExecutionStatus;
finished?: boolean;
};
export namespace ExecutionSummaries {
export type Query = RangeQuery | CountQuery;
export type RangeQuery = { kind: 'range' } & FilterFields &
AccessFields &
RangeFields &
OrderFields;
export type CountQuery = { kind: 'count' } & FilterFields & AccessFields;
type FilterFields = Partial<{
id: string;
finished: boolean;
mode: string;
retryOf: string;
retrySuccessId: string;
status: ExecutionStatus[];
workflowId: string;
waitTill: boolean;
metadata: Array<{ key: string; value: string }>;
startedAfter: string;
startedBefore: string;
}>;
type AccessFields = {
accessibleWorkflowIds?: string[];
};
type RangeFields = {
range: {
limit: number;
firstId?: string;
lastId?: string;
};
};
type OrderFields = {
order?: {
stoppedAt: 'DESC';
};
};
}

View File

@@ -23,8 +23,3 @@ export function isAdvancedExecutionFiltersEnabled(): boolean {
const license = Container.get(License);
return license.isAdvancedExecutionFiltersEnabled();
}
export function isDebugInEditorLicensed(): boolean {
const license = Container.get(License);
return license.isDebugInEditorLicensed();
}

View File

@@ -1,25 +1,19 @@
import type { GetManyActiveFilter } from './execution.types';
import { ExecutionRequest } from './execution.types';
import { ExecutionService } from './execution.service';
import { Get, Post, RestController } from '@/decorators';
import { EnterpriseExecutionsService } from './execution.service.ee';
import { License } from '@/License';
import { WorkflowSharingService } from '@/workflows/workflowSharing.service';
import type { User } from '@/databases/entities/User';
import config from '@/config';
import { jsonParse } from 'n8n-workflow';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { ActiveExecutionService } from './active-execution.service';
import { parseRangeQuery } from './parse-range-query.middleware';
import type { User } from '@/databases/entities/User';
@RestController('/executions')
export class ExecutionsController {
private readonly isQueueMode = config.getEnv('executions.mode') === 'queue';
constructor(
private readonly executionService: ExecutionService,
private readonly enterpriseExecutionService: EnterpriseExecutionsService,
private readonly workflowSharingService: WorkflowSharingService,
private readonly activeExecutionService: ActiveExecutionService,
private readonly license: License,
) {}
@@ -29,37 +23,32 @@ export class ExecutionsController {
: await this.workflowSharingService.getSharedWorkflowIds(user, ['workflow:owner']);
}
@Get('/')
@Get('/', { middlewares: [parseRangeQuery] })
async getMany(req: ExecutionRequest.GetMany) {
const workflowIds = await this.getAccessibleWorkflowIds(req.user);
const accessibleWorkflowIds = await this.getAccessibleWorkflowIds(req.user);
if (workflowIds.length === 0) return { count: 0, estimated: false, results: [] };
if (accessibleWorkflowIds.length === 0) {
return { count: 0, estimated: false, results: [] };
}
return await this.executionService.findMany(req, workflowIds);
}
const { rangeQuery: query } = req;
@Get('/active')
async getActive(req: ExecutionRequest.GetManyActive) {
const filter = req.query.filter?.length ? jsonParse<GetManyActiveFilter>(req.query.filter) : {};
if (query.workflowId && !accessibleWorkflowIds.includes(query.workflowId)) {
return { count: 0, estimated: false, results: [] };
}
const workflowIds = await this.getAccessibleWorkflowIds(req.user);
query.accessibleWorkflowIds = accessibleWorkflowIds;
return this.isQueueMode
? await this.activeExecutionService.findManyInQueueMode(filter, workflowIds)
: await this.activeExecutionService.findManyInRegularMode(filter, workflowIds);
}
if (!this.license.isAdvancedExecutionFiltersEnabled()) delete query.metadata;
@Post('/active/:id/stop')
async stop(req: ExecutionRequest.Stop) {
const workflowIds = await this.getAccessibleWorkflowIds(req.user);
const noStatus = !query.status || query.status.length === 0;
const noRange = !query.range.lastId || !query.range.firstId;
if (workflowIds.length === 0) throw new NotFoundError('Execution not found');
if (noStatus && noRange) {
return await this.executionService.findAllRunningAndLatest(query);
}
const execution = await this.activeExecutionService.findOne(req.params.id, workflowIds);
if (!execution) throw new NotFoundError('Execution not found');
return await this.activeExecutionService.stop(execution);
return await this.executionService.findRangeWithCount(query);
}
@Get('/:id')
@@ -73,6 +62,15 @@ export class ExecutionsController {
: await this.executionService.findOne(req, workflowIds);
}
@Post('/:id/stop')
async stop(req: ExecutionRequest.Stop) {
const workflowIds = await this.getAccessibleWorkflowIds(req.user);
if (workflowIds.length === 0) throw new NotFoundError('Execution not found');
return await this.executionService.stop(req.params.id);
}
@Post('/:id/retry')
async retry(req: ExecutionRequest.Retry) {
const workflowIds = await this.getAccessibleWorkflowIds(req.user);

View File

@@ -0,0 +1,56 @@
import * as ResponseHelper from '@/ResponseHelper';
import type { NextFunction, Response } from 'express';
import type { ExecutionRequest } from './execution.types';
import type { JsonObject } from 'n8n-workflow';
import { ApplicationError, jsonParse } from 'n8n-workflow';
import {
allowedExecutionsQueryFilterFields as ALLOWED_FILTER_FIELDS,
schemaGetExecutionsQueryFilter as SCHEMA,
} from './execution.service';
import { validate } from 'jsonschema';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
const isValid = (arg: JsonObject) => validate(arg, SCHEMA).valid;
/**
* Middleware to parse the query string in a request to retrieve a range of execution summaries.
*/
export const parseRangeQuery = (
req: ExecutionRequest.GetMany,
res: Response,
next: NextFunction,
) => {
const { limit, firstId, lastId } = req.query;
try {
req.rangeQuery = {
kind: 'range',
range: { limit: limit ? Math.min(parseInt(limit, 10), 100) : 20 },
};
if (firstId) req.rangeQuery.range.firstId = firstId;
if (lastId) req.rangeQuery.range.lastId = lastId;
if (req.query.filter) {
const jsonFilter = jsonParse<JsonObject>(req.query.filter, {
errorMessage: 'Failed to parse query string',
});
for (const key of Object.keys(jsonFilter)) {
if (!ALLOWED_FILTER_FIELDS.includes(key)) delete jsonFilter[key];
}
if (jsonFilter.waitTill) jsonFilter.waitTill = Boolean(jsonFilter.waitTill);
if (!isValid(jsonFilter)) throw new ApplicationError('Query does not match schema');
req.rangeQuery = { ...req.rangeQuery, ...jsonFilter };
}
next();
} catch (error) {
if (error instanceof Error) {
ResponseHelper.sendErrorResponse(res, new BadRequestError(error.message));
}
}
};

View File

@@ -0,0 +1,411 @@
import { ExecutionRepository } from '@/databases/repositories/execution.repository';
import { ExecutionService } from '@/executions/execution.service';
import { mock } from 'jest-mock-extended';
import Container from 'typedi';
import { createWorkflow } from './shared/db/workflows';
import { createExecution } from './shared/db/executions';
import * as testDb from './shared/testDb';
import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
import type { ExecutionSummaries } from '@/executions/execution.types';
import { ExecutionMetadataRepository } from '@/databases/repositories/executionMetadata.repository';
describe('ExecutionService', () => {
let executionService: ExecutionService;
let executionRepository: ExecutionRepository;
beforeAll(async () => {
await testDb.init();
executionRepository = Container.get(ExecutionRepository);
executionService = new ExecutionService(
mock(),
mock(),
mock(),
executionRepository,
Container.get(WorkflowRepository),
mock(),
mock(),
mock(),
);
});
afterEach(async () => {
await testDb.truncate(['Execution']);
});
afterAll(async () => {
await testDb.terminate();
});
describe('findRangeWithCount', () => {
test('should return execution summaries', async () => {
const workflow = await createWorkflow();
await Promise.all([
createExecution({ status: 'success' }, workflow),
createExecution({ status: 'success' }, workflow),
]);
const query: ExecutionSummaries.RangeQuery = {
kind: 'range',
status: ['success'],
range: { limit: 20 },
accessibleWorkflowIds: [workflow.id],
};
const output = await executionService.findRangeWithCount(query);
const summaryShape = {
id: expect.any(String),
workflowId: expect.any(String),
mode: expect.any(String),
retryOf: null,
status: expect.any(String),
startedAt: expect.any(String),
stoppedAt: expect.any(String),
waitTill: null,
retrySuccessId: null,
workflowName: expect.any(String),
};
expect(output.count).toBe(2);
expect(output.estimated).toBe(false);
expect(output.results).toEqual([summaryShape, summaryShape]);
});
test('should limit executions', async () => {
const workflow = await createWorkflow();
await Promise.all([
createExecution({ status: 'success' }, workflow),
createExecution({ status: 'success' }, workflow),
createExecution({ status: 'success' }, workflow),
]);
const query: ExecutionSummaries.RangeQuery = {
kind: 'range',
status: ['success'],
range: { limit: 2 },
accessibleWorkflowIds: [workflow.id],
};
const output = await executionService.findRangeWithCount(query);
expect(output.count).toBe(3);
expect(output.estimated).toBe(false);
expect(output.results).toHaveLength(2);
});
test('should retrieve executions before `lastId`, excluding it', async () => {
const workflow = await createWorkflow();
await Promise.all([
createExecution({ status: 'success' }, workflow),
createExecution({ status: 'success' }, workflow),
createExecution({ status: 'success' }, workflow),
createExecution({ status: 'success' }, workflow),
]);
const [firstId, secondId] = await executionRepository.getAllIds();
const query: ExecutionSummaries.RangeQuery = {
kind: 'range',
range: { limit: 20, lastId: secondId },
accessibleWorkflowIds: [workflow.id],
};
const output = await executionService.findRangeWithCount(query);
expect(output.count).toBe(4);
expect(output.estimated).toBe(false);
expect(output.results).toEqual(
expect.arrayContaining([expect.objectContaining({ id: firstId })]),
);
});
test('should retrieve executions after `firstId`, excluding it', async () => {
const workflow = await createWorkflow();
await Promise.all([
createExecution({ status: 'success' }, workflow),
createExecution({ status: 'success' }, workflow),
createExecution({ status: 'success' }, workflow),
createExecution({ status: 'success' }, workflow),
]);
const [firstId, secondId, thirdId, fourthId] = await executionRepository.getAllIds();
const query: ExecutionSummaries.RangeQuery = {
kind: 'range',
range: { limit: 20, firstId },
accessibleWorkflowIds: [workflow.id],
};
const output = await executionService.findRangeWithCount(query);
expect(output.count).toBe(4);
expect(output.estimated).toBe(false);
expect(output.results).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: fourthId }),
expect.objectContaining({ id: thirdId }),
expect.objectContaining({ id: secondId }),
]),
);
});
test('should filter executions by `status`', async () => {
const workflow = await createWorkflow();
await Promise.all([
createExecution({ status: 'success' }, workflow),
createExecution({ status: 'success' }, workflow),
createExecution({ status: 'waiting' }, workflow),
createExecution({ status: 'waiting' }, workflow),
]);
const query: ExecutionSummaries.RangeQuery = {
kind: 'range',
status: ['success'],
range: { limit: 20 },
accessibleWorkflowIds: [workflow.id],
};
const output = await executionService.findRangeWithCount(query);
expect(output.count).toBe(2);
expect(output.estimated).toBe(false);
expect(output.results).toEqual([
expect.objectContaining({ status: 'success' }),
expect.objectContaining({ status: 'success' }),
]);
});
test('should filter executions by `workflowId`', async () => {
const firstWorkflow = await createWorkflow();
const secondWorkflow = await createWorkflow();
await Promise.all([
createExecution({ status: 'success' }, firstWorkflow),
createExecution({ status: 'success' }, secondWorkflow),
createExecution({ status: 'success' }, secondWorkflow),
createExecution({ status: 'success' }, secondWorkflow),
]);
const query: ExecutionSummaries.RangeQuery = {
kind: 'range',
range: { limit: 20 },
workflowId: firstWorkflow.id,
accessibleWorkflowIds: [firstWorkflow.id, secondWorkflow.id],
};
const output = await executionService.findRangeWithCount(query);
expect(output.count).toBe(1);
expect(output.estimated).toBe(false);
expect(output.results).toEqual(
expect.arrayContaining([expect.objectContaining({ workflowId: firstWorkflow.id })]),
);
});
test('should filter executions by `startedBefore`', async () => {
const workflow = await createWorkflow();
await Promise.all([
createExecution({ startedAt: new Date('2020-06-01') }, workflow),
createExecution({ startedAt: new Date('2020-12-31') }, workflow),
]);
const query: ExecutionSummaries.RangeQuery = {
kind: 'range',
range: { limit: 20 },
startedBefore: '2020-07-01',
accessibleWorkflowIds: [workflow.id],
};
const output = await executionService.findRangeWithCount(query);
expect(output.count).toBe(1);
expect(output.estimated).toBe(false);
expect(output.results).toEqual([
expect.objectContaining({ startedAt: '2020-06-01T00:00:00.000Z' }),
]);
});
test('should filter executions by `startedAfter`', async () => {
const workflow = await createWorkflow();
await Promise.all([
createExecution({ startedAt: new Date('2020-06-01') }, workflow),
createExecution({ startedAt: new Date('2020-12-31') }, workflow),
]);
const query: ExecutionSummaries.RangeQuery = {
kind: 'range',
range: { limit: 20 },
startedAfter: '2020-07-01',
accessibleWorkflowIds: [workflow.id],
};
const output = await executionService.findRangeWithCount(query);
expect(output.count).toBe(1);
expect(output.estimated).toBe(false);
expect(output.results).toEqual([
expect.objectContaining({ startedAt: '2020-12-31T00:00:00.000Z' }),
]);
});
test('should exclude executions by inaccessible `workflowId`', async () => {
const accessibleWorkflow = await createWorkflow();
const inaccessibleWorkflow = await createWorkflow();
await Promise.all([
createExecution({ status: 'success' }, accessibleWorkflow),
createExecution({ status: 'success' }, inaccessibleWorkflow),
createExecution({ status: 'success' }, inaccessibleWorkflow),
createExecution({ status: 'success' }, inaccessibleWorkflow),
]);
const query: ExecutionSummaries.RangeQuery = {
kind: 'range',
range: { limit: 20 },
workflowId: inaccessibleWorkflow.id,
accessibleWorkflowIds: [accessibleWorkflow.id],
};
const output = await executionService.findRangeWithCount(query);
expect(output.count).toBe(0);
expect(output.estimated).toBe(false);
expect(output.results).toEqual([]);
});
test('should support advanced filters', async () => {
const workflow = await createWorkflow();
await Promise.all([createExecution({}, workflow), createExecution({}, workflow)]);
const [firstId, secondId] = await executionRepository.getAllIds();
const executionMetadataRepository = Container.get(ExecutionMetadataRepository);
await executionMetadataRepository.save({
key: 'key1',
value: 'value1',
execution: { id: firstId },
});
await executionMetadataRepository.save({
key: 'key2',
value: 'value2',
execution: { id: secondId },
});
const query: ExecutionSummaries.RangeQuery = {
kind: 'range',
range: { limit: 20 },
metadata: [{ key: 'key1', value: 'value1' }],
accessibleWorkflowIds: [workflow.id],
};
const output = await executionService.findRangeWithCount(query);
expect(output.count).toBe(1);
expect(output.estimated).toBe(false);
expect(output.results).toEqual([expect.objectContaining({ id: firstId })]);
});
});
describe('findAllActiveAndLatestFinished', () => {
test('should return all active and latest 20 finished executions', async () => {
const workflow = await createWorkflow();
const totalFinished = 21;
await Promise.all([
createExecution({ status: 'running' }, workflow),
createExecution({ status: 'running' }, workflow),
createExecution({ status: 'running' }, workflow),
...new Array(totalFinished)
.fill(null)
.map(async () => await createExecution({ status: 'success' }, workflow)),
]);
const query: ExecutionSummaries.RangeQuery = {
kind: 'range',
range: { limit: 20 },
accessibleWorkflowIds: [workflow.id],
};
const output = await executionService.findAllRunningAndLatest(query);
expect(output.results).toHaveLength(23); // 3 active + 20 finished (excludes 21st)
expect(output.count).toBe(totalFinished); // 21 finished, excludes active
expect(output.estimated).toBe(false);
});
test('should handle zero active executions', async () => {
const workflow = await createWorkflow();
const totalFinished = 5;
await Promise.all(
new Array(totalFinished)
.fill(null)
.map(async () => await createExecution({ status: 'success' }, workflow)),
);
const query: ExecutionSummaries.RangeQuery = {
kind: 'range',
range: { limit: 20 },
accessibleWorkflowIds: [workflow.id],
};
const output = await executionService.findAllRunningAndLatest(query);
expect(output.results).toHaveLength(totalFinished); // 5 finished
expect(output.count).toBe(totalFinished); // 5 finished, excludes active
expect(output.estimated).toBe(false);
});
test('should handle zero finished executions', async () => {
const workflow = await createWorkflow();
await Promise.all([
createExecution({ status: 'running' }, workflow),
createExecution({ status: 'running' }, workflow),
createExecution({ status: 'running' }, workflow),
]);
const query: ExecutionSummaries.RangeQuery = {
kind: 'range',
range: { limit: 20 },
accessibleWorkflowIds: [workflow.id],
};
const output = await executionService.findAllRunningAndLatest(query);
expect(output.results).toHaveLength(3); // 3 finished
expect(output.count).toBe(0); // 0 finished, excludes active
expect(output.estimated).toBe(false);
});
test('should handle zero executions', async () => {
const workflow = await createWorkflow();
const query: ExecutionSummaries.RangeQuery = {
kind: 'range',
range: { limit: 20 },
accessibleWorkflowIds: [workflow.id],
};
const output = await executionService.findAllRunningAndLatest(query);
expect(output.results).toHaveLength(0);
expect(output.count).toBe(0);
expect(output.estimated).toBe(false);
});
});
});

View File

@@ -1,127 +0,0 @@
import { mock, mockFn } from 'jest-mock-extended';
import { ActiveExecutionService } from '@/executions/active-execution.service';
import config from '@/config';
import type { ExecutionRepository } from '@db/repositories/execution.repository';
import type { ActiveExecutions } from '@/ActiveExecutions';
import type { Job, Queue } from '@/Queue';
import type { IExecutionBase, IExecutionsCurrentSummary } from '@/Interfaces';
import type { WaitTracker } from '@/WaitTracker';
describe('ActiveExecutionsService', () => {
const queue = mock<Queue>();
const activeExecutions = mock<ActiveExecutions>();
const executionRepository = mock<ExecutionRepository>();
const waitTracker = mock<WaitTracker>();
const jobIds = ['j1', 'j2'];
const jobs = jobIds.map((executionId) => mock<Job>({ data: { executionId } }));
const activeExecutionService = new ActiveExecutionService(
mock(),
queue,
activeExecutions,
executionRepository,
waitTracker,
);
const getEnv = mockFn<(typeof config)['getEnv']>();
config.getEnv = getEnv;
beforeEach(() => {
jest.clearAllMocks();
});
describe('stop()', () => {
describe('in regular mode', () => {
getEnv.calledWith('executions.mode').mockReturnValue('regular');
it('should call `ActiveExecutions.stopExecution()`', async () => {
const execution = mock<IExecutionBase>({ id: '123' });
await activeExecutionService.stop(execution);
expect(activeExecutions.stopExecution).toHaveBeenCalledWith(execution.id);
});
it('should call `WaitTracker.stopExecution()` if `ActiveExecutions.stopExecution()` found no execution', async () => {
activeExecutions.stopExecution.mockResolvedValue(undefined);
const execution = mock<IExecutionBase>({ id: '123' });
await activeExecutionService.stop(execution);
expect(waitTracker.stopExecution).toHaveBeenCalledWith(execution.id);
});
});
describe('in queue mode', () => {
it('should call `ActiveExecutions.stopExecution()`', async () => {
const execution = mock<IExecutionBase>({ id: '123' });
await activeExecutionService.stop(execution);
expect(activeExecutions.stopExecution).toHaveBeenCalledWith(execution.id);
});
it('should call `WaitTracker.stopExecution` if `ActiveExecutions.stopExecution()` found no execution', async () => {
activeExecutions.stopExecution.mockResolvedValue(undefined);
const execution = mock<IExecutionBase>({ id: '123' });
await activeExecutionService.stop(execution);
expect(waitTracker.stopExecution).toHaveBeenCalledWith(execution.id);
});
});
});
describe('findManyInQueueMode()', () => {
it('should query for active jobs, waiting jobs, and in-memory executions', async () => {
const sharedWorkflowIds = ['123'];
const filter = {};
const executionIds = ['e1', 'e2'];
const summaries = executionIds.map((e) => mock<IExecutionsCurrentSummary>({ id: e }));
activeExecutions.getActiveExecutions.mockReturnValue(summaries);
queue.getJobs.mockResolvedValue(jobs);
executionRepository.findMultipleExecutions.mockResolvedValue([]);
executionRepository.getManyActive.mockResolvedValue([]);
await activeExecutionService.findManyInQueueMode(filter, sharedWorkflowIds);
expect(queue.getJobs).toHaveBeenCalledWith(['active', 'waiting']);
expect(executionRepository.getManyActive).toHaveBeenCalledWith(
jobIds.concat(executionIds),
sharedWorkflowIds,
filter,
);
});
});
describe('findManyInRegularMode()', () => {
it('should return summaries of in-memory executions', async () => {
const sharedWorkflowIds = ['123'];
const filter = {};
const executionIds = ['e1', 'e2'];
const summaries = executionIds.map((e) =>
mock<IExecutionsCurrentSummary>({ id: e, workflowId: '123', status: 'running' }),
);
activeExecutions.getActiveExecutions.mockReturnValue(summaries);
const result = await activeExecutionService.findManyInRegularMode(filter, sharedWorkflowIds);
expect(result).toEqual([
expect.objectContaining({
id: 'e1',
workflowId: '123',
status: 'running',
}),
expect.objectContaining({
id: 'e2',
workflowId: '123',
status: 'running',
}),
]);
});
});
});

View File

@@ -1,94 +1,145 @@
import { mock, mockFn } from 'jest-mock-extended';
import config from '@/config';
import { mock } from 'jest-mock-extended';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { ExecutionsController } from '@/executions/executions.controller';
import { License } from '@/License';
import { mockInstance } from '../../shared/mocking';
import type { IExecutionBase } from '@/Interfaces';
import type { ActiveExecutionService } from '@/executions/active-execution.service';
import type { ExecutionRequest } from '@/executions/execution.types';
import type { ExecutionRequest, ExecutionSummaries } from '@/executions/execution.types';
import type { ExecutionService } from '@/executions/execution.service';
import type { WorkflowSharingService } from '@/workflows/workflowSharing.service';
describe('ExecutionsController', () => {
const getEnv = mockFn<(typeof config)['getEnv']>();
config.getEnv = getEnv;
mockInstance(License);
const activeExecutionService = mock<ActiveExecutionService>();
const executionService = mock<ExecutionService>();
const workflowSharingService = mock<WorkflowSharingService>();
const req = mock<ExecutionRequest.GetManyActive>({ query: { filter: '{}' } });
const executionsController = new ExecutionsController(
executionService,
mock(),
workflowSharingService,
mock(),
);
beforeEach(() => {
jest.clearAllMocks();
});
describe('getActive()', () => {
workflowSharingService.getSharedWorkflowIds.mockResolvedValue(['123']);
describe('getMany', () => {
const NO_EXECUTIONS = { count: 0, estimated: false, results: [] };
it('should call `ActiveExecutionService.findManyInQueueMode()`', async () => {
getEnv.calledWith('executions.mode').mockReturnValue('queue');
const QUERIES_WITH_EITHER_STATUS_OR_RANGE: ExecutionSummaries.RangeQuery[] = [
{
kind: 'range',
workflowId: undefined,
status: undefined,
range: { lastId: '999', firstId: '111', limit: 20 },
},
{
kind: 'range',
workflowId: undefined,
status: [],
range: { lastId: '999', firstId: '111', limit: 20 },
},
{
kind: 'range',
workflowId: undefined,
status: ['waiting'],
range: { lastId: undefined, firstId: undefined, limit: 20 },
},
{
kind: 'range',
workflowId: undefined,
status: [],
range: { lastId: '999', firstId: '111', limit: 20 },
},
];
await new ExecutionsController(
mock(),
mock(),
workflowSharingService,
activeExecutionService,
mock(),
).getActive(req);
const QUERIES_NEITHER_STATUS_NOR_RANGE_PROVIDED: ExecutionSummaries.RangeQuery[] = [
{
kind: 'range',
workflowId: undefined,
status: undefined,
range: { lastId: undefined, firstId: undefined, limit: 20 },
},
{
kind: 'range',
workflowId: undefined,
status: [],
range: { lastId: undefined, firstId: undefined, limit: 20 },
},
];
expect(activeExecutionService.findManyInQueueMode).toHaveBeenCalled();
expect(activeExecutionService.findManyInRegularMode).not.toHaveBeenCalled();
describe('if either status or range provided', () => {
test.each(QUERIES_WITH_EITHER_STATUS_OR_RANGE)(
'should fetch executions per query',
async (rangeQuery) => {
workflowSharingService.getSharedWorkflowIds.mockResolvedValue(['123']);
executionService.findAllRunningAndLatest.mockResolvedValue(NO_EXECUTIONS);
const req = mock<ExecutionRequest.GetMany>({ rangeQuery });
await executionsController.getMany(req);
expect(executionService.findAllRunningAndLatest).not.toHaveBeenCalled();
expect(executionService.findRangeWithCount).toHaveBeenCalledWith(rangeQuery);
},
);
});
it('should call `ActiveExecutionService.findManyInRegularMode()`', async () => {
getEnv.calledWith('executions.mode').mockReturnValue('regular');
describe('if neither status nor range provided', () => {
test.each(QUERIES_NEITHER_STATUS_NOR_RANGE_PROVIDED)(
'should fetch executions per query',
async (rangeQuery) => {
workflowSharingService.getSharedWorkflowIds.mockResolvedValue(['123']);
executionService.findAllRunningAndLatest.mockResolvedValue(NO_EXECUTIONS);
await new ExecutionsController(
mock(),
mock(),
workflowSharingService,
activeExecutionService,
mock(),
).getActive(req);
const req = mock<ExecutionRequest.GetMany>({ rangeQuery });
expect(activeExecutionService.findManyInQueueMode).not.toHaveBeenCalled();
expect(activeExecutionService.findManyInRegularMode).toHaveBeenCalled();
await executionsController.getMany(req);
expect(executionService.findAllRunningAndLatest).toHaveBeenCalled();
expect(executionService.findRangeWithCount).not.toHaveBeenCalled();
},
);
});
describe('if both status and range provided', () => {
it('should fetch executions per query', async () => {
workflowSharingService.getSharedWorkflowIds.mockResolvedValue(['123']);
executionService.findAllRunningAndLatest.mockResolvedValue(NO_EXECUTIONS);
const rangeQuery: ExecutionSummaries.RangeQuery = {
kind: 'range',
workflowId: undefined,
status: ['success'],
range: { lastId: '999', firstId: '111', limit: 5 },
};
const req = mock<ExecutionRequest.GetMany>({ rangeQuery });
await executionsController.getMany(req);
expect(executionService.findAllRunningAndLatest).not.toHaveBeenCalled();
expect(executionService.findRangeWithCount).toHaveBeenCalledWith(rangeQuery);
});
});
});
describe('stop()', () => {
const req = mock<ExecutionRequest.Stop>({ params: { id: '999' } });
const execution = mock<IExecutionBase>();
describe('stop', () => {
const executionId = '999';
const req = mock<ExecutionRequest.Stop>({ params: { id: executionId } });
it('should 404 when execution is not found or inaccessible for user', async () => {
activeExecutionService.findOne.mockResolvedValue(undefined);
it('should 404 when execution is inaccessible for user', async () => {
workflowSharingService.getSharedWorkflowIds.mockResolvedValue([]);
const promise = new ExecutionsController(
mock(),
mock(),
workflowSharingService,
activeExecutionService,
mock(),
).stop(req);
const promise = executionsController.stop(req);
await expect(promise).rejects.toThrow(NotFoundError);
expect(activeExecutionService.findOne).toHaveBeenCalledWith('999', ['123']);
expect(executionService.stop).not.toHaveBeenCalled();
});
it('should call `ActiveExecutionService.stop()`', async () => {
getEnv.calledWith('executions.mode').mockReturnValue('regular');
activeExecutionService.findOne.mockResolvedValue(execution);
it('should call ask for an execution to be stopped', async () => {
workflowSharingService.getSharedWorkflowIds.mockResolvedValue(['123']);
await new ExecutionsController(
mock(),
mock(),
workflowSharingService,
activeExecutionService,
mock(),
).stop(req);
await executionsController.stop(req);
expect(activeExecutionService.stop).toHaveBeenCalled();
expect(executionService.stop).toHaveBeenCalledWith(executionId);
});
});
});

View File

@@ -0,0 +1,178 @@
import { parseRangeQuery } from '@/executions/parse-range-query.middleware';
import { mock } from 'jest-mock-extended';
import type { NextFunction } from 'express';
import type * as express from 'express';
import type { ExecutionRequest } from '@/executions/execution.types';
describe('`parseRangeQuery` middleware', () => {
const res = mock<express.Response>({
status: () => mock<express.Response>({ json: jest.fn() }),
});
const nextFn: NextFunction = jest.fn();
beforeEach(() => {
jest.restoreAllMocks();
});
describe('errors', () => {
test('should fail on invalid JSON', () => {
const statusSpy = jest.spyOn(res, 'status');
const req = mock<ExecutionRequest.GetMany>({
query: {
filter: '{ "status": ["waiting }',
limit: undefined,
firstId: undefined,
lastId: undefined,
},
});
parseRangeQuery(req, res, nextFn);
expect(nextFn).toBeCalledTimes(0);
expect(statusSpy).toBeCalledWith(400);
});
test('should fail on invalid schema', () => {
const statusSpy = jest.spyOn(res, 'status');
const req = mock<ExecutionRequest.GetMany>({
query: {
filter: '{ "status": 123 }',
limit: undefined,
firstId: undefined,
lastId: undefined,
},
});
parseRangeQuery(req, res, nextFn);
expect(nextFn).toBeCalledTimes(0);
expect(statusSpy).toBeCalledWith(400);
});
});
describe('filter', () => {
test('should parse status and mode fields', () => {
const req = mock<ExecutionRequest.GetMany>({
query: {
filter: '{ "status": ["waiting"], "mode": "manual" }',
limit: undefined,
firstId: undefined,
lastId: undefined,
},
});
parseRangeQuery(req, res, nextFn);
expect(req.rangeQuery.status).toEqual(['waiting']);
expect(req.rangeQuery.mode).toEqual('manual');
expect(nextFn).toBeCalledTimes(1);
});
test('should parse date-related fields', () => {
const req = mock<ExecutionRequest.GetMany>({
query: {
filter:
'{ "startedBefore": "2021-01-01", "startedAfter": "2020-01-01", "waitTill": "true" }',
limit: undefined,
firstId: undefined,
lastId: undefined,
},
});
parseRangeQuery(req, res, nextFn);
expect(req.rangeQuery.startedBefore).toBe('2021-01-01');
expect(req.rangeQuery.startedAfter).toBe('2020-01-01');
expect(req.rangeQuery.waitTill).toBe(true);
expect(nextFn).toBeCalledTimes(1);
});
test('should parse ID-related fields', () => {
const req = mock<ExecutionRequest.GetMany>({
query: {
filter: '{ "id": "123", "workflowId": "456" }',
limit: undefined,
firstId: undefined,
lastId: undefined,
},
});
parseRangeQuery(req, res, nextFn);
expect(req.rangeQuery.id).toBe('123');
expect(req.rangeQuery.workflowId).toBe('456');
expect(nextFn).toBeCalledTimes(1);
});
test('should delete invalid fields', () => {
const req = mock<ExecutionRequest.GetMany>({
query: {
filter: '{ "id": "123", "test": "789" }',
limit: undefined,
firstId: undefined,
lastId: undefined,
},
});
parseRangeQuery(req, res, nextFn);
expect(req.rangeQuery.id).toBe('123');
expect('test' in req.rangeQuery).toBe(false);
expect(nextFn).toBeCalledTimes(1);
});
});
describe('range', () => {
test('should parse first and last IDs', () => {
const req = mock<ExecutionRequest.GetMany>({
query: {
filter: undefined,
limit: undefined,
firstId: '111',
lastId: '999',
},
});
parseRangeQuery(req, res, nextFn);
expect(req.rangeQuery.range.firstId).toBe('111');
expect(req.rangeQuery.range.lastId).toBe('999');
expect(nextFn).toBeCalledTimes(1);
});
test('should parse limit', () => {
const req = mock<ExecutionRequest.GetMany>({
query: {
filter: undefined,
limit: '50',
firstId: undefined,
lastId: undefined,
},
});
parseRangeQuery(req, res, nextFn);
expect(req.rangeQuery.range.limit).toEqual(50);
expect(nextFn).toBeCalledTimes(1);
});
test('should default limit to 20 if absent', () => {
const req = mock<ExecutionRequest.GetMany>({
query: {
filter: undefined,
limit: undefined,
firstId: undefined,
lastId: undefined,
},
});
parseRangeQuery(req, res, nextFn);
expect(req.rangeQuery.range.limit).toEqual(20);
expect(nextFn).toBeCalledTimes(1);
});
});
});