feat(editor): Refactor and unify executions views (no-changelog) (#8538)
This commit is contained in:
@@ -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;
|
||||
} = {};
|
||||
|
||||
@@ -171,7 +171,7 @@ export interface IExecutionsListResponse {
|
||||
estimated: boolean;
|
||||
}
|
||||
|
||||
export interface IExecutionsStopData {
|
||||
export interface ExecutionStopResult {
|
||||
finished?: boolean;
|
||||
mode: WorkflowExecuteMode;
|
||||
startedAt: Date;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
56
packages/cli/src/executions/parse-range-query.middleware.ts
Normal file
56
packages/cli/src/executions/parse-range-query.middleware.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user