Improve workflow crons efficiency (#19381)

**Optimize workflow cron jobs: partition workspaces and use raw
queries**

- Split all 3 workflow cron jobs (WorkflowRunEnqueueCronJob,
WorkflowHandleStaledRunsCronJob, WorkflowCleanWorkflowRunsCronJob) to
process only 1/10th of workspaces per invocation using minute-based
partitioning, reducing per-run load
- Replace ORM repository + workspace context loading with raw SQL
queries in WorkflowRunEnqueueCronJob and
WorkflowHandleStaledRunsCronJob, avoiding costly cache/metadata
hydration for a simple existence check
This commit is contained in:
Thomas Trompette
2026-04-07 14:09:11 +00:00
committed by GitHub
parent 653180d10f
commit e048d03872
4 changed files with 133 additions and 71 deletions
@@ -4,6 +4,9 @@ import { InjectRepository } from '@nestjs/typeorm';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { In, Repository } from 'typeorm';
import { InjectCacheStorage } from 'src/engine/core-modules/cache-storage/decorators/cache-storage.decorator';
import { CacheStorageService } from 'src/engine/core-modules/cache-storage/services/cache-storage.service';
import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum';
import { SentryCronMonitor } from 'src/engine/core-modules/cron/sentry-cron-monitor.decorator';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
@@ -25,9 +28,11 @@ import {
} from 'src/modules/workflow/workflow-runner/workflow-run-queue/jobs/workflow-clean-workflow-runs.job';
import { getRunsToCleanFindOptions } from 'src/modules/workflow/workflow-runner/workflow-run-queue/utils/get-runs-to-clean-find-options.util';
export const CLEAN_WORKFLOW_RUN_CRON_PATTERN = '0 0 * * *';
export const CLEAN_WORKFLOW_RUN_CRON_PATTERN = '0 */3 * * *';
const WORKSPACE_BATCH_SIZE = 50;
const LAST_PARTITION_CACHE_KEY = 'workflow-clean-workflow-runs:last-partition';
const NUMBER_OF_PARTITIONS = 10;
const WORKSPACE_BATCH_SIZE = 10;
@Processor(MessageQueue.cronQueue)
export class WorkflowCleanWorkflowRunsCronJob {
@@ -40,6 +45,8 @@ export class WorkflowCleanWorkflowRunsCronJob {
private readonly messageQueueService: MessageQueueService,
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly exceptionHandlerService: ExceptionHandlerService,
@InjectCacheStorage(CacheStorageNamespace.ModuleWorkflow)
private readonly cacheStorageService: CacheStorageService,
) {}
@Process(WorkflowCleanWorkflowRunsCronJob.name)
@@ -50,21 +57,27 @@ export class WorkflowCleanWorkflowRunsCronJob {
async handle() {
this.logger.log('Starting WorkflowCleanWorkflowRunsCronJob cron');
const activeWorkspaces = await this.workspaceRepository.find({
const allActiveWorkspaces = await this.workspaceRepository.find({
where: {
activationStatus: WorkspaceActivationStatus.ACTIVE,
},
select: ['id'],
order: { id: 'ASC' },
});
const partition = await this.getAndIncrementPartition();
const workspacesForThisRun = allActiveWorkspaces.filter(
(_, index) => index % NUMBER_OF_PARTITIONS === partition,
);
let enqueuedCount = 0;
for (
let workspaceIndex = 0;
workspaceIndex < activeWorkspaces.length;
workspaceIndex < workspacesForThisRun.length;
workspaceIndex += WORKSPACE_BATCH_SIZE
) {
const batch = activeWorkspaces.slice(
const batch = workspacesForThisRun.slice(
workspaceIndex,
workspaceIndex + WORKSPACE_BATCH_SIZE,
);
@@ -87,7 +100,7 @@ export class WorkflowCleanWorkflowRunsCronJob {
}
this.logger.log(
`Completed WorkflowCleanWorkflowRunsCronJob cron, enqueued ${enqueuedCount} jobs`,
`Completed WorkflowCleanWorkflowRunsCronJob cron (partition ${partition}/${NUMBER_OF_PARTITIONS}), enqueued ${enqueuedCount} jobs`,
);
}
@@ -106,6 +119,21 @@ export class WorkflowCleanWorkflowRunsCronJob {
return false;
}
private async getAndIncrementPartition(): Promise<number> {
const lastPartition = await this.cacheStorageService.get<number>(
LAST_PARTITION_CACHE_KEY,
);
const partition =
lastPartition !== undefined
? (lastPartition + 1) % NUMBER_OF_PARTITIONS
: 0;
await this.cacheStorageService.set(LAST_PARTITION_CACHE_KEY, partition);
return partition;
}
private async hasRunsToClean(workspaceId: string): Promise<boolean> {
const authContext = buildSystemAuthContext(workspaceId);
@@ -1,9 +1,12 @@
import { Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { Repository } from 'typeorm';
import { DataSource, Repository } from 'typeorm';
import { InjectCacheStorage } from 'src/engine/core-modules/cache-storage/decorators/cache-storage.decorator';
import { CacheStorageService } from 'src/engine/core-modules/cache-storage/services/cache-storage.service';
import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum';
import { SentryCronMonitor } from 'src/engine/core-modules/cron/sentry-cron-monitor.decorator';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
@@ -12,17 +15,17 @@ import { Processor } from 'src/engine/core-modules/message-queue/decorators/proc
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { WorkflowRunWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow-run.workspace-entity';
import { getWorkspaceSchemaName } from 'src/engine/workspace-datasource/utils/get-workspace-schema-name.util';
import { WorkflowRunStatus } from 'src/modules/workflow/common/standard-objects/workflow-run.workspace-entity';
import { STALED_RUNS_THRESHOLD_MS } from 'src/modules/workflow/workflow-runner/workflow-run-queue/constants/staled-runs-threshold';
import {
WorkflowHandleStaledRunsJob,
WorkflowHandleStaledRunsJobData,
} from 'src/modules/workflow/workflow-runner/workflow-run-queue/jobs/workflow-handle-staled-runs.job';
import { getStaledRunsFindOptions } from 'src/modules/workflow/workflow-runner/workflow-run-queue/utils/get-staled-runs-find-options.util';
export const WORKFLOW_HANDLE_STALED_RUNS_CRON_PATTERN = '0 * * * *';
export const WORKFLOW_HANDLE_STALED_RUNS_CRON_PATTERN = '*/10 * * * *';
const LAST_PARTITION_CACHE_KEY = 'workflow-handle-staled-runs:last-partition';
const NUMBER_OF_PARTITIONS = 10;
const WORKSPACE_BATCH_SIZE = 50;
@Processor(MessageQueue.cronQueue)
@@ -30,12 +33,15 @@ export class WorkflowHandleStaledRunsCronJob {
private readonly logger = new Logger(WorkflowHandleStaledRunsCronJob.name);
constructor(
@InjectDataSource()
private readonly coreDataSource: DataSource,
@InjectRepository(WorkspaceEntity)
private readonly workspaceRepository: Repository<WorkspaceEntity>,
@InjectMessageQueue(MessageQueue.workflowQueue)
private readonly messageQueueService: MessageQueueService,
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly exceptionHandlerService: ExceptionHandlerService,
@InjectCacheStorage(CacheStorageNamespace.ModuleWorkflow)
private readonly cacheStorageService: CacheStorageService,
) {}
@Process(WorkflowHandleStaledRunsCronJob.name)
@@ -46,21 +52,27 @@ export class WorkflowHandleStaledRunsCronJob {
async handle() {
this.logger.log('Starting WorkflowHandleStaledRunsCronJob cron');
const activeWorkspaces = await this.workspaceRepository.find({
const allActiveWorkspaces = await this.workspaceRepository.find({
where: {
activationStatus: WorkspaceActivationStatus.ACTIVE,
},
select: ['id'],
order: { id: 'ASC' },
});
const partition = await this.getAndIncrementPartition();
const workspacesForThisRun = allActiveWorkspaces.filter(
(_, index) => index % NUMBER_OF_PARTITIONS === partition,
);
let enqueuedCount = 0;
for (
let workspaceIndex = 0;
workspaceIndex < activeWorkspaces.length;
workspaceIndex < workspacesForThisRun.length;
workspaceIndex += WORKSPACE_BATCH_SIZE
) {
const batch = activeWorkspaces.slice(
const batch = workspacesForThisRun.slice(
workspaceIndex,
workspaceIndex + WORKSPACE_BATCH_SIZE,
);
@@ -83,7 +95,7 @@ export class WorkflowHandleStaledRunsCronJob {
}
this.logger.log(
`Completed WorkflowHandleStaledRunsCronJob cron, enqueued ${enqueuedCount} jobs`,
`Completed WorkflowHandleStaledRunsCronJob cron (partition ${partition}/${NUMBER_OF_PARTITIONS}), enqueued ${enqueuedCount} jobs`,
);
}
@@ -102,24 +114,30 @@ export class WorkflowHandleStaledRunsCronJob {
return false;
}
private async hasStaledRuns(workspaceId: string): Promise<boolean> {
const authContext = buildSystemAuthContext(workspaceId);
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
const workflowRunRepository =
await this.globalWorkspaceOrmManager.getRepository(
workspaceId,
WorkflowRunWorkspaceEntity,
{ shouldBypassPermissionChecks: true },
);
return workflowRunRepository.exists({
where: getStaledRunsFindOptions(),
});
},
authContext,
{ lite: true },
private async getAndIncrementPartition(): Promise<number> {
const lastPartition = await this.cacheStorageService.get<number>(
LAST_PARTITION_CACHE_KEY,
);
const partition =
lastPartition !== undefined
? (lastPartition + 1) % NUMBER_OF_PARTITIONS
: 0;
await this.cacheStorageService.set(LAST_PARTITION_CACHE_KEY, partition);
return partition;
}
private async hasStaledRuns(workspaceId: string): Promise<boolean> {
const schemaName = getWorkspaceSchemaName(workspaceId);
const thresholdDate = new Date(Date.now() - STALED_RUNS_THRESHOLD_MS);
const result = await this.coreDataSource.query(
`SELECT 1 FROM ${schemaName}."workflowRun" WHERE "status" = $1 AND ("enqueuedAt" < $2 OR "enqueuedAt" IS NULL) LIMIT 1`,
[WorkflowRunStatus.ENQUEUED, thresholdDate],
);
return result.length > 0;
}
}
@@ -1,9 +1,12 @@
import { Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { Repository } from 'typeorm';
import { DataSource, Repository } from 'typeorm';
import { InjectCacheStorage } from 'src/engine/core-modules/cache-storage/decorators/cache-storage.decorator';
import { CacheStorageService } from 'src/engine/core-modules/cache-storage/services/cache-storage.service';
import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum';
import { SentryCronMonitor } from 'src/engine/core-modules/cron/sentry-cron-monitor.decorator';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
@@ -12,17 +15,16 @@ import { Processor } from 'src/engine/core-modules/message-queue/decorators/proc
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { WorkflowRunWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow-run.workspace-entity';
import { NOT_STARTED_RUNS_FIND_OPTIONS } from 'src/modules/workflow/workflow-runner/workflow-run-queue/constants/not-started-runs-find-options';
import { getWorkspaceSchemaName } from 'src/engine/workspace-datasource/utils/get-workspace-schema-name.util';
import { WorkflowRunStatus } from 'src/modules/workflow/common/standard-objects/workflow-run.workspace-entity';
import {
WorkflowRunEnqueueJob,
WorkflowRunEnqueueJobData,
} from 'src/modules/workflow/workflow-runner/workflow-run-queue/jobs/workflow-run-enqueue.job';
export const WORKFLOW_RUN_ENQUEUE_CRON_PATTERN = '* * * * *';
export const WORKFLOW_RUN_ENQUEUE_CRON_PATTERN = '*/5 * * * *';
const LAST_PARTITION_CACHE_KEY = 'workflow-run-enqueue:last-partition';
const NUMBER_OF_PARTITIONS = 10;
const WORKSPACE_BATCH_SIZE = 10;
@Processor(MessageQueue.cronQueue)
@@ -30,12 +32,15 @@ export class WorkflowRunEnqueueCronJob {
private readonly logger = new Logger(WorkflowRunEnqueueCronJob.name);
constructor(
@InjectDataSource()
private readonly coreDataSource: DataSource,
@InjectRepository(WorkspaceEntity)
private readonly workspaceRepository: Repository<WorkspaceEntity>,
@InjectMessageQueue(MessageQueue.workflowQueue)
private readonly messageQueueService: MessageQueueService,
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly exceptionHandlerService: ExceptionHandlerService,
@InjectCacheStorage(CacheStorageNamespace.ModuleWorkflow)
private readonly cacheStorageService: CacheStorageService,
) {}
@Process(WorkflowRunEnqueueCronJob.name)
@@ -46,21 +51,27 @@ export class WorkflowRunEnqueueCronJob {
async handle() {
this.logger.log('Starting WorkflowRunEnqueueCronJob cron');
const activeWorkspaces = await this.workspaceRepository.find({
const allActiveWorkspaces = await this.workspaceRepository.find({
where: {
activationStatus: WorkspaceActivationStatus.ACTIVE,
},
select: ['id'],
order: { id: 'ASC' },
});
const partition = await this.getAndIncrementPartition();
const workspacesForThisRun = allActiveWorkspaces.filter(
(_, index) => index % NUMBER_OF_PARTITIONS === partition,
);
let enqueuedCount = 0;
for (
let workspaceIndex = 0;
workspaceIndex < activeWorkspaces.length;
workspaceIndex < workspacesForThisRun.length;
workspaceIndex += WORKSPACE_BATCH_SIZE
) {
const batch = activeWorkspaces.slice(
const batch = workspacesForThisRun.slice(
workspaceIndex,
workspaceIndex + WORKSPACE_BATCH_SIZE,
);
@@ -83,7 +94,7 @@ export class WorkflowRunEnqueueCronJob {
}
this.logger.log(
`Completed WorkflowRunEnqueueCronJob cron, enqueued ${enqueuedCount} jobs`,
`Completed WorkflowRunEnqueueCronJob cron (partition ${partition}/${NUMBER_OF_PARTITIONS}), enqueued ${enqueuedCount} jobs`,
);
}
@@ -102,24 +113,29 @@ export class WorkflowRunEnqueueCronJob {
return false;
}
private async hasNotStartedRuns(workspaceId: string): Promise<boolean> {
const authContext = buildSystemAuthContext(workspaceId);
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
const workflowRunRepository =
await this.globalWorkspaceOrmManager.getRepository(
workspaceId,
WorkflowRunWorkspaceEntity,
{ shouldBypassPermissionChecks: true },
);
return workflowRunRepository.exists({
where: NOT_STARTED_RUNS_FIND_OPTIONS,
});
},
authContext,
{ lite: true },
private async getAndIncrementPartition(): Promise<number> {
const lastPartition = await this.cacheStorageService.get<number>(
LAST_PARTITION_CACHE_KEY,
);
const partition =
lastPartition !== undefined
? (lastPartition + 1) % NUMBER_OF_PARTITIONS
: 0;
await this.cacheStorageService.set(LAST_PARTITION_CACHE_KEY, partition);
return partition;
}
private async hasNotStartedRuns(workspaceId: string): Promise<boolean> {
const schemaName = getWorkspaceSchemaName(workspaceId);
const result = await this.coreDataSource.query(
`SELECT 1 FROM ${schemaName}."workflowRun" WHERE "status" = $1 LIMIT 1`,
[WorkflowRunStatus.NOT_STARTED],
);
return result.length > 0;
}
}