| import { mockLogger } from '@n8n/backend-test-utils'; |
| import { ExecutionsConfig } from '@n8n/config'; |
| import type { ExecutionEntity } from '@n8n/db'; |
| import { ExecutionRepository } from '@n8n/db'; |
| import { Container } from '@n8n/di'; |
| import { BinaryDataService, InstanceSettings } from 'n8n-core'; |
| import type { ExecutionStatus, IWorkflowBase } from 'n8n-workflow'; |
|
|
| import { Time } from '@/constants'; |
| import { DbConnection } from '@/databases/db-connection'; |
| import { ExecutionsPruningService } from '@/services/pruning/executions-pruning.service'; |
|
|
| import { |
| annotateExecution, |
| createExecution, |
| createSuccessfulExecution, |
| } from './shared/db/executions'; |
| import { createWorkflow } from './shared/db/workflows'; |
| import * as testDb from './shared/test-db'; |
| import { mockInstance } from '../shared/mocking'; |
|
|
| describe('softDeleteOnPruningCycle()', () => { |
| let pruningService: ExecutionsPruningService; |
| const instanceSettings = Container.get(InstanceSettings); |
| instanceSettings.markAsLeader(); |
|
|
| const now = new Date(); |
| const yesterday = new Date(Date.now() - 1 * Time.days.toMilliseconds); |
| let workflow: IWorkflowBase; |
| let executionsConfig: ExecutionsConfig; |
|
|
| beforeAll(async () => { |
| await testDb.init(); |
|
|
| executionsConfig = Container.get(ExecutionsConfig); |
| pruningService = new ExecutionsPruningService( |
| mockLogger(), |
| instanceSettings, |
| Container.get(DbConnection), |
| Container.get(ExecutionRepository), |
| mockInstance(BinaryDataService), |
| executionsConfig, |
| ); |
|
|
| workflow = await createWorkflow(); |
| }); |
|
|
| beforeEach(async () => { |
| await testDb.truncate(['ExecutionEntity', 'ExecutionAnnotation']); |
| }); |
|
|
| afterAll(async () => { |
| await testDb.terminate(); |
| }); |
|
|
| async function findAllExecutions() { |
| return await Container.get(ExecutionRepository).find({ |
| order: { id: 'asc' }, |
| withDeleted: true, |
| }); |
| } |
|
|
| describe('when EXECUTIONS_DATA_PRUNE_MAX_COUNT is set', () => { |
| beforeAll(() => { |
| executionsConfig.pruneDataMaxAge = 336; |
| executionsConfig.pruneDataMaxCount = 1; |
| }); |
|
|
| test('should mark as deleted based on EXECUTIONS_DATA_PRUNE_MAX_COUNT', async () => { |
| const executions = [ |
| await createSuccessfulExecution(workflow), |
| await createSuccessfulExecution(workflow), |
| await createSuccessfulExecution(workflow), |
| ]; |
|
|
| await pruningService.softDelete(); |
|
|
| const result = await findAllExecutions(); |
| expect(result).toEqual([ |
| expect.objectContaining({ id: executions[0].id, deletedAt: expect.any(Date) }), |
| expect.objectContaining({ id: executions[1].id, deletedAt: expect.any(Date) }), |
| expect.objectContaining({ id: executions[2].id, deletedAt: null }), |
| ]); |
| }); |
|
|
| test('should not re-mark already marked executions', async () => { |
| const executions = [ |
| await createExecution( |
| { status: 'success', finished: true, startedAt: now, stoppedAt: now, deletedAt: now }, |
| workflow, |
| ), |
| await createSuccessfulExecution(workflow), |
| ]; |
|
|
| await pruningService.softDelete(); |
|
|
| const result = await findAllExecutions(); |
| expect(result).toEqual([ |
| expect.objectContaining({ id: executions[0].id, deletedAt: now }), |
| expect.objectContaining({ id: executions[1].id, deletedAt: null }), |
| ]); |
| }); |
|
|
| test.each<[ExecutionStatus, Partial<ExecutionEntity>]>([ |
| ['unknown', { startedAt: now, stoppedAt: now }], |
| ['canceled', { startedAt: now, stoppedAt: now }], |
| ['crashed', { startedAt: now, stoppedAt: now }], |
| ['error', { startedAt: now, stoppedAt: now }], |
| ['success', { finished: true, startedAt: now, stoppedAt: now }], |
| ])('should prune %s executions', async (status, attributes) => { |
| const executions = [ |
| await createExecution({ status, ...attributes }, workflow), |
| await createSuccessfulExecution(workflow), |
| ]; |
|
|
| await pruningService.softDelete(); |
|
|
| const result = await findAllExecutions(); |
| expect(result).toEqual([ |
| expect.objectContaining({ id: executions[0].id, deletedAt: expect.any(Date) }), |
| expect.objectContaining({ id: executions[1].id, deletedAt: null }), |
| ]); |
| }); |
|
|
| test.each<[ExecutionStatus, Partial<ExecutionEntity>]>([ |
| ['new', {}], |
| ['running', { startedAt: now }], |
| ['waiting', { startedAt: now, stoppedAt: now, waitTill: now }], |
| ])('should not prune %s executions', async (status, attributes) => { |
| const executions = [ |
| await createExecution({ status, ...attributes }, workflow), |
| await createSuccessfulExecution(workflow), |
| ]; |
|
|
| await pruningService.softDelete(); |
|
|
| const result = await findAllExecutions(); |
| expect(result).toEqual([ |
| expect.objectContaining({ id: executions[0].id, deletedAt: null }), |
| expect.objectContaining({ id: executions[1].id, deletedAt: null }), |
| ]); |
| }); |
|
|
| test('should not prune annotated executions', async () => { |
| const executions = [ |
| await createSuccessfulExecution(workflow), |
| await createSuccessfulExecution(workflow), |
| await createSuccessfulExecution(workflow), |
| ]; |
|
|
| await annotateExecution(executions[0].id, { vote: 'up' }, [workflow.id]); |
|
|
| await pruningService.softDelete(); |
|
|
| const result = await findAllExecutions(); |
| expect(result).toEqual([ |
| expect.objectContaining({ id: executions[0].id, deletedAt: null }), |
| expect.objectContaining({ id: executions[1].id, deletedAt: expect.any(Date) }), |
| expect.objectContaining({ id: executions[2].id, deletedAt: null }), |
| ]); |
| }); |
| }); |
|
|
| describe('when EXECUTIONS_DATA_MAX_AGE is set', () => { |
| beforeAll(() => { |
| executionsConfig.pruneDataMaxAge = 1; |
| executionsConfig.pruneDataMaxCount = 0; |
| }); |
|
|
| test('should mark as deleted based on EXECUTIONS_DATA_MAX_AGE', async () => { |
| const executions = [ |
| await createExecution( |
| { finished: true, startedAt: yesterday, stoppedAt: yesterday, status: 'success' }, |
| workflow, |
| ), |
| await createExecution( |
| { finished: true, startedAt: now, stoppedAt: now, status: 'success' }, |
| workflow, |
| ), |
| ]; |
|
|
| await pruningService.softDelete(); |
|
|
| const result = await findAllExecutions(); |
| expect(result).toEqual([ |
| expect.objectContaining({ id: executions[0].id, deletedAt: expect.any(Date) }), |
| expect.objectContaining({ id: executions[1].id, deletedAt: null }), |
| ]); |
| }); |
|
|
| test('should not re-mark already marked executions', async () => { |
| const executions = [ |
| await createExecution( |
| { |
| status: 'success', |
| finished: true, |
| startedAt: yesterday, |
| stoppedAt: yesterday, |
| deletedAt: yesterday, |
| }, |
| workflow, |
| ), |
| await createSuccessfulExecution(workflow), |
| ]; |
|
|
| await pruningService.softDelete(); |
|
|
| const result = await findAllExecutions(); |
| expect(result).toEqual([ |
| expect.objectContaining({ id: executions[0].id, deletedAt: yesterday }), |
| expect.objectContaining({ id: executions[1].id, deletedAt: null }), |
| ]); |
| }); |
|
|
| test.each<[ExecutionStatus, Partial<ExecutionEntity>]>([ |
| ['unknown', { startedAt: yesterday, stoppedAt: yesterday }], |
| ['canceled', { startedAt: yesterday, stoppedAt: yesterday }], |
| ['crashed', { startedAt: yesterday, stoppedAt: yesterday }], |
| ['error', { startedAt: yesterday, stoppedAt: yesterday }], |
| ['success', { finished: true, startedAt: yesterday, stoppedAt: yesterday }], |
| ])('should prune %s executions', async (status, attributes) => { |
| const execution = await createExecution({ status, ...attributes }, workflow); |
|
|
| await pruningService.softDelete(); |
|
|
| const result = await findAllExecutions(); |
| expect(result).toEqual([ |
| expect.objectContaining({ id: execution.id, deletedAt: expect.any(Date) }), |
| ]); |
| }); |
|
|
| test.each<[ExecutionStatus, Partial<ExecutionEntity>]>([ |
| ['new', {}], |
| ['running', { startedAt: yesterday }], |
| ['waiting', { startedAt: yesterday, stoppedAt: yesterday, waitTill: yesterday }], |
| ])('should not prune %s executions', async (status, attributes) => { |
| const executions = [ |
| await createExecution({ status, ...attributes }, workflow), |
| await createSuccessfulExecution(workflow), |
| ]; |
|
|
| await pruningService.softDelete(); |
|
|
| const result = await findAllExecutions(); |
| expect(result).toEqual([ |
| expect.objectContaining({ id: executions[0].id, deletedAt: null }), |
| expect.objectContaining({ id: executions[1].id, deletedAt: null }), |
| ]); |
| }); |
|
|
| test('should not prune annotated executions', async () => { |
| const executions = [ |
| await createExecution( |
| { finished: true, startedAt: yesterday, stoppedAt: yesterday, status: 'success' }, |
| workflow, |
| ), |
| await createExecution( |
| { finished: true, startedAt: yesterday, stoppedAt: yesterday, status: 'success' }, |
| workflow, |
| ), |
| await createExecution( |
| { finished: true, startedAt: now, stoppedAt: now, status: 'success' }, |
| workflow, |
| ), |
| ]; |
|
|
| await annotateExecution(executions[0].id, { vote: 'up' }, [workflow.id]); |
|
|
| await pruningService.softDelete(); |
|
|
| const result = await findAllExecutions(); |
| expect(result).toEqual([ |
| expect.objectContaining({ id: executions[0].id, deletedAt: null }), |
| expect.objectContaining({ id: executions[1].id, deletedAt: expect.any(Date) }), |
| expect.objectContaining({ id: executions[2].id, deletedAt: null }), |
| ]); |
| }); |
| }); |
| }); |
|
|