feat(core): Decouple insights pruning max age from license (#29527)

This commit is contained in:
Sandra Zollner
2026-05-04 07:03:47 +00:00
committed by GitHub
parent 88b3a0b3c6
commit 45c18fb09c
7 changed files with 42 additions and 93 deletions
@@ -1,4 +1,3 @@
import type { LicenseState } from '@n8n/backend-common';
import {
mockLogger,
createTeamProject,
@@ -41,20 +40,15 @@ describe('InsightsPruningService', () => {
let insightsConfig: InsightsConfig;
let insightsByPeriodRepository: InsightsByPeriodRepository;
let insightsPruningService: InsightsPruningService;
let licenseState: LicenseState;
beforeAll(async () => {
insightsConfig = Container.get(InsightsConfig);
insightsConfig.maxAgeDays = 10;
insightsConfig.pruneCheckIntervalHours = 1;
insightsByPeriodRepository = Container.get(InsightsByPeriodRepository);
licenseState = mock<LicenseState>({
getInsightsRetentionMaxAge: () => insightsConfig.maxAgeDays,
});
insightsPruningService = new InsightsPruningService(
insightsByPeriodRepository,
insightsConfig,
licenseState,
mockLogger(),
);
});
@@ -103,55 +97,25 @@ describe('InsightsPruningService', () => {
expect(await insightsByPeriodRepository.count()).toBe(1);
});
test.each<{ config: number; license: number; result: number }>([
{
config: -1,
license: -1,
result: Number.MAX_SAFE_INTEGER,
},
{
config: -1,
license: 5,
result: 5,
},
{
config: 5,
license: -1,
result: 5,
},
{
config: 5,
license: 10,
result: 5,
},
{
config: 10,
license: 5,
result: 5,
},
test.each<{ config: number; result: number }>([
{ config: -1, result: 730 },
{ config: 0, result: 365 },
{ config: 5, result: 5 },
{ config: 365, result: 365 },
{ config: 730, result: 730 },
{ config: 2000, result: 730 },
])(
'pruningMaxAgeInDays is minimal age between license and config max age',
async ({ config, license, result }) => {
// ARRANGE
const licenseState = mock<LicenseState>({
getInsightsRetentionMaxAge() {
return license;
},
});
'pruningMaxAgeInDays uses N8N_INSIGHTS_MAX_AGE_DAYS: -1 maps to cap, other values below 1 use default, finite values capped at 730',
async ({ config, result }) => {
const insightsPruningService = new InsightsPruningService(
insightsByPeriodRepository,
mock<InsightsConfig>({
maxAgeDays: config,
}),
licenseState,
mockLogger(),
);
// ACT
const maxAge = insightsPruningService.pruningMaxAgeInDays;
// ASSERT
expect(maxAge).toBe(result);
expect(insightsPruningService.pruningMaxAgeInDays).toBe(result);
},
);
});
@@ -1,4 +1,3 @@
import type { LicenseState } from '@n8n/backend-common';
import { mockLogger } from '@n8n/backend-test-utils';
import { Time } from '@n8n/constants';
import { mock } from 'jest-mock-extended';
@@ -11,20 +10,15 @@ describe('InsightsPruningService', () => {
let insightsConfig: InsightsConfig;
let insightsByPeriodRepository: InsightsByPeriodRepository;
let insightsPruningService: InsightsPruningService;
let licenseState: LicenseState;
beforeAll(() => {
insightsConfig = new InsightsConfig();
insightsConfig.maxAgeDays = 10;
insightsConfig.pruneCheckIntervalHours = 1;
insightsByPeriodRepository = mock<InsightsByPeriodRepository>();
licenseState = mock<LicenseState>({
getInsightsRetentionMaxAge: () => insightsConfig.maxAgeDays,
});
insightsPruningService = new InsightsPruningService(
insightsByPeriodRepository,
insightsConfig,
licenseState,
mockLogger(),
);
});
@@ -51,7 +45,6 @@ describe('InsightsPruningService', () => {
const insightsPruningService = new InsightsPruningService(
insightsByPeriodRepository,
insightsConfig,
licenseState,
mockLogger(),
);
const pruneSpy = jest.spyOn(insightsPruningService, 'pruneInsights');
@@ -75,7 +68,6 @@ describe('InsightsPruningService', () => {
const insightsPruningService = new InsightsPruningService(
insightsByPeriodRepository,
insightsConfig,
licenseState,
mockLogger(),
);
@@ -85,22 +85,15 @@ describe('InsightsService (Integration)', () => {
shutdownSpy.mockRestore();
});
const setupMocks = (
instanceType: InstanceType,
isLeader: boolean = false,
isPruningEnabled: boolean = false,
) => {
const setupMocks = (instanceType: InstanceType, isLeader: boolean = false) => {
(instanceSettings as any).instanceType = instanceType;
Object.defineProperty(instanceSettings, 'isLeader', {
get: jest.fn(() => isLeader),
});
Object.defineProperty(pruningService, 'isPruningEnabled', {
get: jest.fn(() => isPruningEnabled),
});
};
test('starts flushing timer for main instance', async () => {
setupMocks('main', false, false);
setupMocks('main', false);
await insightsService.init();
@@ -109,18 +102,8 @@ describe('InsightsService (Integration)', () => {
expect(pruningService.startPruningTimer).not.toHaveBeenCalled();
});
test('starts compaction and flushing timers for main leader instances', async () => {
setupMocks('main', true, false);
await insightsService.init();
expect(initSpy).toHaveBeenCalled();
expect(compactionService.startCompactionTimer).toHaveBeenCalled();
expect(pruningService.startPruningTimer).not.toHaveBeenCalled();
});
test('starts compaction, flushing and pruning timers for main leader instance with pruning enabled', async () => {
setupMocks('main', true, true);
test('starts compaction, flushing and pruning timers for main leader instances', async () => {
setupMocks('main', true);
await insightsService.init();
@@ -130,7 +113,7 @@ describe('InsightsService (Integration)', () => {
});
test('starts only collection flushing timer for webhook instance', async () => {
setupMocks('webhook', false, false);
setupMocks('webhook', false);
await insightsService.init();
@@ -140,7 +123,7 @@ describe('InsightsService (Integration)', () => {
});
test('do no start any timers for non-main instances', async () => {
setupMocks('worker', false, false);
setupMocks('worker', false);
await insightsService.init();
@@ -1,10 +1,11 @@
import { LicenseState, Logger } from '@n8n/backend-common';
import { Logger } from '@n8n/backend-common';
import { Time } from '@n8n/constants';
import { Service } from '@n8n/di';
import { strict } from 'assert';
import { InsightsByPeriodRepository } from './database/repositories/insights-by-period.repository';
import { InsightsConfig } from './insights.config';
import { INSIGHTS_MAX_AGE_DAYS_CAP, INSIGHTS_MAX_AGE_DAYS_DEFAULT } from './insights.constants';
@Service()
export class InsightsPruningService {
@@ -17,23 +18,26 @@ export class InsightsPruningService {
constructor(
private readonly insightsByPeriodRepository: InsightsByPeriodRepository,
private readonly config: InsightsConfig,
private readonly licenseState: LicenseState,
private readonly logger: Logger,
) {
this.logger = this.logger.scoped('insights');
}
get isPruningEnabled() {
return this.licenseState.getInsightsRetentionMaxAge() > -1 || this.config.maxAgeDays > -1;
}
get pruningMaxAgeInDays() {
const toMaxSafeIfUnlimited = (days: number) => (days === -1 ? Number.MAX_SAFE_INTEGER : days);
const configuredMaxAgeDays = this.config.maxAgeDays;
if (typeof configuredMaxAgeDays !== 'number' || !Number.isFinite(configuredMaxAgeDays)) {
return INSIGHTS_MAX_AGE_DAYS_DEFAULT;
}
const licenseMaxAge = toMaxSafeIfUnlimited(this.licenseState.getInsightsRetentionMaxAge());
const configMaxAge = toMaxSafeIfUnlimited(this.config.maxAgeDays);
if (configuredMaxAgeDays === -1) {
return INSIGHTS_MAX_AGE_DAYS_CAP;
}
return Math.min(licenseMaxAge, configMaxAge);
if (configuredMaxAgeDays < 1) {
return INSIGHTS_MAX_AGE_DAYS_DEFAULT;
}
return Math.min(configuredMaxAgeDays, INSIGHTS_MAX_AGE_DAYS_CAP);
}
startPruningTimer() {
@@ -1,5 +1,7 @@
import { Config, Env } from '@n8n/config';
import { INSIGHTS_MAX_AGE_DAYS_DEFAULT } from './insights.constants';
@Config
export class InsightsConfig {
/**
@@ -45,11 +47,11 @@ export class InsightsConfig {
flushIntervalSeconds: number = 30;
/**
* How old (days) insights data must be to qualify for regular deletion
* Default: -1 (no pruning)
* How old (days) insights data must be to qualify for regular deletion.
* Default: 365. Values are capped at 730 (two years).
*/
@Env('N8N_INSIGHTS_MAX_AGE_DAYS')
maxAgeDays: number = -1;
maxAgeDays: number = INSIGHTS_MAX_AGE_DAYS_DEFAULT;
/**
* How often (hours) insights data will be checked for regular deletion.
@@ -1,3 +1,9 @@
/** Default for `N8N_INSIGHTS_MAX_AGE_DAYS`; also used when the configured value is invalid or not a finite number ≥ 1. */
export const INSIGHTS_MAX_AGE_DAYS_DEFAULT = 365;
/** Maximum allowed value for `N8N_INSIGHTS_MAX_AGE_DAYS` when pruning (2 years). */
export const INSIGHTS_MAX_AGE_DAYS_CAP = 730;
export const INSIGHTS_DATE_RANGE_KEYS = [
'day',
'week',
@@ -52,9 +52,7 @@ export class InsightsService {
@OnLeaderTakeover()
startCompactionAndPruningTimers() {
this.compactionService.startCompactionTimer();
if (this.pruningService.isPruningEnabled) {
this.pruningService.startPruningTimer();
}
this.pruningService.startPruningTimer();
}
@OnLeaderStepdown()