mirror of
https://github.com/n8n-io/n8n.git
synced 2025-01-11 21:07:28 -08:00
test(core): Expand test coverage for pruning (#11567)
This commit is contained in:
parent
46eceabc27
commit
19d55da4ad
|
@ -7,6 +7,7 @@ export const LOG_SCOPES = [
|
||||||
'external-secrets',
|
'external-secrets',
|
||||||
'license',
|
'license',
|
||||||
'multi-main-setup',
|
'multi-main-setup',
|
||||||
|
'pruning',
|
||||||
'pubsub',
|
'pubsub',
|
||||||
'redis',
|
'redis',
|
||||||
'scaling',
|
'scaling',
|
||||||
|
|
|
@ -27,7 +27,7 @@ import { Subscriber } from '@/scaling/pubsub/subscriber.service';
|
||||||
import { Server } from '@/server';
|
import { Server } from '@/server';
|
||||||
import { OrchestrationService } from '@/services/orchestration.service';
|
import { OrchestrationService } from '@/services/orchestration.service';
|
||||||
import { OwnershipService } from '@/services/ownership.service';
|
import { OwnershipService } from '@/services/ownership.service';
|
||||||
import { PruningService } from '@/services/pruning.service';
|
import { PruningService } from '@/services/pruning/pruning.service';
|
||||||
import { UrlService } from '@/services/url.service';
|
import { UrlService } from '@/services/url.service';
|
||||||
import { WaitTracker } from '@/wait-tracker';
|
import { WaitTracker } from '@/wait-tracker';
|
||||||
import { WorkflowRunner } from '@/workflow-runner';
|
import { WorkflowRunner } from '@/workflow-runner';
|
||||||
|
|
|
@ -513,7 +513,7 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
|
||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
async hardDeleteSoftDeletedExecutions() {
|
async findSoftDeletedExecutions() {
|
||||||
const date = new Date();
|
const date = new Date();
|
||||||
date.setHours(date.getHours() - this.globalConfig.pruning.hardDeleteBuffer);
|
date.setHours(date.getHours() - this.globalConfig.pruning.hardDeleteBuffer);
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,213 @@
|
||||||
|
import type { GlobalConfig } from '@n8n/config';
|
||||||
|
import { mock } from 'jest-mock-extended';
|
||||||
|
import type { InstanceSettings } from 'n8n-core';
|
||||||
|
|
||||||
|
import type { MultiMainSetup } from '@/scaling/multi-main-setup.ee';
|
||||||
|
import type { OrchestrationService } from '@/services/orchestration.service';
|
||||||
|
import { mockLogger } from '@test/mocking';
|
||||||
|
|
||||||
|
import { PruningService } from '../pruning.service';
|
||||||
|
|
||||||
|
describe('PruningService', () => {
|
||||||
|
describe('init', () => {
|
||||||
|
it('should start pruning if leader', () => {
|
||||||
|
const pruningService = new PruningService(
|
||||||
|
mockLogger(),
|
||||||
|
mock<InstanceSettings>({ isLeader: true }),
|
||||||
|
mock(),
|
||||||
|
mock(),
|
||||||
|
mock<OrchestrationService>({
|
||||||
|
isMultiMainSetupEnabled: true,
|
||||||
|
multiMainSetup: mock<MultiMainSetup>(),
|
||||||
|
}),
|
||||||
|
mock(),
|
||||||
|
);
|
||||||
|
const startPruningSpy = jest.spyOn(pruningService, 'startPruning');
|
||||||
|
|
||||||
|
pruningService.init();
|
||||||
|
|
||||||
|
expect(startPruningSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not start pruning if follower', () => {
|
||||||
|
const pruningService = new PruningService(
|
||||||
|
mockLogger(),
|
||||||
|
mock<InstanceSettings>({ isLeader: false }),
|
||||||
|
mock(),
|
||||||
|
mock(),
|
||||||
|
mock<OrchestrationService>({
|
||||||
|
isMultiMainSetupEnabled: true,
|
||||||
|
multiMainSetup: mock<MultiMainSetup>(),
|
||||||
|
}),
|
||||||
|
mock(),
|
||||||
|
);
|
||||||
|
const startPruningSpy = jest.spyOn(pruningService, 'startPruning');
|
||||||
|
|
||||||
|
pruningService.init();
|
||||||
|
|
||||||
|
expect(startPruningSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should register leadership events if multi-main setup is enabled', () => {
|
||||||
|
const pruningService = new PruningService(
|
||||||
|
mockLogger(),
|
||||||
|
mock<InstanceSettings>({ isLeader: true }),
|
||||||
|
mock(),
|
||||||
|
mock(),
|
||||||
|
mock<OrchestrationService>({
|
||||||
|
isMultiMainSetupEnabled: true,
|
||||||
|
multiMainSetup: mock<MultiMainSetup>({ on: jest.fn() }),
|
||||||
|
}),
|
||||||
|
mock(),
|
||||||
|
);
|
||||||
|
|
||||||
|
pruningService.init();
|
||||||
|
|
||||||
|
// @ts-expect-error Private method
|
||||||
|
expect(pruningService.orchestrationService.multiMainSetup.on).toHaveBeenCalledWith(
|
||||||
|
'leader-takeover',
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
|
|
||||||
|
// @ts-expect-error Private method
|
||||||
|
expect(pruningService.orchestrationService.multiMainSetup.on).toHaveBeenCalledWith(
|
||||||
|
'leader-stepdown',
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isEnabled', () => {
|
||||||
|
it('should return `true` based on config if leader main', () => {
|
||||||
|
const pruningService = new PruningService(
|
||||||
|
mockLogger(),
|
||||||
|
mock<InstanceSettings>({ isLeader: true, instanceType: 'main' }),
|
||||||
|
mock(),
|
||||||
|
mock(),
|
||||||
|
mock<OrchestrationService>({
|
||||||
|
isMultiMainSetupEnabled: true,
|
||||||
|
multiMainSetup: mock<MultiMainSetup>(),
|
||||||
|
}),
|
||||||
|
mock<GlobalConfig>({ pruning: { isEnabled: true } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// @ts-expect-error Private method
|
||||||
|
const isEnabled = pruningService.isEnabled();
|
||||||
|
|
||||||
|
expect(isEnabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return `false` based on config if leader main', () => {
|
||||||
|
const pruningService = new PruningService(
|
||||||
|
mockLogger(),
|
||||||
|
mock<InstanceSettings>({ isLeader: true, instanceType: 'main' }),
|
||||||
|
mock(),
|
||||||
|
mock(),
|
||||||
|
mock<OrchestrationService>({
|
||||||
|
isMultiMainSetupEnabled: true,
|
||||||
|
multiMainSetup: mock<MultiMainSetup>(),
|
||||||
|
}),
|
||||||
|
mock<GlobalConfig>({ pruning: { isEnabled: false } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// @ts-expect-error Private method
|
||||||
|
const isEnabled = pruningService.isEnabled();
|
||||||
|
|
||||||
|
expect(isEnabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return `false` if non-main even if enabled', () => {
|
||||||
|
const pruningService = new PruningService(
|
||||||
|
mockLogger(),
|
||||||
|
mock<InstanceSettings>({ isLeader: false, instanceType: 'worker' }),
|
||||||
|
mock(),
|
||||||
|
mock(),
|
||||||
|
mock<OrchestrationService>({
|
||||||
|
isMultiMainSetupEnabled: true,
|
||||||
|
multiMainSetup: mock<MultiMainSetup>(),
|
||||||
|
}),
|
||||||
|
mock<GlobalConfig>({ pruning: { isEnabled: true } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// @ts-expect-error Private method
|
||||||
|
const isEnabled = pruningService.isEnabled();
|
||||||
|
|
||||||
|
expect(isEnabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return `false` if follower main even if enabled', () => {
|
||||||
|
const pruningService = new PruningService(
|
||||||
|
mockLogger(),
|
||||||
|
mock<InstanceSettings>({ isLeader: false, isFollower: true, instanceType: 'main' }),
|
||||||
|
mock(),
|
||||||
|
mock(),
|
||||||
|
mock<OrchestrationService>({
|
||||||
|
isMultiMainSetupEnabled: true,
|
||||||
|
multiMainSetup: mock<MultiMainSetup>(),
|
||||||
|
}),
|
||||||
|
mock<GlobalConfig>({ pruning: { isEnabled: true }, multiMainSetup: { enabled: true } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// @ts-expect-error Private method
|
||||||
|
const isEnabled = pruningService.isEnabled();
|
||||||
|
|
||||||
|
expect(isEnabled).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('startPruning', () => {
|
||||||
|
it('should not start pruning if service is disabled', () => {
|
||||||
|
const pruningService = new PruningService(
|
||||||
|
mockLogger(),
|
||||||
|
mock<InstanceSettings>({ isLeader: true, instanceType: 'main' }),
|
||||||
|
mock(),
|
||||||
|
mock(),
|
||||||
|
mock<OrchestrationService>({
|
||||||
|
isMultiMainSetupEnabled: true,
|
||||||
|
multiMainSetup: mock<MultiMainSetup>(),
|
||||||
|
}),
|
||||||
|
mock<GlobalConfig>({ pruning: { isEnabled: false } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// @ts-expect-error Private method
|
||||||
|
const setSoftDeletionInterval = jest.spyOn(pruningService, 'setSoftDeletionInterval');
|
||||||
|
|
||||||
|
// @ts-expect-error Private method
|
||||||
|
const scheduleHardDeletion = jest.spyOn(pruningService, 'scheduleHardDeletion');
|
||||||
|
|
||||||
|
pruningService.startPruning();
|
||||||
|
|
||||||
|
expect(setSoftDeletionInterval).not.toHaveBeenCalled();
|
||||||
|
expect(scheduleHardDeletion).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should start pruning if service is enabled', () => {
|
||||||
|
const pruningService = new PruningService(
|
||||||
|
mockLogger(),
|
||||||
|
mock<InstanceSettings>({ isLeader: true, instanceType: 'main' }),
|
||||||
|
mock(),
|
||||||
|
mock(),
|
||||||
|
mock<OrchestrationService>({
|
||||||
|
isMultiMainSetupEnabled: true,
|
||||||
|
multiMainSetup: mock<MultiMainSetup>(),
|
||||||
|
}),
|
||||||
|
mock<GlobalConfig>({ pruning: { isEnabled: true } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const setSoftDeletionInterval = jest
|
||||||
|
// @ts-expect-error Private method
|
||||||
|
.spyOn(pruningService, 'setSoftDeletionInterval')
|
||||||
|
.mockImplementation();
|
||||||
|
|
||||||
|
const scheduleHardDeletion = jest
|
||||||
|
// @ts-expect-error Private method
|
||||||
|
.spyOn(pruningService, 'scheduleHardDeletion')
|
||||||
|
.mockImplementation();
|
||||||
|
|
||||||
|
pruningService.startPruning();
|
||||||
|
|
||||||
|
expect(setSoftDeletionInterval).toHaveBeenCalled();
|
||||||
|
expect(scheduleHardDeletion).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -3,12 +3,12 @@ import { BinaryDataService, InstanceSettings } from 'n8n-core';
|
||||||
import { jsonStringify } from 'n8n-workflow';
|
import { jsonStringify } from 'n8n-workflow';
|
||||||
import { Service } from 'typedi';
|
import { Service } from 'typedi';
|
||||||
|
|
||||||
import { inTest, TIME } from '@/constants';
|
import { TIME } from '@/constants';
|
||||||
import { ExecutionRepository } from '@/databases/repositories/execution.repository';
|
import { ExecutionRepository } from '@/databases/repositories/execution.repository';
|
||||||
import { OnShutdown } from '@/decorators/on-shutdown';
|
import { OnShutdown } from '@/decorators/on-shutdown';
|
||||||
import { Logger } from '@/logging/logger.service';
|
import { Logger } from '@/logging/logger.service';
|
||||||
|
|
||||||
import { OrchestrationService } from './orchestration.service';
|
import { OrchestrationService } from '../orchestration.service';
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export class PruningService {
|
export class PruningService {
|
||||||
|
@ -32,7 +32,9 @@ export class PruningService {
|
||||||
private readonly binaryDataService: BinaryDataService,
|
private readonly binaryDataService: BinaryDataService,
|
||||||
private readonly orchestrationService: OrchestrationService,
|
private readonly orchestrationService: OrchestrationService,
|
||||||
private readonly globalConfig: GlobalConfig,
|
private readonly globalConfig: GlobalConfig,
|
||||||
) {}
|
) {
|
||||||
|
this.logger = this.logger.scoped('pruning');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @important Requires `OrchestrationService` to be initialized.
|
* @important Requires `OrchestrationService` to be initialized.
|
||||||
|
@ -49,9 +51,9 @@ export class PruningService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private isPruningEnabled() {
|
private isEnabled() {
|
||||||
const { instanceType, isFollower } = this.instanceSettings;
|
const { instanceType, isFollower } = this.instanceSettings;
|
||||||
if (!this.globalConfig.pruning.isEnabled || inTest || instanceType !== 'main') {
|
if (!this.globalConfig.pruning.isEnabled || instanceType !== 'main') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -66,23 +68,23 @@ export class PruningService {
|
||||||
* @important Call this method only after DB migrations have completed.
|
* @important Call this method only after DB migrations have completed.
|
||||||
*/
|
*/
|
||||||
startPruning() {
|
startPruning() {
|
||||||
if (!this.isPruningEnabled()) return;
|
if (!this.isEnabled()) return;
|
||||||
|
|
||||||
if (this.isShuttingDown) {
|
if (this.isShuttingDown) {
|
||||||
this.logger.warn('[Pruning] Cannot start pruning while shutting down');
|
this.logger.warn('Cannot start pruning while shutting down');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.debug('[Pruning] Starting soft-deletion and hard-deletion timers');
|
this.logger.debug('Starting soft-deletion and hard-deletion timers');
|
||||||
|
|
||||||
this.setSoftDeletionInterval();
|
this.setSoftDeletionInterval();
|
||||||
this.scheduleHardDeletion();
|
this.scheduleHardDeletion();
|
||||||
}
|
}
|
||||||
|
|
||||||
stopPruning() {
|
stopPruning() {
|
||||||
if (!this.isPruningEnabled()) return;
|
if (!this.isEnabled()) return;
|
||||||
|
|
||||||
this.logger.debug('[Pruning] Removing soft-deletion and hard-deletion timers');
|
this.logger.debug('Removing soft-deletion and hard-deletion timers');
|
||||||
|
|
||||||
clearInterval(this.softDeletionInterval);
|
clearInterval(this.softDeletionInterval);
|
||||||
clearTimeout(this.hardDeletionTimeout);
|
clearTimeout(this.hardDeletionTimeout);
|
||||||
|
@ -96,7 +98,7 @@ export class PruningService {
|
||||||
this.rates.softDeletion,
|
this.rates.softDeletion,
|
||||||
);
|
);
|
||||||
|
|
||||||
this.logger.debug(`[Pruning] Soft-deletion scheduled every ${when}`);
|
this.logger.debug(`Soft-deletion scheduled every ${when}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
private scheduleHardDeletion(rateMs = this.rates.hardDeletion) {
|
private scheduleHardDeletion(rateMs = this.rates.hardDeletion) {
|
||||||
|
@ -113,27 +115,27 @@ export class PruningService {
|
||||||
? error.message
|
? error.message
|
||||||
: jsonStringify(error, { replaceCircularRefs: true });
|
: jsonStringify(error, { replaceCircularRefs: true });
|
||||||
|
|
||||||
this.logger.error('[Pruning] Failed to hard-delete executions', { errorMessage });
|
this.logger.error('Failed to hard-delete executions', { errorMessage });
|
||||||
});
|
});
|
||||||
}, rateMs);
|
}, rateMs);
|
||||||
|
|
||||||
this.logger.debug(`[Pruning] Hard-deletion scheduled for next ${when}`);
|
this.logger.debug(`Hard-deletion scheduled for next ${when}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mark executions as deleted based on age and count, in a pruning cycle.
|
* Mark executions as deleted based on age and count, in a pruning cycle.
|
||||||
*/
|
*/
|
||||||
async softDeleteOnPruningCycle() {
|
async softDeleteOnPruningCycle() {
|
||||||
this.logger.debug('[Pruning] Starting soft-deletion of executions');
|
this.logger.debug('Starting soft-deletion of executions');
|
||||||
|
|
||||||
const result = await this.executionRepository.softDeletePrunableExecutions();
|
const result = await this.executionRepository.softDeletePrunableExecutions();
|
||||||
|
|
||||||
if (result.affected === 0) {
|
if (result.affected === 0) {
|
||||||
this.logger.debug('[Pruning] Found no executions to soft-delete');
|
this.logger.debug('Found no executions to soft-delete');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.debug('[Pruning] Soft-deleted executions', { count: result.affected });
|
this.logger.debug('Soft-deleted executions', { count: result.affected });
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnShutdown()
|
@OnShutdown()
|
||||||
|
@ -147,26 +149,26 @@ export class PruningService {
|
||||||
* @return Delay in ms after which the next cycle should be started
|
* @return Delay in ms after which the next cycle should be started
|
||||||
*/
|
*/
|
||||||
private async hardDeleteOnPruningCycle() {
|
private async hardDeleteOnPruningCycle() {
|
||||||
const ids = await this.executionRepository.hardDeleteSoftDeletedExecutions();
|
const ids = await this.executionRepository.findSoftDeletedExecutions();
|
||||||
|
|
||||||
const executionIds = ids.map((o) => o.executionId);
|
const executionIds = ids.map((o) => o.executionId);
|
||||||
|
|
||||||
if (executionIds.length === 0) {
|
if (executionIds.length === 0) {
|
||||||
this.logger.debug('[Pruning] Found no executions to hard-delete');
|
this.logger.debug('Found no executions to hard-delete');
|
||||||
|
|
||||||
return this.rates.hardDeletion;
|
return this.rates.hardDeletion;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.logger.debug('[Pruning] Starting hard-deletion of executions', { executionIds });
|
this.logger.debug('Starting hard-deletion of executions', { executionIds });
|
||||||
|
|
||||||
await this.binaryDataService.deleteMany(ids);
|
await this.binaryDataService.deleteMany(ids);
|
||||||
|
|
||||||
await this.executionRepository.deleteByIds(executionIds);
|
await this.executionRepository.deleteByIds(executionIds);
|
||||||
|
|
||||||
this.logger.debug('[Pruning] Hard-deleted executions', { executionIds });
|
this.logger.debug('Hard-deleted executions', { executionIds });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('[Pruning] Failed to hard-delete executions', {
|
this.logger.error('Failed to hard-delete executions', {
|
||||||
executionIds,
|
executionIds,
|
||||||
error: error instanceof Error ? error.message : `${error}`,
|
error: error instanceof Error ? error.message : `${error}`,
|
||||||
});
|
});
|
|
@ -8,8 +8,7 @@ import { TIME } from '@/constants';
|
||||||
import type { ExecutionEntity } from '@/databases/entities/execution-entity';
|
import type { ExecutionEntity } from '@/databases/entities/execution-entity';
|
||||||
import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
|
import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
|
||||||
import { ExecutionRepository } from '@/databases/repositories/execution.repository';
|
import { ExecutionRepository } from '@/databases/repositories/execution.repository';
|
||||||
import { Logger } from '@/logging/logger.service';
|
import { PruningService } from '@/services/pruning/pruning.service';
|
||||||
import { PruningService } from '@/services/pruning.service';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
annotateExecution,
|
annotateExecution,
|
||||||
|
@ -18,7 +17,7 @@ import {
|
||||||
} from './shared/db/executions';
|
} from './shared/db/executions';
|
||||||
import { createWorkflow } from './shared/db/workflows';
|
import { createWorkflow } from './shared/db/workflows';
|
||||||
import * as testDb from './shared/test-db';
|
import * as testDb from './shared/test-db';
|
||||||
import { mockInstance } from '../shared/mocking';
|
import { mockInstance, mockLogger } from '../shared/mocking';
|
||||||
|
|
||||||
describe('softDeleteOnPruningCycle()', () => {
|
describe('softDeleteOnPruningCycle()', () => {
|
||||||
let pruningService: PruningService;
|
let pruningService: PruningService;
|
||||||
|
@ -35,7 +34,7 @@ describe('softDeleteOnPruningCycle()', () => {
|
||||||
|
|
||||||
globalConfig = Container.get(GlobalConfig);
|
globalConfig = Container.get(GlobalConfig);
|
||||||
pruningService = new PruningService(
|
pruningService = new PruningService(
|
||||||
mockInstance(Logger),
|
mockLogger(),
|
||||||
instanceSettings,
|
instanceSettings,
|
||||||
Container.get(ExecutionRepository),
|
Container.get(ExecutionRepository),
|
||||||
mockInstance(BinaryDataService),
|
mockInstance(BinaryDataService),
|
||||||
|
|
Loading…
Reference in a new issue