mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-20 11:22:15 +00:00
feat(API): Implement compaction logic for insights (#14062)
Co-authored-by: Danny Martini <danny@n8n.io>
This commit is contained in:
committed by
GitHub
parent
e0f9506912
commit
d8433d2895
@@ -14,6 +14,12 @@ import { createTeamProject } from '@test-integration/db/projects';
|
|||||||
import { createWorkflow } from '@test-integration/db/workflows';
|
import { createWorkflow } from '@test-integration/db/workflows';
|
||||||
import * as testDb from '@test-integration/test-db';
|
import * as testDb from '@test-integration/test-db';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createMetadata,
|
||||||
|
createRawInsightsEvent,
|
||||||
|
createCompactedInsightsEvent,
|
||||||
|
createRawInsightsEvents,
|
||||||
|
} from '../entities/__tests__/db-utils';
|
||||||
import { InsightsService } from '../insights.service';
|
import { InsightsService } from '../insights.service';
|
||||||
import { InsightsByPeriodRepository } from '../repositories/insights-by-period.repository';
|
import { InsightsByPeriodRepository } from '../repositories/insights-by-period.repository';
|
||||||
|
|
||||||
@@ -30,13 +36,22 @@ async function truncateAll() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize DB once for all tests
|
||||||
|
beforeAll(async () => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
await testDb.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Terminate DB once after all tests complete
|
||||||
|
afterAll(async () => {
|
||||||
|
await testDb.terminate();
|
||||||
|
});
|
||||||
|
|
||||||
describe('workflowExecuteAfterHandler', () => {
|
describe('workflowExecuteAfterHandler', () => {
|
||||||
let insightsService: InsightsService;
|
let insightsService: InsightsService;
|
||||||
let insightsRawRepository: InsightsRawRepository;
|
let insightsRawRepository: InsightsRawRepository;
|
||||||
let insightsMetadataRepository: InsightsMetadataRepository;
|
let insightsMetadataRepository: InsightsMetadataRepository;
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await testDb.init();
|
|
||||||
|
|
||||||
insightsService = Container.get(InsightsService);
|
insightsService = Container.get(InsightsService);
|
||||||
insightsRawRepository = Container.get(InsightsRawRepository);
|
insightsRawRepository = Container.get(InsightsRawRepository);
|
||||||
insightsMetadataRepository = Container.get(InsightsMetadataRepository);
|
insightsMetadataRepository = Container.get(InsightsMetadataRepository);
|
||||||
@@ -245,3 +260,345 @@ describe('workflowExecuteAfterHandler', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('compaction', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await truncateAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('compactRawToHour', () => {
|
||||||
|
type TestData = {
|
||||||
|
name: string;
|
||||||
|
timestamps: DateTime[];
|
||||||
|
batches: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
test.each<TestData>([
|
||||||
|
{
|
||||||
|
name: 'compact into 2 rows',
|
||||||
|
timestamps: [
|
||||||
|
DateTime.utc(2000, 1, 1, 0, 0),
|
||||||
|
DateTime.utc(2000, 1, 1, 0, 59),
|
||||||
|
DateTime.utc(2000, 1, 1, 1, 0),
|
||||||
|
],
|
||||||
|
batches: [2, 1],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'compact into 3 rows',
|
||||||
|
timestamps: [
|
||||||
|
DateTime.utc(2000, 1, 1, 0, 0),
|
||||||
|
DateTime.utc(2000, 1, 1, 1, 0),
|
||||||
|
DateTime.utc(2000, 1, 1, 2, 0),
|
||||||
|
],
|
||||||
|
batches: [1, 1, 1],
|
||||||
|
},
|
||||||
|
])('$name', async ({ timestamps, batches }) => {
|
||||||
|
// ARRANGE
|
||||||
|
const insightsService = Container.get(InsightsService);
|
||||||
|
const insightsRawRepository = Container.get(InsightsRawRepository);
|
||||||
|
const insightsByPeriodRepository = Container.get(InsightsByPeriodRepository);
|
||||||
|
|
||||||
|
const project = await createTeamProject();
|
||||||
|
const workflow = await createWorkflow({}, project);
|
||||||
|
// create before so we can create the raw events in parallel
|
||||||
|
await createMetadata(workflow);
|
||||||
|
for (const timestamp of timestamps) {
|
||||||
|
await createRawInsightsEvent(workflow, { type: 'success', value: 1, timestamp });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ACT
|
||||||
|
const compactedRows = await insightsService.compactRawToHour();
|
||||||
|
|
||||||
|
// ASSERT
|
||||||
|
expect(compactedRows).toBe(timestamps.length);
|
||||||
|
await expect(insightsRawRepository.count()).resolves.toBe(0);
|
||||||
|
const allCompacted = await insightsByPeriodRepository.find({ order: { periodStart: 1 } });
|
||||||
|
expect(allCompacted).toHaveLength(batches.length);
|
||||||
|
for (const [index, compacted] of allCompacted.entries()) {
|
||||||
|
expect(compacted.value).toBe(batches[index]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('batch compaction split events in hourly insight periods', async () => {
|
||||||
|
// ARRANGE
|
||||||
|
const insightsService = Container.get(InsightsService);
|
||||||
|
const insightsRawRepository = Container.get(InsightsRawRepository);
|
||||||
|
const insightsByPeriodRepository = Container.get(InsightsByPeriodRepository);
|
||||||
|
|
||||||
|
const project = await createTeamProject();
|
||||||
|
const workflow = await createWorkflow({}, project);
|
||||||
|
|
||||||
|
const batchSize = 100;
|
||||||
|
|
||||||
|
let timestamp = DateTime.utc().startOf('hour');
|
||||||
|
for (let i = 0; i < batchSize; i++) {
|
||||||
|
await createRawInsightsEvent(workflow, { type: 'success', value: 1, timestamp });
|
||||||
|
// create 60 events per hour
|
||||||
|
timestamp = timestamp.plus({ minute: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ACT
|
||||||
|
await insightsService.compactInsights();
|
||||||
|
|
||||||
|
// ASSERT
|
||||||
|
await expect(insightsRawRepository.count()).resolves.toBe(0);
|
||||||
|
|
||||||
|
const allCompacted = await insightsByPeriodRepository.find({ order: { periodStart: 1 } });
|
||||||
|
const accumulatedValues = allCompacted.reduce((acc, event) => acc + event.value, 0);
|
||||||
|
expect(accumulatedValues).toBe(batchSize);
|
||||||
|
expect(allCompacted[0].value).toBe(60);
|
||||||
|
expect(allCompacted[1].value).toBe(40);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('batch compaction split events in hourly insight periods by type and workflow', async () => {
|
||||||
|
// ARRANGE
|
||||||
|
const insightsService = Container.get(InsightsService);
|
||||||
|
const insightsRawRepository = Container.get(InsightsRawRepository);
|
||||||
|
const insightsByPeriodRepository = Container.get(InsightsByPeriodRepository);
|
||||||
|
|
||||||
|
const project = await createTeamProject();
|
||||||
|
const workflow1 = await createWorkflow({}, project);
|
||||||
|
const workflow2 = await createWorkflow({}, project);
|
||||||
|
|
||||||
|
const batchSize = 100;
|
||||||
|
|
||||||
|
let timestamp = DateTime.utc().startOf('hour');
|
||||||
|
for (let i = 0; i < batchSize / 4; i++) {
|
||||||
|
await createRawInsightsEvent(workflow1, { type: 'success', value: 1, timestamp });
|
||||||
|
timestamp = timestamp.plus({ minute: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < batchSize / 4; i++) {
|
||||||
|
await createRawInsightsEvent(workflow1, { type: 'failure', value: 1, timestamp });
|
||||||
|
timestamp = timestamp.plus({ minute: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < batchSize / 4; i++) {
|
||||||
|
await createRawInsightsEvent(workflow2, { type: 'runtime_ms', value: 1200, timestamp });
|
||||||
|
timestamp = timestamp.plus({ minute: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < batchSize / 4; i++) {
|
||||||
|
await createRawInsightsEvent(workflow2, { type: 'time_saved_min', value: 3, timestamp });
|
||||||
|
timestamp = timestamp.plus({ minute: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ACT
|
||||||
|
await insightsService.compactInsights();
|
||||||
|
|
||||||
|
// ASSERT
|
||||||
|
await expect(insightsRawRepository.count()).resolves.toBe(0);
|
||||||
|
|
||||||
|
const allCompacted = await insightsByPeriodRepository.find({
|
||||||
|
order: { metaId: 'ASC', periodStart: 'ASC' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Expect 2 insights for workflow 1 (for success and failure)
|
||||||
|
// and 3 for workflow 2 (2 period starts for runtime_ms and 1 for time_saved_min)
|
||||||
|
expect(allCompacted).toHaveLength(5);
|
||||||
|
const metaIds = allCompacted.map((event) => event.metaId);
|
||||||
|
|
||||||
|
// meta id are ordered. first 2 are for workflow 1, last 3 are for workflow 2
|
||||||
|
const uniqueMetaIds = [metaIds[0], metaIds[2]];
|
||||||
|
const workflow1Insights = allCompacted.filter((event) => event.metaId === uniqueMetaIds[0]);
|
||||||
|
const workflow2Insights = allCompacted.filter((event) => event.metaId === uniqueMetaIds[1]);
|
||||||
|
|
||||||
|
expect(workflow1Insights).toHaveLength(2);
|
||||||
|
expect(workflow2Insights).toHaveLength(3);
|
||||||
|
|
||||||
|
const successInsights = workflow1Insights.find((event) => event.type === 'success');
|
||||||
|
const failureInsights = workflow1Insights.find((event) => event.type === 'failure');
|
||||||
|
|
||||||
|
expect(successInsights).toBeTruthy();
|
||||||
|
expect(failureInsights).toBeTruthy();
|
||||||
|
// success and failure insights should have the value matching the number or raw events (because value = 1)
|
||||||
|
expect(successInsights!.value).toBe(25);
|
||||||
|
expect(failureInsights!.value).toBe(25);
|
||||||
|
|
||||||
|
const runtimeMsEvents = workflow2Insights.filter((event) => event.type === 'runtime_ms');
|
||||||
|
const timeSavedMinEvents = workflow2Insights.find((event) => event.type === 'time_saved_min');
|
||||||
|
expect(runtimeMsEvents).toHaveLength(2);
|
||||||
|
|
||||||
|
// The last 10 minutes of the first hour
|
||||||
|
expect(runtimeMsEvents[0].value).toBe(1200 * 10);
|
||||||
|
|
||||||
|
// The first 15 minutes of the second hour
|
||||||
|
expect(runtimeMsEvents[1].value).toBe(1200 * 15);
|
||||||
|
expect(timeSavedMinEvents).toBeTruthy();
|
||||||
|
expect(timeSavedMinEvents!.value).toBe(3 * 25);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return the number of compacted events', async () => {
|
||||||
|
// ARRANGE
|
||||||
|
const insightsService = Container.get(InsightsService);
|
||||||
|
|
||||||
|
const project = await createTeamProject();
|
||||||
|
const workflow = await createWorkflow({}, project);
|
||||||
|
|
||||||
|
const batchSize = 100;
|
||||||
|
|
||||||
|
let timestamp = DateTime.utc(2000, 1, 1, 0, 0);
|
||||||
|
for (let i = 0; i < batchSize; i++) {
|
||||||
|
await createRawInsightsEvent(workflow, { type: 'success', value: 1, timestamp });
|
||||||
|
// create 60 events per hour
|
||||||
|
timestamp = timestamp.plus({ minute: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ACT
|
||||||
|
const numberOfCompactedData = await insightsService.compactRawToHour();
|
||||||
|
|
||||||
|
// ASSERT
|
||||||
|
expect(numberOfCompactedData).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('works with data in the compacted table', async () => {
|
||||||
|
// ARRANGE
|
||||||
|
const insightsService = Container.get(InsightsService);
|
||||||
|
const insightsRawRepository = Container.get(InsightsRawRepository);
|
||||||
|
const insightsByPeriodRepository = Container.get(InsightsByPeriodRepository);
|
||||||
|
|
||||||
|
const project = await createTeamProject();
|
||||||
|
const workflow = await createWorkflow({}, project);
|
||||||
|
|
||||||
|
const batchSize = 100;
|
||||||
|
|
||||||
|
let timestamp = DateTime.utc().startOf('hour');
|
||||||
|
|
||||||
|
// Create an existing compacted event for the first hour
|
||||||
|
await createCompactedInsightsEvent(workflow, {
|
||||||
|
type: 'success',
|
||||||
|
value: 10,
|
||||||
|
periodUnit: 'hour',
|
||||||
|
periodStart: timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
const events = Array<{ type: 'success'; value: number; timestamp: DateTime }>();
|
||||||
|
for (let i = 0; i < batchSize; i++) {
|
||||||
|
events.push({ type: 'success', value: 1, timestamp });
|
||||||
|
timestamp = timestamp.plus({ minute: 1 });
|
||||||
|
}
|
||||||
|
await createRawInsightsEvents(workflow, events);
|
||||||
|
|
||||||
|
// ACT
|
||||||
|
await insightsService.compactInsights();
|
||||||
|
|
||||||
|
// ASSERT
|
||||||
|
await expect(insightsRawRepository.count()).resolves.toBe(0);
|
||||||
|
|
||||||
|
const allCompacted = await insightsByPeriodRepository.find({ order: { periodStart: 1 } });
|
||||||
|
const accumulatedValues = allCompacted.reduce((acc, event) => acc + event.value, 0);
|
||||||
|
expect(accumulatedValues).toBe(batchSize + 10);
|
||||||
|
expect(allCompacted[0].value).toBe(70);
|
||||||
|
expect(allCompacted[1].value).toBe(40);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('works with data bigger than the batch size', async () => {
|
||||||
|
// ARRANGE
|
||||||
|
const insightsService = Container.get(InsightsService);
|
||||||
|
const insightsRawRepository = Container.get(InsightsRawRepository);
|
||||||
|
const insightsByPeriodRepository = Container.get(InsightsByPeriodRepository);
|
||||||
|
|
||||||
|
// spy on the compactRawToHour method to check if it's called multiple times
|
||||||
|
const rawToHourSpy = jest.spyOn(insightsService, 'compactRawToHour');
|
||||||
|
|
||||||
|
const project = await createTeamProject();
|
||||||
|
const workflow = await createWorkflow({}, project);
|
||||||
|
|
||||||
|
const batchSize = 600;
|
||||||
|
|
||||||
|
let timestamp = DateTime.utc().startOf('hour');
|
||||||
|
const events = Array<{ type: 'success'; value: number; timestamp: DateTime }>();
|
||||||
|
for (let i = 0; i < batchSize; i++) {
|
||||||
|
events.push({ type: 'success', value: 1, timestamp });
|
||||||
|
timestamp = timestamp.plus({ minute: 1 });
|
||||||
|
}
|
||||||
|
await createRawInsightsEvents(workflow, events);
|
||||||
|
|
||||||
|
// ACT
|
||||||
|
await insightsService.compactInsights();
|
||||||
|
|
||||||
|
// ASSERT
|
||||||
|
expect(rawToHourSpy).toHaveBeenCalledTimes(3);
|
||||||
|
await expect(insightsRawRepository.count()).resolves.toBe(0);
|
||||||
|
const allCompacted = await insightsByPeriodRepository.find({ order: { periodStart: 1 } });
|
||||||
|
const accumulatedValues = allCompacted.reduce((acc, event) => acc + event.value, 0);
|
||||||
|
expect(accumulatedValues).toBe(batchSize);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('compaction is running on schedule', async () => {
|
||||||
|
// ARRANGE
|
||||||
|
const insightsService = Container.get(InsightsService);
|
||||||
|
|
||||||
|
// spy on the compactInsights method to check if it's called
|
||||||
|
insightsService.compactInsights = jest.fn();
|
||||||
|
|
||||||
|
// ACT
|
||||||
|
// advance by 1 hour and 1 minute
|
||||||
|
jest.advanceTimersByTime(1000 * 60 * 60);
|
||||||
|
|
||||||
|
// ASSERT
|
||||||
|
expect(insightsService.compactInsights).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('compactHourToDay', () => {
|
||||||
|
type TestData = {
|
||||||
|
name: string;
|
||||||
|
periodStarts: DateTime[];
|
||||||
|
batches: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
test.each<TestData>([
|
||||||
|
{
|
||||||
|
name: 'compact into 2 rows',
|
||||||
|
periodStarts: [
|
||||||
|
DateTime.utc(2000, 1, 1, 0, 0),
|
||||||
|
DateTime.utc(2000, 1, 1, 23, 59),
|
||||||
|
DateTime.utc(2000, 1, 2, 1, 0),
|
||||||
|
],
|
||||||
|
batches: [2, 1],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'compact into 3 rows',
|
||||||
|
periodStarts: [
|
||||||
|
DateTime.utc(2000, 1, 1, 0, 0),
|
||||||
|
DateTime.utc(2000, 1, 1, 23, 59),
|
||||||
|
DateTime.utc(2000, 1, 2, 0, 0),
|
||||||
|
DateTime.utc(2000, 1, 2, 23, 59),
|
||||||
|
DateTime.utc(2000, 1, 3, 23, 59),
|
||||||
|
],
|
||||||
|
batches: [2, 2, 1],
|
||||||
|
},
|
||||||
|
])('$name', async ({ periodStarts, batches }) => {
|
||||||
|
// ARRANGE
|
||||||
|
const insightsService = Container.get(InsightsService);
|
||||||
|
const insightsRawRepository = Container.get(InsightsRawRepository);
|
||||||
|
const insightsByPeriodRepository = Container.get(InsightsByPeriodRepository);
|
||||||
|
|
||||||
|
const project = await createTeamProject();
|
||||||
|
const workflow = await createWorkflow({}, project);
|
||||||
|
// create before so we can create the raw events in parallel
|
||||||
|
await createMetadata(workflow);
|
||||||
|
for (const periodStart of periodStarts) {
|
||||||
|
await createCompactedInsightsEvent(workflow, {
|
||||||
|
type: 'success',
|
||||||
|
value: 1,
|
||||||
|
periodUnit: 'hour',
|
||||||
|
periodStart,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ACT
|
||||||
|
const compactedRows = await insightsService.compactHourToDay();
|
||||||
|
|
||||||
|
// ASSERT
|
||||||
|
expect(compactedRows).toBe(periodStarts.length);
|
||||||
|
await expect(insightsRawRepository.count()).resolves.toBe(0);
|
||||||
|
const allCompacted = await insightsByPeriodRepository.find({ order: { periodStart: 1 } });
|
||||||
|
expect(allCompacted).toHaveLength(batches.length);
|
||||||
|
for (const [index, compacted] of allCompacted.entries()) {
|
||||||
|
expect(compacted.value).toBe(batches[index]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { GlobalConfig } from '@n8n/config';
|
||||||
import { Container } from '@n8n/di';
|
import { Container } from '@n8n/di';
|
||||||
import type { DateTime } from 'luxon';
|
import type { DateTime } from 'luxon';
|
||||||
import type { IWorkflowBase } from 'n8n-workflow';
|
import type { IWorkflowBase } from 'n8n-workflow';
|
||||||
@@ -7,8 +8,10 @@ import { SharedWorkflowRepository } from '@/databases/repositories/shared-workfl
|
|||||||
|
|
||||||
import { InsightsMetadata } from '../../entities/insights-metadata';
|
import { InsightsMetadata } from '../../entities/insights-metadata';
|
||||||
import { InsightsRaw } from '../../entities/insights-raw';
|
import { InsightsRaw } from '../../entities/insights-raw';
|
||||||
|
import { InsightsByPeriodRepository } from '../../repositories/insights-by-period.repository';
|
||||||
import { InsightsMetadataRepository } from '../../repositories/insights-metadata.repository';
|
import { InsightsMetadataRepository } from '../../repositories/insights-metadata.repository';
|
||||||
import { InsightsRawRepository } from '../../repositories/insights-raw.repository';
|
import { InsightsRawRepository } from '../../repositories/insights-raw.repository';
|
||||||
|
import { InsightsByPeriod } from '../insights-by-period';
|
||||||
|
|
||||||
async function getWorkflowSharing(workflow: IWorkflowBase) {
|
async function getWorkflowSharing(workflow: IWorkflowBase) {
|
||||||
return await Container.get(SharedWorkflowRepository).find({
|
return await Container.get(SharedWorkflowRepository).find({
|
||||||
@@ -16,6 +19,7 @@ async function getWorkflowSharing(workflow: IWorkflowBase) {
|
|||||||
relations: { project: true },
|
relations: { project: true },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
export const { type: dbType } = Container.get(GlobalConfig).database;
|
||||||
|
|
||||||
export async function createMetadata(workflow: WorkflowEntity) {
|
export async function createMetadata(workflow: WorkflowEntity) {
|
||||||
const insightsMetadataRepository = Container.get(InsightsMetadataRepository);
|
const insightsMetadataRepository = Container.get(InsightsMetadataRepository);
|
||||||
@@ -62,3 +66,49 @@ export async function createRawInsightsEvent(
|
|||||||
}
|
}
|
||||||
return await insightsRawRepository.save(event);
|
return await insightsRawRepository.save(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createRawInsightsEvents(
|
||||||
|
workflow: WorkflowEntity,
|
||||||
|
parametersArray: Array<{
|
||||||
|
type: InsightsRaw['type'];
|
||||||
|
value: number;
|
||||||
|
timestamp?: DateTime;
|
||||||
|
}>,
|
||||||
|
) {
|
||||||
|
const insightsRawRepository = Container.get(InsightsRawRepository);
|
||||||
|
const metadata = await createMetadata(workflow);
|
||||||
|
|
||||||
|
const events = parametersArray.map((parameters) => {
|
||||||
|
const event = new InsightsRaw();
|
||||||
|
event.metaId = metadata.metaId;
|
||||||
|
event.type = parameters.type;
|
||||||
|
event.value = parameters.value;
|
||||||
|
if (parameters.timestamp) {
|
||||||
|
event.timestamp = parameters.timestamp.toUTC().toJSDate();
|
||||||
|
}
|
||||||
|
return event;
|
||||||
|
});
|
||||||
|
await insightsRawRepository.save(events);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCompactedInsightsEvent(
|
||||||
|
workflow: WorkflowEntity,
|
||||||
|
parameters: {
|
||||||
|
type: InsightsByPeriod['type'];
|
||||||
|
value: number;
|
||||||
|
periodUnit: InsightsByPeriod['periodUnit'];
|
||||||
|
periodStart: DateTime;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const insightsByPeriodRepository = Container.get(InsightsByPeriodRepository);
|
||||||
|
const metadata = await createMetadata(workflow);
|
||||||
|
|
||||||
|
const event = new InsightsByPeriod();
|
||||||
|
event.metaId = metadata.metaId;
|
||||||
|
event.type = parameters.type;
|
||||||
|
event.value = parameters.value;
|
||||||
|
event.periodUnit = parameters.periodUnit;
|
||||||
|
event.periodStart = parameters.periodStart.toUTC().startOf(parameters.periodUnit).toJSDate();
|
||||||
|
|
||||||
|
return await insightsByPeriodRepository.save(event);
|
||||||
|
}
|
||||||
|
|||||||
18
packages/cli/src/modules/insights/insights.config.ts
Normal file
18
packages/cli/src/modules/insights/insights.config.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Config, Env } from '@n8n/config/src/decorators';
|
||||||
|
|
||||||
|
@Config
|
||||||
|
export class InsightsConfig {
|
||||||
|
/**
|
||||||
|
* The interval in minutes at which the insights data should be compacted.
|
||||||
|
* Default: 60
|
||||||
|
*/
|
||||||
|
@Env('N8N_INSIGHTS_COMPACTION_INTERVAL_MINUTES')
|
||||||
|
compactionIntervalMinutes: number = 60;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The number of raw insights data to compact in a single batch.
|
||||||
|
* Default: 500
|
||||||
|
*/
|
||||||
|
@Env('N8N_INSIGHTS_COMPACTION_BATCH_SIZE')
|
||||||
|
compactionBatchSize: number = 500;
|
||||||
|
}
|
||||||
@@ -1,13 +1,20 @@
|
|||||||
import { Service } from '@n8n/di';
|
import { Container, Service } from '@n8n/di';
|
||||||
import type { ExecutionLifecycleHooks } from 'n8n-core';
|
import type { ExecutionLifecycleHooks } from 'n8n-core';
|
||||||
import { UnexpectedError } from 'n8n-workflow';
|
|
||||||
import type { ExecutionStatus, IRun, WorkflowExecuteMode } from 'n8n-workflow';
|
import type { ExecutionStatus, IRun, WorkflowExecuteMode } from 'n8n-workflow';
|
||||||
|
import { UnexpectedError } from 'n8n-workflow';
|
||||||
|
|
||||||
import { SharedWorkflow } from '@/databases/entities/shared-workflow';
|
import { SharedWorkflow } from '@/databases/entities/shared-workflow';
|
||||||
import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository';
|
import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository';
|
||||||
|
import { OnShutdown } from '@/decorators/on-shutdown';
|
||||||
import { InsightsMetadata } from '@/modules/insights/entities/insights-metadata';
|
import { InsightsMetadata } from '@/modules/insights/entities/insights-metadata';
|
||||||
import { InsightsRaw } from '@/modules/insights/entities/insights-raw';
|
import { InsightsRaw } from '@/modules/insights/entities/insights-raw';
|
||||||
|
|
||||||
|
import { InsightsConfig } from './insights.config';
|
||||||
|
import { InsightsByPeriodRepository } from './repositories/insights-by-period.repository';
|
||||||
|
import { InsightsRawRepository } from './repositories/insights-raw.repository';
|
||||||
|
|
||||||
|
const config = Container.get(InsightsConfig);
|
||||||
|
|
||||||
const shouldSkipStatus: Record<ExecutionStatus, boolean> = {
|
const shouldSkipStatus: Record<ExecutionStatus, boolean> = {
|
||||||
success: false,
|
success: false,
|
||||||
crashed: false,
|
crashed: false,
|
||||||
@@ -35,7 +42,27 @@ const shouldSkipMode: Record<WorkflowExecuteMode, boolean> = {
|
|||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export class InsightsService {
|
export class InsightsService {
|
||||||
constructor(private readonly sharedWorkflowRepository: SharedWorkflowRepository) {}
|
private compactInsightsTimer: NodeJS.Timer | undefined;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly sharedWorkflowRepository: SharedWorkflowRepository,
|
||||||
|
private readonly insightsByPeriodRepository: InsightsByPeriodRepository,
|
||||||
|
private readonly insightsRawRepository: InsightsRawRepository,
|
||||||
|
) {
|
||||||
|
const intervalMilliseconds = config.compactionIntervalMinutes * 60 * 1000;
|
||||||
|
this.compactInsightsTimer = setInterval(
|
||||||
|
async () => await this.compactInsights(),
|
||||||
|
intervalMilliseconds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnShutdown()
|
||||||
|
shutdown() {
|
||||||
|
if (this.compactInsightsTimer !== undefined) {
|
||||||
|
clearInterval(this.compactInsightsTimer);
|
||||||
|
this.compactInsightsTimer = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async workflowExecuteAfterHandler(ctx: ExecutionLifecycleHooks, fullRunData: IRun) {
|
async workflowExecuteAfterHandler(ctx: ExecutionLifecycleHooks, fullRunData: IRun) {
|
||||||
if (shouldSkipStatus[fullRunData.status] || shouldSkipMode[fullRunData.mode]) {
|
if (shouldSkipStatus[fullRunData.status] || shouldSkipMode[fullRunData.mode]) {
|
||||||
@@ -107,4 +134,48 @@ export class InsightsService {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async compactInsights() {
|
||||||
|
let numberOfCompactedRawData: number;
|
||||||
|
|
||||||
|
// Compact raw data to hourly aggregates
|
||||||
|
do {
|
||||||
|
numberOfCompactedRawData = await this.compactRawToHour();
|
||||||
|
} while (numberOfCompactedRawData > 0);
|
||||||
|
|
||||||
|
let numberOfCompactedHourData: number;
|
||||||
|
|
||||||
|
// Compact hourly data to daily aggregates
|
||||||
|
do {
|
||||||
|
numberOfCompactedHourData = await this.compactHourToDay();
|
||||||
|
} while (numberOfCompactedHourData > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compacts raw data to hourly aggregates
|
||||||
|
async compactRawToHour() {
|
||||||
|
// Build the query to gather raw insights data for the batch
|
||||||
|
const batchQuery = this.insightsRawRepository.getRawInsightsBatchQuery(
|
||||||
|
config.compactionBatchSize,
|
||||||
|
);
|
||||||
|
|
||||||
|
return await this.insightsByPeriodRepository.compactSourceDataIntoInsightPeriod({
|
||||||
|
sourceBatchQuery: batchQuery.getSql(),
|
||||||
|
sourceTableName: this.insightsRawRepository.metadata.tableName,
|
||||||
|
periodUnit: 'hour',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compacts hourly data to daily aggregates
|
||||||
|
async compactHourToDay() {
|
||||||
|
// get hour data query for batching
|
||||||
|
const batchQuery = this.insightsByPeriodRepository.getPeriodInsightsBatchQuery(
|
||||||
|
'hour',
|
||||||
|
config.compactionBatchSize,
|
||||||
|
);
|
||||||
|
|
||||||
|
return await this.insightsByPeriodRepository.compactSourceDataIntoInsightPeriod({
|
||||||
|
sourceBatchQuery: batchQuery.getSql(),
|
||||||
|
periodUnit: 'day',
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,164 @@
|
|||||||
import { Service } from '@n8n/di';
|
import { GlobalConfig } from '@n8n/config';
|
||||||
|
import { Container, Service } from '@n8n/di';
|
||||||
import { DataSource, Repository } from '@n8n/typeorm';
|
import { DataSource, Repository } from '@n8n/typeorm';
|
||||||
|
|
||||||
|
import { sql } from '@/utils/sql';
|
||||||
|
|
||||||
import { InsightsByPeriod } from '../entities/insights-by-period';
|
import { InsightsByPeriod } from '../entities/insights-by-period';
|
||||||
|
import type { PeriodUnits } from '../entities/insights-shared';
|
||||||
|
import { PeriodUnitToNumber } from '../entities/insights-shared';
|
||||||
|
|
||||||
|
const dbType = Container.get(GlobalConfig).database.type;
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export class InsightsByPeriodRepository extends Repository<InsightsByPeriod> {
|
export class InsightsByPeriodRepository extends Repository<InsightsByPeriod> {
|
||||||
constructor(dataSource: DataSource) {
|
constructor(dataSource: DataSource) {
|
||||||
super(InsightsByPeriod, dataSource.manager);
|
super(InsightsByPeriod, dataSource.manager);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private escapeField(fieldName: string) {
|
||||||
|
return this.manager.connection.driver.escape(fieldName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getPeriodFilterExpr(periodUnit: PeriodUnits) {
|
||||||
|
const daysAgo = periodUnit === 'day' ? 90 : 180;
|
||||||
|
// Database-specific period start expression to filter out data to compact by days matching the periodUnit
|
||||||
|
let periodStartExpr = `date('now', '-${daysAgo} days')`;
|
||||||
|
if (dbType === 'postgresdb') {
|
||||||
|
periodStartExpr = `CURRENT_DATE - INTERVAL '${daysAgo} day'`;
|
||||||
|
} else if (dbType === 'mysqldb' || dbType === 'mariadb') {
|
||||||
|
periodStartExpr = `DATE_SUB(CURRENT_DATE, INTERVAL ${daysAgo} DAY)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return periodStartExpr;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getPeriodStartExpr(periodUnit: PeriodUnits) {
|
||||||
|
// Database-specific period start expression to truncate timestamp to the periodUnit
|
||||||
|
// SQLite by default
|
||||||
|
let periodStartExpr = `strftime('%Y-%m-%d ${periodUnit === 'hour' ? '%H' : '00'}:00:00.000', periodStart)`;
|
||||||
|
if (dbType === 'mysqldb' || dbType === 'mariadb') {
|
||||||
|
periodStartExpr =
|
||||||
|
periodUnit === 'hour'
|
||||||
|
? "DATE_FORMAT(periodStart, '%Y-%m-%d %H:00:00')"
|
||||||
|
: "DATE_FORMAT(periodStart, '%Y-%m-%d 00:00:00')";
|
||||||
|
} else if (dbType === 'postgresdb') {
|
||||||
|
periodStartExpr = `DATE_TRUNC('${periodUnit}', ${this.escapeField('periodStart')})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return periodStartExpr;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPeriodInsightsBatchQuery(periodUnit: PeriodUnits, compactionBatchSize: number) {
|
||||||
|
// Build the query to gather period insights data for the batch
|
||||||
|
const batchQuery = this.createQueryBuilder()
|
||||||
|
.select(
|
||||||
|
['id', 'metaId', 'type', 'periodStart', 'value'].map((fieldName) =>
|
||||||
|
this.escapeField(fieldName),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.where(`${this.escapeField('periodUnit')} = ${PeriodUnitToNumber[periodUnit]}`)
|
||||||
|
.andWhere(`${this.escapeField('periodStart')} < ${this.getPeriodFilterExpr('day')}`)
|
||||||
|
.orderBy(this.escapeField('periodStart'), 'ASC')
|
||||||
|
.limit(compactionBatchSize);
|
||||||
|
return batchQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAggregationQuery(periodUnit: PeriodUnits) {
|
||||||
|
// Get the start period expression depending on the period unit and database type
|
||||||
|
const periodStartExpr = this.getPeriodStartExpr(periodUnit);
|
||||||
|
|
||||||
|
// Function to get the aggregation query
|
||||||
|
const aggregationQuery = this.manager
|
||||||
|
.createQueryBuilder()
|
||||||
|
.select(this.escapeField('metaId'))
|
||||||
|
.addSelect(this.escapeField('type'))
|
||||||
|
.addSelect(PeriodUnitToNumber[periodUnit].toString(), 'periodUnit')
|
||||||
|
.addSelect(periodStartExpr, 'periodStart')
|
||||||
|
.addSelect(`SUM(${this.escapeField('value')})`, 'value')
|
||||||
|
.from('rows_to_compact', 'rtc')
|
||||||
|
.groupBy(this.escapeField('metaId'))
|
||||||
|
.addGroupBy(this.escapeField('type'))
|
||||||
|
.addGroupBy(periodStartExpr);
|
||||||
|
|
||||||
|
return aggregationQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
async compactSourceDataIntoInsightPeriod({
|
||||||
|
sourceBatchQuery, // Query to get batch source data. Must return those fields: 'id', 'metaId', 'type', 'periodStart', 'value'
|
||||||
|
sourceTableName = this.metadata.tableName, // Repository references for table operations
|
||||||
|
periodUnit,
|
||||||
|
}: {
|
||||||
|
sourceBatchQuery: string;
|
||||||
|
sourceTableName?: string;
|
||||||
|
periodUnit: PeriodUnits;
|
||||||
|
}): Promise<number> {
|
||||||
|
// Create temp table that only exists in this transaction for rows to compact
|
||||||
|
const getBatchAndStoreInTemporaryTable = sql`
|
||||||
|
CREATE TEMPORARY TABLE rows_to_compact AS
|
||||||
|
${sourceBatchQuery};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const countBatch = sql`
|
||||||
|
SELECT COUNT(*) ${this.escapeField('rowsInBatch')} FROM rows_to_compact;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const targetColumnNamesStr = ['metaId', 'type', 'periodUnit', 'periodStart']
|
||||||
|
.map((param) => this.escapeField(param))
|
||||||
|
.join(', ');
|
||||||
|
const targetColumnNamesWithValue = `${targetColumnNamesStr}, value`;
|
||||||
|
|
||||||
|
// Function to get the aggregation query
|
||||||
|
const aggregationQuery = this.getAggregationQuery(periodUnit);
|
||||||
|
|
||||||
|
// Insert or update aggregated data
|
||||||
|
const insertQueryBase = sql`
|
||||||
|
INSERT INTO ${this.metadata.tableName}
|
||||||
|
(${targetColumnNamesWithValue})
|
||||||
|
${aggregationQuery.getSql()}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Database-specific duplicate key logic
|
||||||
|
let deduplicateQuery: string;
|
||||||
|
if (dbType === 'mysqldb' || dbType === 'mariadb') {
|
||||||
|
deduplicateQuery = sql`
|
||||||
|
ON DUPLICATE KEY UPDATE value = value + VALUES(value)`;
|
||||||
|
} else {
|
||||||
|
deduplicateQuery = sql`
|
||||||
|
ON CONFLICT(${targetColumnNamesStr})
|
||||||
|
DO UPDATE SET value = ${this.metadata.tableName}.value + excluded.value
|
||||||
|
RETURNING *`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const upsertEvents = sql`
|
||||||
|
${insertQueryBase}
|
||||||
|
${deduplicateQuery}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Delete the processed rows
|
||||||
|
const deleteBatch = sql`
|
||||||
|
DELETE FROM ${sourceTableName}
|
||||||
|
WHERE id IN (SELECT id FROM rows_to_compact);
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
const dropTemporaryTable = sql`
|
||||||
|
DROP TABLE rows_to_compact;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.manager.transaction(async (trx) => {
|
||||||
|
await trx.query(getBatchAndStoreInTemporaryTable);
|
||||||
|
|
||||||
|
await trx.query<Array<{ type: any; value: number }>>(upsertEvents);
|
||||||
|
|
||||||
|
const rowsInBatch = await trx.query<[{ rowsInBatch: number | string }]>(countBatch);
|
||||||
|
|
||||||
|
await trx.query(deleteBatch);
|
||||||
|
await trx.query(dropTemporaryTable);
|
||||||
|
|
||||||
|
return Number(rowsInBatch[0].rowsInBatch);
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,4 +8,18 @@ export class InsightsRawRepository extends Repository<InsightsRaw> {
|
|||||||
constructor(dataSource: DataSource) {
|
constructor(dataSource: DataSource) {
|
||||||
super(InsightsRaw, dataSource.manager);
|
super(InsightsRaw, dataSource.manager);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getRawInsightsBatchQuery(compactionBatchSize: number) {
|
||||||
|
// Build the query to gather raw insights data for the batch
|
||||||
|
const batchQuery = this.createQueryBuilder()
|
||||||
|
.select(
|
||||||
|
['id', 'metaId', 'type', 'value'].map((fieldName) =>
|
||||||
|
this.manager.connection.driver.escape(fieldName),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.addSelect('timestamp', 'periodStart')
|
||||||
|
.orderBy('timestamp', 'ASC')
|
||||||
|
.limit(compactionBatchSize);
|
||||||
|
return batchQuery;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user