fix(core): Reduce risk of race condition during workflow activation loop (#13186)

Co-authored-by: Tomi Turtiainen <10324676+tomi@users.noreply.github.com>
This commit is contained in:
Iván Ovejero 2025-02-13 16:10:43 +01:00 committed by GitHub
parent 47c5688618
commit 64c5b6e060
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 69 additions and 26 deletions

View file

@ -10,12 +10,15 @@ import type {
import { Workflow } from 'n8n-workflow'; import { Workflow } from 'n8n-workflow';
import { ActiveWorkflowManager } from '@/active-workflow-manager'; import { ActiveWorkflowManager } from '@/active-workflow-manager';
import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
import type { WorkflowRepository } from '@/databases/repositories/workflow.repository';
import type { NodeTypes } from '@/node-types'; import type { NodeTypes } from '@/node-types';
describe('ActiveWorkflowManager', () => { describe('ActiveWorkflowManager', () => {
let activeWorkflowManager: ActiveWorkflowManager; let activeWorkflowManager: ActiveWorkflowManager;
const instanceSettings = mock<InstanceSettings>(); const instanceSettings = mock<InstanceSettings>({ isMultiMain: false });
const nodeTypes = mock<NodeTypes>(); const nodeTypes = mock<NodeTypes>();
const workflowRepository = mock<WorkflowRepository>();
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
@ -27,7 +30,7 @@ describe('ActiveWorkflowManager', () => {
mock(), mock(),
nodeTypes, nodeTypes,
mock(), mock(),
mock(), workflowRepository,
mock(), mock(),
mock(), mock(),
mock(), mock(),
@ -122,5 +125,27 @@ describe('ActiveWorkflowManager', () => {
} }
}); });
}); });
describe('add', () => {
test.each<[WorkflowActivateMode]>([['init'], ['leadershipChange']])(
'should skip inactive workflow in `%s` activation mode',
async (mode) => {
const checkSpy = jest.spyOn(activeWorkflowManager, 'checkIfWorkflowCanBeActivated');
const addWebhooksSpy = jest.spyOn(activeWorkflowManager, 'addWebhooks');
const addTriggersAndPollersSpy = jest.spyOn(
activeWorkflowManager,
'addTriggersAndPollers',
);
workflowRepository.findById.mockResolvedValue(mock<WorkflowEntity>({ active: false }));
const result = await activeWorkflowManager.add('some-id', mode);
expect(checkSpy).not.toHaveBeenCalled();
expect(addWebhooksSpy).not.toHaveBeenCalled();
expect(addTriggersAndPollersSpy).not.toHaveBeenCalled();
expect(result).toBe(false);
},
);
});
}); });
}); });

View file

@ -24,6 +24,7 @@ import type {
WorkflowActivateMode, WorkflowActivateMode,
WorkflowExecuteMode, WorkflowExecuteMode,
INodeType, INodeType,
WorkflowId,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { import {
Workflow, Workflow,
@ -39,11 +40,13 @@ import {
WORKFLOW_REACTIVATE_INITIAL_TIMEOUT, WORKFLOW_REACTIVATE_INITIAL_TIMEOUT,
WORKFLOW_REACTIVATE_MAX_TIMEOUT, WORKFLOW_REACTIVATE_MAX_TIMEOUT,
} from '@/constants'; } from '@/constants';
import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
import { OnShutdown } from '@/decorators/on-shutdown'; import { OnShutdown } from '@/decorators/on-shutdown';
import { executeErrorWorkflow } from '@/execution-lifecycle/execute-error-workflow'; import { executeErrorWorkflow } from '@/execution-lifecycle/execute-error-workflow';
import { ExecutionService } from '@/executions/execution.service'; import { ExecutionService } from '@/executions/execution.service';
import { ExternalHooks } from '@/external-hooks'; import { ExternalHooks } from '@/external-hooks';
import type { IWorkflowDb } from '@/interfaces';
import { NodeTypes } from '@/node-types'; import { NodeTypes } from '@/node-types';
import { Publisher } from '@/scaling/pubsub/publisher.service'; import { Publisher } from '@/scaling/pubsub/publisher.service';
import { ActiveWorkflowsService } from '@/services/active-workflows.service'; import { ActiveWorkflowsService } from '@/services/active-workflows.service';
@ -59,12 +62,12 @@ interface QueuedActivation {
activationMode: WorkflowActivateMode; activationMode: WorkflowActivateMode;
lastTimeout: number; lastTimeout: number;
timeout: NodeJS.Timeout; timeout: NodeJS.Timeout;
workflowData: IWorkflowBase; workflowData: IWorkflowDb;
} }
@Service() @Service()
export class ActiveWorkflowManager { export class ActiveWorkflowManager {
private queuedActivations: { [workflowId: string]: QueuedActivation } = {}; private queuedActivations: Record<WorkflowId, QueuedActivation> = {};
constructor( constructor(
private readonly logger: Logger, private readonly logger: Logger,
@ -92,7 +95,6 @@ export class ActiveWorkflowManager {
await this.addActiveWorkflows('init'); await this.addActiveWorkflows('init');
await this.externalHooks.run('activeWorkflows.initialized'); await this.externalHooks.run('activeWorkflows.initialized');
await this.webhookService.populateCache();
} }
async getAllWorkflowActivationErrors() { async getAllWorkflowActivationErrors() {
@ -134,7 +136,7 @@ export class ActiveWorkflowManager {
* @important Do not confuse with `ActiveWorkflows.isActive()`, * @important Do not confuse with `ActiveWorkflows.isActive()`,
* which checks if the workflow is active in memory. * which checks if the workflow is active in memory.
*/ */
async isActive(workflowId: string) { async isActive(workflowId: WorkflowId) {
const workflow = await this.workflowRepository.findOne({ const workflow = await this.workflowRepository.findOne({
select: ['active'], select: ['active'],
where: { id: workflowId }, where: { id: workflowId },
@ -230,7 +232,7 @@ export class ActiveWorkflowManager {
* Remove all webhooks of a workflow from the database, and * Remove all webhooks of a workflow from the database, and
* deregister those webhooks from external services. * deregister those webhooks from external services.
*/ */
async clearWebhooks(workflowId: string) { async clearWebhooks(workflowId: WorkflowId) {
const workflowData = await this.workflowRepository.findOne({ const workflowData = await this.workflowRepository.findOne({
where: { id: workflowId }, where: { id: workflowId },
}); });
@ -270,7 +272,7 @@ export class ActiveWorkflowManager {
* and overwrites the emit to be able to start it in subprocess * and overwrites the emit to be able to start it in subprocess
*/ */
getExecutePollFunctions( getExecutePollFunctions(
workflowData: IWorkflowBase, workflowData: IWorkflowDb,
additionalData: IWorkflowExecuteAdditionalData, additionalData: IWorkflowExecuteAdditionalData,
mode: WorkflowExecuteMode, mode: WorkflowExecuteMode,
activation: WorkflowActivateMode, activation: WorkflowActivateMode,
@ -321,7 +323,7 @@ export class ActiveWorkflowManager {
* and overwrites the emit to be able to start it in subprocess * and overwrites the emit to be able to start it in subprocess
*/ */
getExecuteTriggerFunctions( getExecuteTriggerFunctions(
workflowData: IWorkflowBase, workflowData: IWorkflowDb,
additionalData: IWorkflowExecuteAdditionalData, additionalData: IWorkflowExecuteAdditionalData,
mode: WorkflowExecuteMode, mode: WorkflowExecuteMode,
activation: WorkflowActivateMode, activation: WorkflowActivateMode,
@ -378,7 +380,7 @@ export class ActiveWorkflowManager {
); );
this.executeErrorWorkflow(activationError, workflowData, mode); this.executeErrorWorkflow(activationError, workflowData, mode);
this.addQueuedWorkflowActivation(activation, workflowData); this.addQueuedWorkflowActivation(activation, workflowData as WorkflowEntity);
}; };
return new TriggerContext(workflow, node, additionalData, mode, activation, emit, emitError); return new TriggerContext(workflow, node, additionalData, mode, activation, emit, emitError);
}; };
@ -411,9 +413,9 @@ export class ActiveWorkflowManager {
* only on instance init or (in multi-main setup) on leadership change. * only on instance init or (in multi-main setup) on leadership change.
*/ */
async addActiveWorkflows(activationMode: 'init' | 'leadershipChange') { async addActiveWorkflows(activationMode: 'init' | 'leadershipChange') {
const dbWorkflows = await this.workflowRepository.getAllActive(); const dbWorkflowIds = await this.workflowRepository.getAllActiveIds();
if (dbWorkflows.length === 0) return; if (dbWorkflowIds.length === 0) return;
if (this.instanceSettings.isLeader) { if (this.instanceSettings.isLeader) {
this.logger.info(' ================================'); this.logger.info(' ================================');
@ -421,11 +423,11 @@ export class ActiveWorkflowManager {
this.logger.info(' ================================'); this.logger.info(' ================================');
} }
const batches = chunk(dbWorkflows, this.workflowsConfig.activationBatchSize); const batches = chunk(dbWorkflowIds, this.workflowsConfig.activationBatchSize);
for (const batch of batches) { for (const batch of batches) {
const activationPromises = batch.map(async (dbWorkflow) => { const activationPromises = batch.map(async (dbWorkflowId) => {
await this.activateWorkflow(dbWorkflow, activationMode); await this.activateWorkflow(dbWorkflowId, activationMode);
}); });
await Promise.all(activationPromises); await Promise.all(activationPromises);
@ -435,9 +437,12 @@ export class ActiveWorkflowManager {
} }
private async activateWorkflow( private async activateWorkflow(
dbWorkflow: IWorkflowBase, workflowId: WorkflowId,
activationMode: 'init' | 'leadershipChange', activationMode: 'init' | 'leadershipChange',
) { ) {
const dbWorkflow = await this.workflowRepository.findById(workflowId);
if (!dbWorkflow) return;
try { try {
const wasActivated = await this.add(dbWorkflow.id, activationMode, dbWorkflow, { const wasActivated = await this.add(dbWorkflow.id, activationMode, dbWorkflow, {
shouldPublish: false, shouldPublish: false,
@ -515,9 +520,9 @@ export class ActiveWorkflowManager {
* since webhooks do not require continuous execution. * since webhooks do not require continuous execution.
*/ */
async add( async add(
workflowId: string, workflowId: WorkflowId,
activationMode: WorkflowActivateMode, activationMode: WorkflowActivateMode,
existingWorkflow?: IWorkflowBase, existingWorkflow?: WorkflowEntity,
{ shouldPublish } = { shouldPublish: true }, { shouldPublish } = { shouldPublish: true },
) { ) {
if (this.instanceSettings.isMultiMain && shouldPublish) { if (this.instanceSettings.isMultiMain && shouldPublish) {
@ -547,6 +552,16 @@ export class ActiveWorkflowManager {
}); });
} }
if (['init', 'leadershipChange'].includes(activationMode) && !dbWorkflow.active) {
this.logger.debug(
`Skipping workflow ${formatWorkflow(dbWorkflow)} as it is no longer active`,
{
workflowId: dbWorkflow.id,
},
);
return false;
}
if (shouldDisplayActivationMessage) { if (shouldDisplayActivationMessage) {
this.logger.debug(`Initializing active workflow ${formatWorkflow(dbWorkflow)} (startup)`, { this.logger.debug(`Initializing active workflow ${formatWorkflow(dbWorkflow)} (startup)`, {
workflowName: dbWorkflow.name, workflowName: dbWorkflow.name,
@ -672,7 +687,7 @@ export class ActiveWorkflowManager {
*/ */
private addQueuedWorkflowActivation( private addQueuedWorkflowActivation(
activationMode: WorkflowActivateMode, activationMode: WorkflowActivateMode,
workflowData: IWorkflowBase, workflowData: WorkflowEntity,
) { ) {
const workflowId = workflowData.id; const workflowId = workflowData.id;
const workflowName = workflowData.name; const workflowName = workflowData.name;
@ -729,7 +744,7 @@ export class ActiveWorkflowManager {
/** /**
* Remove a workflow from the activation queue * Remove a workflow from the activation queue
*/ */
private removeQueuedWorkflowActivation(workflowId: string) { private removeQueuedWorkflowActivation(workflowId: WorkflowId) {
if (this.queuedActivations[workflowId]) { if (this.queuedActivations[workflowId]) {
clearTimeout(this.queuedActivations[workflowId].timeout); clearTimeout(this.queuedActivations[workflowId].timeout);
delete this.queuedActivations[workflowId]; delete this.queuedActivations[workflowId];
@ -752,7 +767,7 @@ export class ActiveWorkflowManager {
*/ */
// TODO: this should happen in a transaction // TODO: this should happen in a transaction
// maybe, see: https://github.com/n8n-io/n8n/pull/8904#discussion_r1530150510 // maybe, see: https://github.com/n8n-io/n8n/pull/8904#discussion_r1530150510
async remove(workflowId: string) { async remove(workflowId: WorkflowId) {
if (this.instanceSettings.isMultiMain) { if (this.instanceSettings.isMultiMain) {
try { try {
await this.clearWebhooks(workflowId); await this.clearWebhooks(workflowId);
@ -794,7 +809,7 @@ export class ActiveWorkflowManager {
/** /**
* Stop running active triggers and pollers for a workflow. * Stop running active triggers and pollers for a workflow.
*/ */
async removeWorkflowTriggersAndPollers(workflowId: string) { async removeWorkflowTriggersAndPollers(workflowId: WorkflowId) {
if (!this.activeWorkflows.isActive(workflowId)) return; if (!this.activeWorkflows.isActive(workflowId)) return;
const wasRemoved = await this.activeWorkflows.remove(workflowId); const wasRemoved = await this.activeWorkflows.remove(workflowId);
@ -810,7 +825,7 @@ export class ActiveWorkflowManager {
* Register as active in memory a trigger- or poller-based workflow. * Register as active in memory a trigger- or poller-based workflow.
*/ */
async addTriggersAndPollers( async addTriggersAndPollers(
dbWorkflow: IWorkflowBase, dbWorkflow: WorkflowEntity,
workflow: Workflow, workflow: Workflow,
{ {
activationMode, activationMode,
@ -856,7 +871,7 @@ export class ActiveWorkflowManager {
} }
} }
async removeActivationError(workflowId: string) { async removeActivationError(workflowId: WorkflowId) {
await this.activationErrorsService.deregister(workflowId); await this.activationErrorsService.deregister(workflowId);
} }

View file

@ -37,11 +37,14 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
}); });
} }
async getAllActive() { async getAllActiveIds() {
return await this.find({ const result = await this.find({
select: { id: true },
where: { active: true }, where: { active: true },
relations: { shared: { project: { projectRelations: true } } }, relations: { shared: { project: { projectRelations: true } } },
}); });
return result.map(({ id }) => id);
} }
async getActiveIds({ maxResults }: { maxResults?: number } = {}) { async getActiveIds({ maxResults }: { maxResults?: number } = {}) {