perf(core): Aggregate workflow statistics to avoid hot-row contention (#33045)

This commit is contained in:
Iván Ovejero 2026-07-06 13:11:18 +02:00 committed by GitHub
parent b610b42558
commit 378710072f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 1245 additions and 88 deletions

View File

@ -119,6 +119,7 @@ Auto-generated from the PostgreSQL migrations in @n8n/db. Do not edit by hand.
| [public.workflow_publish_history](public.workflow_publish_history.md) | 6 | | BASE TABLE |
| [public.workflow_published_version](public.workflow_published_version.md) | 4 | | BASE TABLE |
| [public.workflow_statistics](public.workflow_statistics.md) | 7 | | BASE TABLE |
| [public.workflow_statistics_delta](public.workflow_statistics_delta.md) | 6 | | BASE TABLE |
| [public.workflows_tags](public.workflows_tags.md) | 2 | | BASE TABLE |
## Stored procedures and functions
@ -1372,6 +1373,14 @@ erDiagram
varchar_36_ workflowId
varchar_128_ workflowName
}
"public.workflow_statistics_delta" {
timestamp_3__with_time_zone createdAt
bigint id
varchar_128_ name
smallint rootCountDelta
varchar_36_ workflowId
varchar_128_ workflowName
}
"public.workflows_tags" {
varchar_36_ tagId FK
varchar_36_ workflowId FK

View File

@ -0,0 +1,49 @@
# public.workflow_statistics_delta
## Columns
| Name | Type | Default | Nullable | Children | Parents | Comment |
| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
| createdAt | timestamp(3) with time zone | CURRENT_TIMESTAMP | false | | | |
| id | bigint | | false | | | |
| name | varchar(128) | | false | | | |
| rootCountDelta | smallint | | false | | | |
| workflowId | varchar(36) | | false | | | |
| workflowName | varchar(128) | | true | | | |
## Constraints
| Name | Type | Definition |
| ---- | ---- | ---------- |
| workflow_statistics_delta_createdAt_not_null | n | NOT NULL "createdAt" |
| workflow_statistics_delta_id_not_null | n | NOT NULL id |
| workflow_statistics_delta_name_not_null | n | NOT NULL name |
| workflow_statistics_delta_pkey | PRIMARY KEY | PRIMARY KEY (id) |
| workflow_statistics_delta_rootCountDelta_not_null | n | NOT NULL "rootCountDelta" |
| workflow_statistics_delta_workflowId_not_null | n | NOT NULL "workflowId" |
## Indexes
| Name | Definition |
| ---- | ---------- |
| workflow_statistics_delta_pkey | CREATE UNIQUE INDEX workflow_statistics_delta_pkey ON public.workflow_statistics_delta USING btree (id) |
## Relations
```mermaid
erDiagram
"public.workflow_statistics_delta" {
timestamp_3__with_time_zone createdAt
bigint id
varchar_128_ name
smallint rootCountDelta
varchar_36_ workflowId
varchar_128_ workflowName
}
```
---
> Generated by [tbls](https://github.com/k1LoW/tbls)

View File

@ -172,7 +172,8 @@ type EntityName =
| 'DynamicCredentialUserEntry'
| 'TokenExchangeJti'
| 'TrustedKeySourceEntity'
| 'TrustedKeyEntity';
| 'TrustedKeyEntity'
| 'WorkflowStatisticsDelta';
/**
* Truncate specific DB tables in a test DB.
@ -204,6 +205,16 @@ export async function truncate(entities: EntityName[]) {
}
for (const name of entities) {
// `workflow_statistics_delta` is a raw-SQL, Postgres-only table with no TypeORM entity, so it
// can't go through the repository, so we clear it directly.
if (name === 'WorkflowStatisticsDelta') {
const { type, tablePrefix } = Container.get(GlobalConfig).database;
if (type === 'postgresdb') {
const table = connection.driver.escape(`${tablePrefix}workflow_statistics_delta`);
await connection.query(`DELETE FROM ${table}`);
}
continue;
}
await connection.getRepository(name).delete({});
}
}

View File

@ -22,6 +22,7 @@ export const LOG_SCOPES = [
'task-runner-js',
'task-runner-py',
'insights',
'workflow-statistics',
'workflow-activation',
'ssh-client',
'data-table',

View File

@ -0,0 +1,73 @@
/* eslint-disable @typescript-eslint/unbound-method */
import type { MigrationContext, ReversibleMigration } from '../migration-types';
/**
* Create `workflow_statistics_delta` to store increments to workflow statistics
* that we will later fold into the `workflow_statistics`. This prevents hot-row
* contention on the `workflow_statistics` table in high-throughput scenarios.
*
* This table is a high-churn buffer (insert per execution, delete on fold), so
* its autovacuum is tuned to reclaim the fold's dead tuples aggressively
* instead of letting the table and its PK index bloat.
*
* - `scale_factor = 0` removes the size-proportional part of the vacuum trigger,
* so dead-tuple cleanup does not back off as the table grows during a backlog.
* - `threshold = 1000` fires a vacuum at a flat 1000 dead tuples regardless of
* table size, keeping it responsive.
* - `cost_delay = 0` drops autovacuum's throttling pauses so it runs at full
* speed and keeps up with the delete rate.
*/
export class CreateWorkflowStatisticsDeltaTable1784000000043 implements ReversibleMigration {
async up({ escape, runQuery }: MigrationContext) {
const delta = escape.tableName('workflow_statistics_delta');
const id = escape.columnName('id');
const workflowId = escape.columnName('workflowId');
const name = escape.columnName('name');
const rootCountDelta = escape.columnName('rootCountDelta');
const createdAt = escape.columnName('createdAt');
const workflowName = escape.columnName('workflowName');
await runQuery(
`CREATE TABLE ${delta} (
${id} BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
${workflowId} VARCHAR(36) NOT NULL,
${name} VARCHAR(128) NOT NULL,
${rootCountDelta} SMALLINT NOT NULL,
${createdAt} TIMESTAMP(3) WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
${workflowName} VARCHAR(128)
) WITH (
autovacuum_vacuum_scale_factor = 0.0,
autovacuum_vacuum_threshold = 1000,
autovacuum_vacuum_cost_delay = 0
)`,
);
}
async down({ escape, runQuery }: MigrationContext) {
const stats = escape.tableName('workflow_statistics');
const statsDelta = escape.tableName('workflow_statistics_delta');
const workflowId = escape.columnName('workflowId');
const name = escape.columnName('name');
const count = escape.columnName('count');
const rootCount = escape.columnName('rootCount');
const rootCountDelta = escape.columnName('rootCountDelta');
const latestEvent = escape.columnName('latestEvent');
const createdAt = escape.columnName('createdAt');
const workflowName = escape.columnName('workflowName');
await runQuery(
`INSERT INTO ${stats} (${workflowId}, ${name}, ${count}, ${rootCount}, ${latestEvent}, ${workflowName})
SELECT ${workflowId}, ${name}, COUNT(*), SUM(${rootCountDelta}), MAX(${createdAt}),
(ARRAY_AGG(${workflowName} ORDER BY ${createdAt} DESC NULLS LAST))[1]
FROM ${statsDelta}
GROUP BY ${workflowId}, ${name}
ON CONFLICT (${name}, ${workflowId}) DO UPDATE SET
${count} = ${stats}.${count} + EXCLUDED.${count},
${rootCount} = ${stats}.${rootCount} + EXCLUDED.${rootCount},
${latestEvent} = GREATEST(${stats}.${latestEvent}, EXCLUDED.${latestEvent}),
${workflowName} = EXCLUDED.${workflowName}`,
);
await runQuery(`DROP TABLE ${statsDelta}`);
}
}

View File

@ -53,6 +53,7 @@ import { ExpandVariablesValueColumnToText1777420800000 } from './1777420800000-E
import { LimitWorkflowVersionTriggerToContent1784000000003 } from './1784000000003-LimitWorkflowVersionTriggerToContent';
import { AddProjectIdToInstanceAiThread1784000000028 } from './1784000000028-AddProjectIdToInstanceAiThread';
import { AddExecutionEntityWorkflowStatusIndex1784000000031 } from './1784000000031-AddExecutionEntityWorkflowStatusIndex';
import { CreateWorkflowStatisticsDeltaTable1784000000043 } from './1784000000043-CreateWorkflowStatisticsDeltaTable';
import { CreateLdapEntities1674509946020 } from '../common/1674509946020-CreateLdapEntities';
import { PurgeInvalidWorkflowConnections1675940580449 } from '../common/1675940580449-PurgeInvalidWorkflowConnections';
import { RemoveResetPasswordColumns1690000000030 } from '../common/1690000000030-RemoveResetPasswordColumns';
@ -437,4 +438,5 @@ export const postgresMigrations: Migration[] = [
CreateWorkflowPublicationTriggerStatusTable1784000000040,
AddUsedPrivateCredentialsToExecutionEntity1784000000041,
CreateSchedulerTables1784000000042,
CreateWorkflowStatisticsDeltaTable1784000000043,
];

View File

@ -1,8 +1,8 @@
import { GlobalConfig } from '@n8n/config';
import { Service } from '@n8n/di';
import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
import { DataSource, QueryFailedError, Repository } from '@n8n/typeorm';
import assert from 'node:assert';
import { DataSource, type EntityManager, QueryFailedError, Repository } from '@n8n/typeorm';
import { UnexpectedError } from 'n8n-workflow';
import {
ProjectRelation,
@ -17,6 +17,27 @@ import { StatisticsNames } from '../entities/types-db';
type StatisticsInsertResult = 'insert' | 'failed' | 'alreadyExists';
type StatisticsUpsertResult = StatisticsInsertResult | 'update';
/**
* A counter row the fold just created for the first time, i.e. a workflow's first-ever
* production success or failure. The rollup uses these to fire one-time milestone events.
*/
export type FirstOccurrenceRow = {
name: StatisticsNames;
workflowId: string;
workflowName: string | null;
firstEventMs: number;
};
/** One row returned by the fold query: the batch total (`delta_rows`) plus per-counter fold results. */
type FoldRow = {
delta_rows: number; // `::int` cast in the query -> pg returns int4 as a JS number
workflowId: string;
name: StatisticsNames;
inserted: boolean;
workflowName: string | null;
firstEvent: Date; // timestamptz -> pg driver parses to a JS Date
};
@Service()
export class WorkflowStatisticsRepository extends Repository<WorkflowStatistics> {
constructor(
@ -65,58 +86,129 @@ export class WorkflowStatisticsRepository extends Repository<WorkflowStatistics>
isRootExecution: boolean,
workflowName?: string,
): Promise<StatisticsUpsertResult> {
const dbType = this.globalConfig.database.type;
if (this.globalConfig.database.type !== 'sqlite') {
throw new UnexpectedError(
'upsertWorkflowStatistics is SQLite-only; use appendIncrement + rollup on Postgres',
);
}
const escapedTableName = this.manager.connection.driver.escape(this.metadata.tableName);
try {
const rootCountIncrement = isRootExecution ? 1 : 0;
if (dbType === 'sqlite') {
await this.query(
`INSERT INTO ${escapedTableName} ("count", "rootCount", "name", "workflowId", "workflowName", "latestEvent")
VALUES (1, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT (workflowId, name)
DO UPDATE SET
count = count + 1,
rootCount = rootCount + ?,
workflowName = excluded.workflowName,
latestEvent = CURRENT_TIMESTAMP`,
[rootCountIncrement, eventName, workflowId, workflowName ?? null, rootCountIncrement],
);
await this.query(
`INSERT INTO ${escapedTableName} ("count", "rootCount", "name", "workflowId", "workflowName", "latestEvent")
VALUES (1, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT (workflowId, name)
DO UPDATE SET
count = count + 1,
rootCount = rootCount + ?,
workflowName = excluded.workflowName,
latestEvent = CURRENT_TIMESTAMP`,
[rootCountIncrement, eventName, workflowId, workflowName ?? null, rootCountIncrement],
);
// SQLite does not offer a reliable way to know whether or not an insert or update happened.
// We'll use a naive approach in this case. Query again after and it might cause us to miss the
// first production execution sometimes due to concurrency, but it's the only way.
const counter = await this.findOne({
select: ['count'],
where: { name: eventName, workflowId },
});
// SQLite does not offer a reliable way to know whether or not an insert or update happened.
// We'll use a naive approach in this case. Query again after and it might cause us to miss the
// first production execution sometimes due to concurrency, but it's the only way.
const counter = await this.findOne({
select: ['count'],
where: { name: eventName, workflowId },
});
return (counter?.count ?? 0) > 1 ? 'update' : counter?.count === 1 ? 'insert' : 'failed';
} else if (dbType === 'postgresdb') {
const queryResult = (await this.query(
`INSERT INTO ${escapedTableName} ("count", "rootCount", "name", "workflowId", "workflowName", "latestEvent")
VALUES (1, $1, $2, $3, $4, CURRENT_TIMESTAMP)
ON CONFLICT ("name", "workflowId")
DO UPDATE SET
"count" = ${escapedTableName}."count" + 1,
"rootCount" = ${escapedTableName}."rootCount" + $5,
"workflowName" = $4,
"latestEvent" = CURRENT_TIMESTAMP
RETURNING *;`,
[rootCountIncrement, eventName, workflowId, workflowName ?? null, rootCountIncrement],
)) as Array<{ count: string | number }>;
return Number(queryResult[0].count) === 1 ? 'insert' : 'update';
}
assert.fail('Unknown database type');
return (counter?.count ?? 0) > 1 ? 'update' : counter?.count === 1 ? 'insert' : 'failed';
} catch (error) {
console.log('error', error);
if (error instanceof QueryFailedError) return 'failed';
throw error;
}
}
private deltaTableName() {
const { tablePrefix } = this.globalConfig.database;
return this.manager.connection.driver.escape(`${tablePrefix}workflow_statistics_delta`);
}
async appendIncrement(
eventName: StatisticsNames,
workflowId: string,
isRootExecution: boolean,
workflowName?: string,
): Promise<void> {
const table = this.deltaTableName();
await this.query(
`INSERT INTO ${table} ("workflowId", "name", "rootCountDelta", "workflowName") VALUES ($1, $2, $3, $4)`,
[workflowId, eventName, isRootExecution ? 1 : 0, workflowName ?? null],
);
}
async rollupIncrements(
manager: EntityManager,
batchSize: number,
): Promise<{ increments: number; firstOccurrences: FirstOccurrenceRow[] }> {
const stats = this.manager.connection.driver.escape(this.metadata.tableName);
const delta = this.deltaTableName();
/**
* Query walkthrough
*
* Fold a batch of increments into the target in one atomic statement, so concurrent appends and crashes
* cannot double-count or drop rows. Whatever was not claimed-and-folded stays for next tick.
*
* - `batch` Claim the oldest increments (DELETE + RETURNING, so they cannot be folded twice).
* - `agg` Sum the claimed rows per (workflowId, name) into the totals to add.
* - `upsert` Add the totals into the counter, creating the row if new. `"xmax" = 0` flags rows it created.
* - `SELECT` Return how many increments processed, plus the created (first-ever) rows for milestones.
*/
const rows = await manager.query<FoldRow[]>(
`WITH batch AS (
DELETE FROM ${delta}
WHERE id IN (SELECT id FROM ${delta} ORDER BY id LIMIT $1)
RETURNING "workflowId", "name", "rootCountDelta", "createdAt", "workflowName"
),
agg AS (
SELECT "workflowId", "name",
COUNT(*) AS "countIncrement",
SUM("rootCountDelta") AS "rootCountIncrement",
MAX("createdAt") AS "latestEvent",
MIN("createdAt") AS "firstEvent",
(ARRAY_AGG("workflowName" ORDER BY "createdAt" DESC NULLS LAST))[1] AS "workflowName"
FROM batch GROUP BY "workflowId", "name"
),
upsert AS (
INSERT INTO ${stats} ("workflowId", "name", "count", "rootCount", "latestEvent", "workflowName")
SELECT "workflowId", "name", "countIncrement", "rootCountIncrement", "latestEvent", "workflowName" FROM agg
ON CONFLICT ("name", "workflowId") DO UPDATE SET
"count" = ${stats}."count" + EXCLUDED."count",
"rootCount" = ${stats}."rootCount" + EXCLUDED."rootCount",
"latestEvent" = GREATEST(${stats}."latestEvent", EXCLUDED."latestEvent"),
"workflowName" = EXCLUDED."workflowName"
RETURNING "workflowId", "name", ("xmax" = 0) AS inserted
)
SELECT (SELECT COUNT(*) FROM batch)::int AS delta_rows,
u."workflowId", u."name", u.inserted, a."workflowName", a."firstEvent"
FROM upsert u JOIN agg a ON a."workflowId" = u."workflowId" AND a."name" = u."name"`,
[batchSize],
);
if (rows.length === 0) return { increments: 0, firstOccurrences: [] };
const firstOccurrences: FirstOccurrenceRow[] = rows
.filter(
(r) =>
r.inserted &&
(r.name === StatisticsNames.productionSuccess ||
r.name === StatisticsNames.productionError),
)
.map((r) => ({
name: r.name,
workflowId: r.workflowId,
workflowName: r.workflowName,
firstEventMs: r.firstEvent.getTime(),
}));
return { increments: rows[0].delta_rows, firstOccurrences };
}
async queryNumWorkflowsUserHasWithFiveOrMoreProdExecs(userId: User['id']): Promise<number> {
const result = await this.createQueryBuilder('ws')
.select('COUNT(DISTINCT ws.workflowId)', 'count')

View File

@ -12,6 +12,7 @@ import { OperationalError } from 'n8n-workflow';
export const enum DbLock {
AUTH_ROLES_SYNC = 1001,
TRUSTED_KEY_REFRESH = 1002,
WORKFLOW_STATISTICS_ROLLUP = 1003,
/** Reserved for integration tests — never use in production code */
TEST = 9999,
}

View File

@ -41,6 +41,7 @@ import { JwtService } from '@/services/jwt.service';
import { OwnershipService } from '@/services/ownership.service';
import { ExecutionsPruningService } from '@/services/pruning/executions-pruning.service';
import { WorkflowHistoryCompactionService } from '@/services/pruning/workflow-history-compaction.service';
import { WorkflowStatisticsRollupService } from '@/services/workflow-statistics-rollup.service';
import { UrlService } from '@/services/url.service';
import { WaitTracker } from '@/wait-tracker';
import { WorkflowRunner } from '@/workflow-runner';
@ -398,6 +399,7 @@ export class Start extends BaseCommand<z.infer<typeof flagsSchema>> {
Container.get(ExecutionsPruningService).init();
Container.get(WorkflowHistoryCompactionService).init();
Container.get(WorkflowStatisticsRollupService).init();
Container.get(N8NCheckpointStorage).init();
if (this.globalConfig.executions.mode === 'regular') {

View File

@ -6,6 +6,11 @@ import { mock, mockClear } from 'vitest-mock-extended';
import { mockEntityManager } from '@test/mocking';
// `upsertWorkflowStatistics` is SQLite-only; on Postgres, recording goes through appendIncrement + rollup.
const runOnSqlite = (process.env.DB_TYPE ?? 'sqlite') === 'sqlite';
// eslint-disable-next-line n8n-local-rules/no-skipped-tests -- SQLite-only method
const describeSqlite = runOnSqlite ? describe : describe.skip;
describe('insertWorkflowStatistics', () => {
const entityManager = mockEntityManager(WorkflowStatistics);
const workflowStatisticsRepository = Container.get(WorkflowStatisticsRepository);
@ -52,7 +57,7 @@ describe('insertWorkflowStatistics', () => {
});
});
describe('upsertWorkflowStatistics', () => {
describeSqlite('upsertWorkflowStatistics', () => {
let repository: WorkflowStatisticsRepository;
beforeAll(async () => {
Container.reset();

View File

@ -0,0 +1,279 @@
import type { Logger } from '@n8n/backend-common';
import type { DatabaseConfig } from '@n8n/config';
import type { DbConnection, DbLockService, WorkflowStatisticsRepository } from '@n8n/db';
import { StatisticsNames } from '@n8n/db';
import { mock } from 'vitest-mock-extended';
import type { ErrorReporter, InstanceSettings } from 'n8n-core';
import { OperationalError } from 'n8n-workflow';
import type { WorkflowStatisticsService } from '../workflow-statistics.service';
import { WorkflowStatisticsRollupService } from '../workflow-statistics-rollup.service';
type RollupResult = Awaited<ReturnType<WorkflowStatisticsRepository['rollupIncrements']>>;
describe('WorkflowStatisticsRollupService', () => {
const dbConnection = mock<DbConnection>({ connectionState: { migrated: true } });
const makeService = (opts: {
isLeader?: boolean;
instanceType?: 'main' | 'worker';
dbType?: 'postgresdb' | 'sqlite';
}) => {
const instanceSettings = mock<InstanceSettings>({
isLeader: opts.isLeader ?? true,
instanceType: opts.instanceType ?? 'main',
instanceRole: 'leader',
});
const databaseConfig = mock<DatabaseConfig>({ type: opts.dbType ?? 'postgresdb' });
const errorReporter = mock<ErrorReporter>();
const dbLockService = mock<DbLockService>();
const repository = mock<WorkflowStatisticsRepository>();
const statisticsService = mock<WorkflowStatisticsService>();
const logger = mock<Logger>(); // the scoped logger the service actually logs to
const service = new WorkflowStatisticsRollupService(
mock<Logger>({ scoped: vi.fn().mockReturnValue(logger) }),
errorReporter,
instanceSettings,
dbConnection,
databaseConfig,
dbLockService,
repository,
statisticsService,
);
return { service, logger, errorReporter, dbLockService, repository, statisticsService };
};
/** Invoke the private rollup tick directly. */
const rollup = async (service: WorkflowStatisticsRollupService) =>
await (service as unknown as { rollup: () => Promise<number> }).rollup();
describe('shouldRun', () => {
it('is true for a leader main on Postgres', () => {
const { service } = makeService({
isLeader: true,
instanceType: 'main',
dbType: 'postgresdb',
});
expect(service.shouldRun).toBe(true);
});
it('is false on SQLite', () => {
const { service } = makeService({ isLeader: true, instanceType: 'main', dbType: 'sqlite' });
expect(service.shouldRun).toBe(false);
});
it('is false for a follower', () => {
const { service } = makeService({ isLeader: false });
expect(service.shouldRun).toBe(false);
});
it('is false for a non-main instance', () => {
const { service } = makeService({ instanceType: 'worker' });
expect(service.shouldRun).toBe(false);
});
});
describe('init', () => {
it('starts the rollup on a leader main (Postgres)', () => {
const { service } = makeService({ isLeader: true });
const startSpy = vi.spyOn(service, 'start').mockImplementation(() => {});
service.init();
expect(startSpy).toHaveBeenCalled();
});
it('does not start on a follower', () => {
const { service } = makeService({ isLeader: false });
const startSpy = vi.spyOn(service, 'start').mockImplementation(() => {});
service.init();
expect(startSpy).not.toHaveBeenCalled();
});
it('does not run after shutdown', async () => {
const { service } = makeService({ isLeader: true });
expect(service.shouldRun).toBe(true);
await service.shutdown();
expect(service.shouldRun).toBe(false);
});
});
describe('start', () => {
const isRunning = (service: WorkflowStatisticsRollupService) =>
(service as unknown as { timeout: NodeJS.Timeout | undefined }).timeout !== undefined;
it('does not schedule on a takeover when it should not run', () => {
const { service } = makeService({ dbType: 'sqlite' });
service.start();
expect(isRunning(service)).toBe(false);
});
});
describe('lock contention', () => {
const lockHeld = () => new OperationalError('lock held');
it('warns with counts once skips reach the threshold', async () => {
const { service, dbLockService, logger } = makeService({});
dbLockService.tryWithLock.mockRejectedValue(lockHeld());
for (let i = 0; i < 5; i++) await rollup(service);
expect(logger.warn).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(expect.any(String), {
consecutiveLockSkips: 5,
totalLockSkips: 5,
});
});
it('resets the consecutive count on a successful fold but keeps the total', async () => {
const { service, dbLockService, logger } = makeService({});
const empty: RollupResult = { increments: 0, firstOccurrences: [] };
for (let i = 0; i < 4; i++) dbLockService.tryWithLock.mockRejectedValueOnce(lockHeld());
dbLockService.tryWithLock.mockResolvedValueOnce(empty);
for (let i = 0; i < 5; i++) await rollup(service);
expect(logger.warn).not.toHaveBeenCalled(); // streak broken at 4
for (let i = 0; i < 5; i++) dbLockService.tryWithLock.mockRejectedValueOnce(lockHeld());
for (let i = 0; i < 5; i++) await rollup(service);
expect(logger.warn).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(expect.any(String), {
consecutiveLockSkips: 5,
totalLockSkips: 9,
});
});
});
describe('shutdown', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('resolves immediately when no tick is in flight', async () => {
const { service } = makeService({});
await expect(service.shutdown()).resolves.toBeUndefined();
});
it('awaits the in-flight tick (fold and milestones) before resolving, then stops', async () => {
const { service, dbLockService, statisticsService } = makeService({});
let resolveFold!: (result: RollupResult) => void;
dbLockService.tryWithLock.mockImplementation(
async () => await new Promise<RollupResult>((resolve) => (resolveFold = resolve)),
);
statisticsService.emitFirstOccurrenceEvent.mockResolvedValue();
service.start();
await vi.advanceTimersByTimeAsync(0); // fire the first tick; fold now in flight
let shutdownSettled = false;
const shutdownPromise = service.shutdown().then(() => (shutdownSettled = true));
await vi.advanceTimersByTimeAsync(0); // flush microtasks
expect(shutdownSettled).toBe(false); // still awaiting the in-flight fold
resolveFold({
increments: 1,
firstOccurrences: [
{
name: StatisticsNames.productionSuccess,
workflowId: 'wf-1',
workflowName: 'A',
firstEventMs: 1,
},
],
});
await shutdownPromise;
// The milestone was emitted before shutdown resolved.
expect(statisticsService.emitFirstOccurrenceEvent).toHaveBeenCalledTimes(1);
// No further tick is scheduled after shutdown.
await vi.advanceTimersByTimeAsync(60_000);
expect(dbLockService.tryWithLock).toHaveBeenCalledTimes(1);
});
});
describe('rollup', () => {
it('skips the tick (returns 0, no milestones) when the advisory lock is held by another instance', async () => {
const { service, dbLockService, statisticsService } = makeService({});
dbLockService.tryWithLock.mockRejectedValue(new OperationalError('lock held'));
const folded = await rollup(service);
expect(folded).toBe(0);
expect(statisticsService.emitFirstOccurrenceEvent).not.toHaveBeenCalled();
});
it('rethrows a non-lock error so the scheduler logs and retries the tick', async () => {
const { service, dbLockService, statisticsService } = makeService({});
dbLockService.tryWithLock.mockRejectedValue(new Error('connection reset'));
await expect(rollup(service)).rejects.toThrow('connection reset');
expect(statisticsService.emitFirstOccurrenceEvent).not.toHaveBeenCalled();
});
it('fires a milestone for each first-occurrence row and returns the increment count', async () => {
const { service, dbLockService, statisticsService } = makeService({});
const result: RollupResult = {
increments: 1234,
firstOccurrences: [
{
name: StatisticsNames.productionSuccess,
workflowId: 'wf-1',
workflowName: 'A',
firstEventMs: 1,
},
{
name: StatisticsNames.productionError,
workflowId: 'wf-2',
workflowName: 'B',
firstEventMs: 2,
},
],
};
dbLockService.tryWithLock.mockResolvedValue(result);
const folded = await rollup(service);
expect(folded).toBe(1234);
expect(statisticsService.emitFirstOccurrenceEvent).toHaveBeenCalledTimes(2);
expect(statisticsService.emitFirstOccurrenceEvent).toHaveBeenCalledWith(
StatisticsNames.productionSuccess,
'wf-1',
'A',
1,
);
});
it('isolates milestone failures: one throwing row does not stop the others or fail the tick', async () => {
const { service, dbLockService, statisticsService } = makeService({});
const result: RollupResult = {
increments: 2,
firstOccurrences: [
{
name: StatisticsNames.productionSuccess,
workflowId: 'deleted-wf',
workflowName: null,
firstEventMs: 1,
},
{
name: StatisticsNames.productionSuccess,
workflowId: 'wf-2',
workflowName: 'B',
firstEventMs: 2,
},
],
};
dbLockService.tryWithLock.mockResolvedValue(result);
statisticsService.emitFirstOccurrenceEvent
.mockRejectedValueOnce(new Error('workflow/project deleted during rollup lag'))
.mockResolvedValueOnce();
// Must not throw, and must still return the folded count.
await expect(rollup(service)).resolves.toBe(2);
expect(statisticsService.emitFirstOccurrenceEvent).toHaveBeenCalledTimes(2);
});
});
});

View File

@ -6,13 +6,13 @@ import {
testDb,
mockInstance,
} from '@n8n/backend-test-utils';
import { GlobalConfig } from '@n8n/config';
import { DatabaseConfig, GlobalConfig } from '@n8n/config';
import type { IWorkflowDb, Project, WorkflowEntity, WorkflowRepository, User } from '@n8n/db';
import { SettingsRepository, WorkflowStatisticsRepository } from '@n8n/db';
import { SettingsRepository, StatisticsNames, WorkflowStatisticsRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import {
QueryFailedError,
type DataSource,
DataSource,
type EntityManager,
type EntityMetadata,
} from '@n8n/typeorm';
@ -40,24 +40,59 @@ describe('WorkflowStatisticsService', () => {
let user: User;
let personalProject: Project;
let workflow: IWorkflowDb & WorkflowEntity;
let dataSource: DataSource;
let isPostgres: boolean;
beforeAll(async () => {
await testDb.init();
workflowStatisticsService = Container.get(WorkflowStatisticsService);
workflowStatisticsRepository = Container.get(WorkflowStatisticsRepository);
userService = Container.get(UserService);
dataSource = Container.get(DataSource);
isPostgres = Container.get(GlobalConfig).database.type === 'postgresdb';
user = await createUser();
personalProject = await getPersonalProject(user);
workflow = await createWorkflow({}, user);
});
/**
* On Postgres, `workflowExecutionCompleted` appends to the delta table and the rollup folds it
* out of band. These tests assert the materialized counter + milestone, so we drive the fold
* here (the rollup's work, minus the leader/lock/timer) to mimic the synchronous behavior the
* SQLite path still has. On SQLite this is a no-op (the upsert path already materialized).
*/
const flushStats = async (service: WorkflowStatisticsService) => {
if (!isPostgres) return;
const { firstOccurrences } = await workflowStatisticsRepository.rollupIncrements(
dataSource.manager,
10_000,
);
for (const occ of firstOccurrences) {
await service.emitFirstOccurrenceEvent(
occ.name,
occ.workflowId,
occ.workflowName,
occ.firstEventMs,
);
}
};
const completeAndFlush = async (
service: WorkflowStatisticsService,
workflowData: IWorkflowDb & WorkflowEntity,
runData: IRun,
) => {
await service.workflowExecutionCompleted(workflowData, runData);
await flushStats(service);
};
afterAll(async () => {
await testDb.terminate();
});
beforeEach(async () => {
vi.restoreAllMocks();
await testDb.truncate(['WorkflowStatistics']);
await testDb.truncate(['WorkflowStatistics', 'WorkflowStatisticsDelta']);
// Clear first production failure setting
const settingsRepository = Container.get(SettingsRepository);
await settingsRepository.delete({ key: 'instance.firstProductionFailure' });
@ -77,8 +112,8 @@ describe('WorkflowStatisticsService', () => {
};
// ACT
await workflowStatisticsService.workflowExecutionCompleted(workflow, runData);
await workflowStatisticsService.workflowExecutionCompleted(workflow, runData);
await completeAndFlush(workflowStatisticsService, workflow, runData);
await completeAndFlush(workflowStatisticsService, workflow, runData);
// ASSERT
const statistics = await workflowStatisticsRepository.find();
@ -110,8 +145,8 @@ describe('WorkflowStatisticsService', () => {
};
// ACT
await workflowStatisticsService.workflowExecutionCompleted(workflow, runData);
await workflowStatisticsService.workflowExecutionCompleted(workflow, runData);
await completeAndFlush(workflowStatisticsService, workflow, runData);
await completeAndFlush(workflowStatisticsService, workflow, runData);
// ASSERT
const statistics = await workflowStatisticsRepository.find();
@ -141,7 +176,7 @@ describe('WorkflowStatisticsService', () => {
storedAt: 'db',
};
await workflowStatisticsService.workflowExecutionCompleted(workflow, runData);
await completeAndFlush(workflowStatisticsService, workflow, runData);
const statistics = await workflowStatisticsRepository.find();
expect(statistics).toHaveLength(0);
@ -162,8 +197,8 @@ describe('WorkflowStatisticsService', () => {
};
// ACT
await workflowStatisticsService.workflowExecutionCompleted(workflow, runData);
await workflowStatisticsService.workflowExecutionCompleted(workflow, runData);
await completeAndFlush(workflowStatisticsService, workflow, runData);
await completeAndFlush(workflowStatisticsService, workflow, runData);
// ASSERT
const statistics = await workflowStatisticsRepository.find();
@ -193,7 +228,7 @@ describe('WorkflowStatisticsService', () => {
};
// ACT
await workflowStatisticsService.workflowExecutionCompleted(workflow, runData);
await completeAndFlush(workflowStatisticsService, workflow, runData);
// ASSERT
const statistics = await workflowStatisticsRepository.find();
@ -215,14 +250,16 @@ describe('WorkflowStatisticsService', () => {
const updateSettingsSpy = vi.spyOn(userService, 'updateSettings');
// ACT
await workflowStatisticsService.workflowExecutionCompleted(workflow, runData);
await completeAndFlush(workflowStatisticsService, workflow, runData);
// ASSERT
expect(updateSettingsSpy).toHaveBeenCalledTimes(1);
expect(updateSettingsSpy).toHaveBeenCalledWith(user.id, {
firstSuccessfulWorkflowId: workflow.id,
userActivated: true,
userActivatedAt: runData.startedAt.getTime(),
// On Postgres the milestone fires from the fold using the delta's `firstEvent`
// (~completion time), not `runData.startedAt` — an accepted best-effort drift.
userActivatedAt: isPostgres ? expect.any(Number) : runData.startedAt.getTime(),
});
expect(emitSpy).toHaveBeenCalledTimes(1);
expect(emitSpy).toHaveBeenCalledWith('first-production-workflow-succeeded', {
@ -246,7 +283,7 @@ describe('WorkflowStatisticsService', () => {
const updateSettingsSpy = vi.spyOn(userService, 'updateSettings');
// ACT
await workflowStatisticsService.workflowExecutionCompleted(workflow, runData);
await completeAndFlush(workflowStatisticsService, workflow, runData);
// ASSERT
expect(updateSettingsSpy).not.toHaveBeenCalled();
@ -266,12 +303,12 @@ describe('WorkflowStatisticsService', () => {
startedAt: new Date(),
storedAt: 'db',
};
await workflowStatisticsService.workflowExecutionCompleted(workflow, runData);
await completeAndFlush(workflowStatisticsService, workflow, runData);
const emitSpy = vi.spyOn(Container.get(EventService), 'emit');
const updateSettingsSpy = vi.spyOn(Container.get(UserService), 'updateSettings');
// ACT
await workflowStatisticsService.workflowExecutionCompleted(workflow, runData);
await completeAndFlush(workflowStatisticsService, workflow, runData);
// ASSERT
expect(updateSettingsSpy).not.toHaveBeenCalled();
@ -305,11 +342,12 @@ describe('WorkflowStatisticsService', () => {
Container.get(EventService),
settingsRepository,
workflowRepositoryNoErrorWorkflows,
Container.get(DatabaseConfig),
);
const emitSpy = vi.spyOn(Container.get(EventService), 'emit');
// ACT
await statisticsService.workflowExecutionCompleted(workflow, runData);
await completeAndFlush(statisticsService, workflow, runData);
// ASSERT
expect(emitSpy).toHaveBeenCalledWith('instance-first-production-workflow-failed', {
@ -347,14 +385,15 @@ describe('WorkflowStatisticsService', () => {
Container.get(EventService),
settingsRepository,
workflowRepositoryNoErrorWorkflows,
Container.get(DatabaseConfig),
);
// First failure - this will set the instance.firstProductionFailure setting
await statisticsService.workflowExecutionCompleted(workflow, runData);
await completeAndFlush(statisticsService, workflow, runData);
const emitSpy = vi.spyOn(Container.get(EventService), 'emit');
// ACT - Second failure
await statisticsService.workflowExecutionCompleted(workflow, runData);
await completeAndFlush(statisticsService, workflow, runData);
// ASSERT
expect(emitSpy).not.toHaveBeenCalled();
@ -387,11 +426,12 @@ describe('WorkflowStatisticsService', () => {
Container.get(EventService),
settingsRepository,
workflowRepositoryWithErrorWorkflows,
Container.get(DatabaseConfig),
);
const emitSpy = vi.spyOn(Container.get(EventService), 'emit');
// ACT
await statisticsService.workflowExecutionCompleted(workflow, runData);
await completeAndFlush(statisticsService, workflow, runData);
// ASSERT
expect(emitSpy).not.toHaveBeenCalledWith(
@ -413,7 +453,7 @@ describe('WorkflowStatisticsService', () => {
const emitSpy = vi.spyOn(Container.get(EventService), 'emit');
// ACT
await workflowStatisticsService.workflowExecutionCompleted(workflow, runData);
await completeAndFlush(workflowStatisticsService, workflow, runData);
// ASSERT
expect(emitSpy).not.toHaveBeenCalledWith(
@ -435,7 +475,7 @@ describe('WorkflowStatisticsService', () => {
const emitSpy = vi.spyOn(Container.get(EventService), 'emit');
// ACT
await workflowStatisticsService.workflowExecutionCompleted(workflow, runData);
await completeAndFlush(workflowStatisticsService, workflow, runData);
// ASSERT
expect(emitSpy).not.toHaveBeenCalledWith(
@ -462,7 +502,7 @@ describe('WorkflowStatisticsService', () => {
const updateSettingsSpy = vi.spyOn(userService, 'updateSettings');
// ACT
await workflowStatisticsService.workflowExecutionCompleted(teamWorkflow, runData);
await completeAndFlush(workflowStatisticsService, teamWorkflow, runData);
// ASSERT
expect(updateSettingsSpy).not.toHaveBeenCalled();
@ -504,6 +544,7 @@ describe('WorkflowStatisticsService', () => {
Container.get(EventService),
settingsRepository,
workflowRepositoryNoErrorWorkflows,
Container.get(DatabaseConfig),
);
const emitSpy = vi.spyOn(Container.get(EventService), 'emit');
@ -511,7 +552,7 @@ describe('WorkflowStatisticsService', () => {
const instanceOwner = await ownershipService.getInstanceOwner();
// ACT
await statisticsService.workflowExecutionCompleted(teamWorkflow, runData);
await completeAndFlush(statisticsService, teamWorkflow, runData);
// ASSERT
// For team projects, it should fall back to instance owner
@ -522,6 +563,239 @@ describe('WorkflowStatisticsService', () => {
userId: instanceOwner.id,
});
});
// The fold is Postgres-only (raw CTE). These exercise its mechanics directly via the repository.
describe('rollupIncrements (Postgres append path)', () => {
// The delta table is cleared by the outer beforeEach via testDb.truncate.
const deltaTable = () =>
`${Container.get(GlobalConfig).database.tablePrefix}workflow_statistics_delta`;
const append = async (isRoot: boolean) =>
await workflowStatisticsRepository.appendIncrement(
StatisticsNames.productionSuccess,
workflow.id,
isRoot,
workflow.name,
);
const appendFor = async (name: StatisticsNames, workflowId: string, isRoot: boolean) =>
await workflowStatisticsRepository.appendIncrement(name, workflowId, isRoot, 'wf');
const countersByKey = async () => {
const rows = await workflowStatisticsRepository.find();
return new Map(rows.map((r) => [`${r.workflowId}|${r.name}`, r]));
};
test('folds many appended deltas into one counter row with exact totals', async () => {
if (!isPostgres) return;
// 5 appends: 3 root, 2 non-root
await append(true);
await append(true);
await append(true);
await append(false);
await append(false);
const { increments, firstOccurrences } =
await workflowStatisticsRepository.rollupIncrements(dataSource.manager, 10_000);
expect(increments).toBe(5);
expect(firstOccurrences).toHaveLength(1); // the new production_success counter row
expect(firstOccurrences[0]).toMatchObject({
name: 'production_success',
workflowId: workflow.id,
});
const [counter] = await workflowStatisticsRepository.find();
expect(counter).toMatchObject({ count: 5, rootCount: 3, name: 'production_success' });
const remaining = await dataSource.query(`SELECT COUNT(*)::int AS c FROM ${deltaTable()}`);
expect(remaining[0].c).toBe(0); // delta drained
});
test('drains a backlog larger than the batch size across multiple folds (bounded)', async () => {
if (!isPostgres) return;
for (let i = 0; i < 5; i++) await append(true);
// Batch size 2 -> folds of 2, 2, 1, then 0 (drained).
const counts: number[] = [];
let folded: number;
do {
({ increments: folded } = await workflowStatisticsRepository.rollupIncrements(
dataSource.manager,
2,
));
counts.push(folded);
} while (folded > 0);
expect(counts).toEqual([2, 2, 1, 0]);
const [counter] = await workflowStatisticsRepository.find();
expect(counter).toMatchObject({ count: 5, rootCount: 5 });
});
test('does not report a first occurrence when the counter row already exists', async () => {
if (!isPostgres) return;
// First occurrence: creates the counter row.
await append(true);
const first = await workflowStatisticsRepository.rollupIncrements(
dataSource.manager,
10_000,
);
expect(first.firstOccurrences).toHaveLength(1);
// Subsequent fold updates the existing row -> no first-occurrence -> no milestone.
await append(true);
const second = await workflowStatisticsRepository.rollupIncrements(
dataSource.manager,
10_000,
);
expect(second.increments).toBe(1);
expect(second.firstOccurrences).toHaveLength(0);
const [counter] = await workflowStatisticsRepository.find();
expect(counter).toMatchObject({ count: 2 });
});
test('folds several (workflow, name) groups in one batch, each with its own totals', async () => {
if (!isPostgres) return;
const workflow2 = await createWorkflow({}, user);
// Three groups folded together: exercises GROUP BY + the upsert/agg join.
await appendFor(StatisticsNames.productionSuccess, workflow.id, true);
await appendFor(StatisticsNames.productionSuccess, workflow.id, true);
await appendFor(StatisticsNames.productionSuccess, workflow.id, false);
await appendFor(StatisticsNames.productionError, workflow.id, true);
await appendFor(StatisticsNames.productionSuccess, workflow2.id, true);
await appendFor(StatisticsNames.productionSuccess, workflow2.id, true);
const { increments, firstOccurrences } =
await workflowStatisticsRepository.rollupIncrements(dataSource.manager, 10_000);
expect(increments).toBe(6);
// Every group is new, so each is reported once and attributed to the right (workflow, name).
const occKeys = new Set(firstOccurrences.map((o) => `${o.workflowId}|${o.name}`));
expect(occKeys).toEqual(
new Set([
`${workflow.id}|production_success`,
`${workflow.id}|production_error`,
`${workflow2.id}|production_success`,
]),
);
const counters = await countersByKey();
expect(counters.get(`${workflow.id}|production_success`)).toMatchObject({
count: 3,
rootCount: 2,
});
expect(counters.get(`${workflow.id}|production_error`)).toMatchObject({
count: 1,
rootCount: 1,
});
expect(counters.get(`${workflow2.id}|production_success`)).toMatchObject({
count: 2,
rootCount: 2,
});
});
test('reports a first occurrence only for groups whose counter row is new', async () => {
if (!isPostgres) return;
const workflow2 = await createWorkflow({}, user);
// Pre-existing counter row for workflow1/success.
await appendFor(StatisticsNames.productionSuccess, workflow.id, true);
await workflowStatisticsRepository.rollupIncrements(dataSource.manager, 10_000);
// One batch touching the existing group and a brand-new one.
await appendFor(StatisticsNames.productionSuccess, workflow.id, true);
await appendFor(StatisticsNames.productionSuccess, workflow2.id, true);
const { increments, firstOccurrences } =
await workflowStatisticsRepository.rollupIncrements(dataSource.manager, 10_000);
expect(increments).toBe(2);
expect(firstOccurrences).toHaveLength(1);
expect(firstOccurrences[0]).toMatchObject({
workflowId: workflow2.id,
name: 'production_success',
});
const counters = await countersByKey();
expect(counters.get(`${workflow.id}|production_success`)).toMatchObject({ count: 2 });
expect(counters.get(`${workflow2.id}|production_success`)).toMatchObject({ count: 1 });
});
test('does not regress an existing counter row to an older event time', async () => {
if (!isPostgres) return;
const future = new Date(Date.now() + 24 * 60 * 60 * 1000);
await workflowStatisticsRepository.insert({
workflowId: workflow.id,
name: StatisticsNames.productionSuccess,
count: 1,
rootCount: 1,
latestEvent: future,
workflowName: workflow.name,
});
// Folding "now" events into a row whose stored timestamp is in the future.
await append(true);
await workflowStatisticsRepository.rollupIncrements(dataSource.manager, 10_000);
const [counter] = await workflowStatisticsRepository.find();
expect(counter).toMatchObject({ count: 2, rootCount: 2 });
// GREATEST keeps the newer stored timestamp, not the older folded one.
expect(counter.latestEvent.getTime()).toBe(future.getTime());
});
test('returns an empty result when there are no deltas', async () => {
if (!isPostgres) return;
const result = await workflowStatisticsRepository.rollupIncrements(
dataSource.manager,
10_000,
);
expect(result).toEqual({ increments: 0, firstOccurrences: [] });
});
test('carries the earliest event as the first-occurrence time and the latest as the counter time', async () => {
if (!isPostgres) return;
// Two deltas with explicit, ordered timestamps so MIN/MAX are deterministic.
const earliest = new Date('2030-01-01T00:00:00.000Z');
const latest = new Date('2030-01-02T00:00:00.000Z');
await dataSource.query(
`INSERT INTO ${deltaTable()} ("workflowId", "name", "rootCountDelta", "createdAt", "workflowName")
VALUES ($1, $2, 1, $3, 'older'), ($4, $5, 1, $6, 'newer')`,
[
workflow.id,
StatisticsNames.productionSuccess,
earliest,
workflow.id,
StatisticsNames.productionSuccess,
latest,
],
);
const { firstOccurrences } = await workflowStatisticsRepository.rollupIncrements(
dataSource.manager,
10_000,
);
expect(firstOccurrences).toHaveLength(1);
expect(firstOccurrences[0].firstEventMs).toBe(earliest.getTime()); // MIN(createdAt)
const [counter] = await workflowStatisticsRepository.find();
expect(counter.latestEvent.getTime()).toBe(latest.getTime()); // MAX(createdAt)
expect(counter.workflowName).toBe('newer'); // name from the most recent delta
});
});
});
describe('nodeFetchedData', () => {
@ -540,6 +814,7 @@ describe('WorkflowStatisticsService', () => {
ownershipService = mockInstance(OwnershipService);
userService = mockInstance(UserService);
const globalConfig = Container.get(GlobalConfig);
const databaseConfig = Container.get(DatabaseConfig);
entityManager = mock<EntityManager>();
const dataSource = mock<DataSource>({
@ -566,6 +841,7 @@ describe('WorkflowStatisticsService', () => {
eventService,
settingsRepository,
workflowRepository,
databaseConfig,
);
globalConfig.diagnostics.enabled = true;
globalConfig.deployment.type = 'n8n-testing';

View File

@ -0,0 +1,174 @@
import { Logger } from '@n8n/backend-common';
import { DatabaseConfig } from '@n8n/config';
import { Time } from '@n8n/constants';
import { DbConnection, DbLock, DbLockService, WorkflowStatisticsRepository } from '@n8n/db';
import { OnLeaderStepdown, OnLeaderTakeover, OnShutdown } from '@n8n/decorators';
import { Service } from '@n8n/di';
import { ErrorReporter, InstanceSettings } from 'n8n-core';
import { OperationalError } from 'n8n-workflow';
import { strict } from 'node:assert';
import { WorkflowStatisticsService } from './workflow-statistics.service';
type RollupResult = Awaited<ReturnType<WorkflowStatisticsRepository['rollupIncrements']>>;
const BATCH_SIZE = 5000;
/** Steady cadence once the backlog is drained. */
const STEADY_INTERVAL_MS = 5 * Time.seconds.toMilliseconds;
/** Fast cadence while a batch comes back full, i.e. backlog remaining. */
const BUSY_INTERVAL_MS = 250;
/** Consecutive lock skips after which to warn that the lock is persistently held elsewhere. */
const SKIP_WARN_THRESHOLD = 5;
/**
* Folds statistics increments in the `workflow_statistics_delta` table
* into the `workflow_statistics` table.
*/
@Service()
export class WorkflowStatisticsRollupService {
private timeout: NodeJS.Timeout | undefined;
/** Tracks the in-flight tick so `shutdown` can await it. */
private inflightRollup: Promise<void> | undefined;
private isShuttingDown = false;
private consecutiveLockSkips = 0;
private totalLockSkips = 0;
constructor(
private readonly logger: Logger,
private readonly errorReporter: ErrorReporter,
private readonly instanceSettings: InstanceSettings,
private readonly dbConnection: DbConnection,
private readonly databaseConfig: DatabaseConfig,
private readonly dbLockService: DbLockService,
private readonly repository: WorkflowStatisticsRepository,
private readonly statisticsService: WorkflowStatisticsService,
) {
this.logger = this.logger.scoped('workflow-statistics');
}
get shouldRun() {
return (
this.databaseConfig.type === 'postgresdb' &&
this.dbConnection.connectionState.migrated &&
this.instanceSettings.instanceType === 'main' &&
this.instanceSettings.isLeader &&
!this.isShuttingDown
);
}
init() {
strict(this.instanceSettings.instanceRole !== 'unset', 'Instance role is not set');
if (this.shouldRun) this.start();
}
@OnLeaderTakeover()
start() {
if (!this.shouldRun || this.timeout !== undefined) return;
this.scheduleNext(0);
this.logger.debug('Workflow statistics rollup interval started');
}
@OnLeaderStepdown()
stop() {
clearTimeout(this.timeout);
this.timeout = undefined;
}
@OnShutdown()
async shutdown() {
this.isShuttingDown = true;
clearTimeout(this.timeout);
this.timeout = undefined;
await this.inflightRollup;
}
private scheduleNext(delayMs: number) {
this.timeout = setTimeout(() => {
let nextDelayMs = STEADY_INTERVAL_MS;
this.inflightRollup = this.rollup()
.then((increments) => {
if (increments >= BATCH_SIZE) nextDelayMs = BUSY_INTERVAL_MS; // backlog remaining
})
.catch((error) => {
this.errorReporter.error(error, { shouldBeLogged: true });
})
.finally(() => {
this.inflightRollup = undefined;
if (this.timeout !== undefined) this.scheduleNext(nextDelayMs);
});
}, delayMs);
}
/** Fold one batch of increments and fire any resulting milestones. Returns increments folded. */
private async rollup(): Promise<number> {
const result = await this.foldBatch();
if (!result) return 0;
await this.emitMilestones(result.firstOccurrences);
return result.increments;
}
/** Fold a batch under an advisory lock. Returns null if another instance holds the lock. */
private async foldBatch(): Promise<RollupResult | null> {
try {
const result = await this.dbLockService.tryWithLock(
DbLock.WORKFLOW_STATISTICS_ROLLUP,
async (tx) => await this.repository.rollupIncrements(tx, BATCH_SIZE),
);
this.consecutiveLockSkips = 0;
return result;
} catch (error) {
if (error instanceof OperationalError) {
this.registerLockSkip(); // another instance holds the lock
return null;
}
throw error;
}
}
/**
* Occasional skips are expected around leader transitions; persistent skips suggest a process
* outside this deployment holds the lock, e.g. a second n8n instance sharing this database
* (advisory locks are not schema- or table-prefix-scoped).
*/
private registerLockSkip() {
this.consecutiveLockSkips++;
this.totalLockSkips++;
if (this.consecutiveLockSkips % SKIP_WARN_THRESHOLD !== 0) return;
this.logger.warn(
'Workflow statistics rollup repeatedly skipped: lock held by another process',
{
consecutiveLockSkips: this.consecutiveLockSkips,
totalLockSkips: this.totalLockSkips,
},
);
}
/** Fire the one-time milestone event for each workflow whose counter row was just created. */
private async emitMilestones(firstOccurrences: RollupResult['firstOccurrences']): Promise<void> {
for (const occurrence of firstOccurrences) {
try {
await this.statisticsService.emitFirstOccurrenceEvent(
occurrence.name,
occurrence.workflowId,
occurrence.workflowName,
occurrence.firstEventMs,
);
} catch (error) {
this.errorReporter.error(error, { shouldBeLogged: true });
}
}
}
}

View File

@ -1,4 +1,5 @@
import { Logger, TypedEmitter } from '@n8n/backend-common';
import { DatabaseConfig } from '@n8n/config';
import {
SettingsRepository,
StatisticsNames,
@ -6,6 +7,7 @@ import {
WorkflowStatisticsRepository,
} from '@n8n/db';
import { Service } from '@n8n/di';
import { ensureError } from '@n8n/utils/errors/ensure-error';
import {
isCompletedExecutionStatus,
type ExecutionStatus,
@ -93,6 +95,7 @@ export class WorkflowStatisticsService extends TypedEmitter<WorkflowStatisticsEv
private readonly eventService: EventService,
private readonly settingsRepository: SettingsRepository,
private readonly workflowRepository: WorkflowRepository,
private readonly databaseConfig: DatabaseConfig,
) {
super({ captureRejections: true });
if ('SKIP_STATISTICS_EVENTS' in process.env) return;
@ -115,29 +118,76 @@ export class WorkflowStatisticsService extends TypedEmitter<WorkflowStatisticsEv
const workflowId = workflowData.id;
if (!workflowId) return;
const isRoot = isRootExecutionForRun(runData);
let upsertResult: Awaited<ReturnType<WorkflowStatisticsRepository['upsertWorkflowStatistics']>>;
try {
const upsertResult = await this.repository.upsertWorkflowStatistics(
/**
* For performance reasons, in Postgres we append and fold out of band,
* whereas in SQLite we upsert directly.
*/
if (this.databaseConfig.type === 'postgresdb') {
await this.repository.appendIncrement(
statisticsName,
workflowId,
isRoot,
workflowData.name,
);
return;
}
upsertResult = await this.repository.upsertWorkflowStatistics(
statisticsName,
workflowId,
isRootExecutionForRun(runData),
isRoot,
workflowData.name,
);
if (upsertResult !== 'insert') return;
if (statisticsName === StatisticsNames.productionSuccess) {
await this.emitFirstProductionWorkflowSucceeded(workflowId, runData);
} else if (statisticsName === StatisticsNames.productionError) {
await this.emitInstanceFirstProductionWorkflowFailed(workflowData, workflowId, runData);
}
} catch (error) {
this.logger.debug('Unable to fire first workflow telemetry event');
this.logger.error('Failed to record workflow statistic', { error: ensureError(error) });
return;
}
if (upsertResult !== 'insert') return;
try {
await this.emitFirstOccurrenceEvent(
statisticsName,
workflowId,
workflowData.name ?? null,
runData.startedAt.getTime(),
);
} catch (error) {
this.logger.debug('Failed to emit workflow statistics milestone', {
error: ensureError(error),
});
}
}
/** Emit an event on first production success or first production failure. */
async emitFirstOccurrenceEvent(
statisticsName: StatisticsNames,
workflowId: string,
workflowName: string | null,
firstEventMs: number,
): Promise<void> {
if (statisticsName === StatisticsNames.productionSuccess) {
await this.emitFirstProductionWorkflowSucceeded(workflowId, firstEventMs);
return;
}
if (statisticsName === StatisticsNames.productionError) {
await this.emitInstanceFirstProductionWorkflowFailed(
workflowId,
workflowName ?? '',
firstEventMs,
);
}
}
private async emitFirstProductionWorkflowSucceeded(
workflowId: string,
runData: IRun,
userActivatedAtMs: number,
): Promise<void> {
const project = await this.ownershipService.getWorkflowProjectCached(workflowId);
let userId: string | null = null;
@ -150,7 +200,7 @@ export class WorkflowStatisticsService extends TypedEmitter<WorkflowStatisticsEv
await this.userService.updateSettings(owner.id, {
firstSuccessfulWorkflowId: workflowId,
userActivated: true,
userActivatedAt: runData.startedAt.getTime(),
userActivatedAt: userActivatedAtMs,
});
}
}
@ -163,9 +213,9 @@ export class WorkflowStatisticsService extends TypedEmitter<WorkflowStatisticsEv
}
private async emitInstanceFirstProductionWorkflowFailed(
workflowData: IWorkflowBase,
workflowId: string,
runData: IRun,
workflowName: string,
timestampMs: number,
): Promise<void> {
const instanceHadProductionFailure = await this.settingsRepository.findByKey(
'instance.firstProductionFailure',
@ -193,7 +243,7 @@ export class WorkflowStatisticsService extends TypedEmitter<WorkflowStatisticsEv
workflowId,
projectId: project.id,
userId: owner.id,
timestamp: runData.startedAt.getTime(),
timestamp: timestampMs,
}),
loadOnStartup: false,
});
@ -201,7 +251,7 @@ export class WorkflowStatisticsService extends TypedEmitter<WorkflowStatisticsEv
this.eventService.emit('instance-first-production-workflow-failed', {
projectId: project.id,
workflowId,
workflowName: workflowData.name,
workflowName,
userId: owner.id,
});
}

View File

@ -8,6 +8,7 @@
*/
import { createTeamProject, createWorkflow, testDb, testModules } from '@n8n/backend-test-utils';
import { GlobalConfig } from '@n8n/config';
import type { Project, WorkflowEntity } from '@n8n/db';
import { ExecutionRepository, StatisticsNames, WorkflowStatisticsRepository } from '@n8n/db';
import { Container } from '@n8n/di';
@ -147,8 +148,15 @@ describe('Insights vs Workflow Statistics Integration', () => {
expectedCount: number,
timeout = 10000,
): Promise<void> {
const isPostgres = Container.get(GlobalConfig).database.type === 'postgresdb';
const start = Date.now();
while (Date.now() - start < timeout) {
if (isPostgres) {
await workflowStatisticsRepository.rollupIncrements(
workflowStatisticsRepository.manager,
10_000,
);
}
const stats = await workflowStatisticsRepository.findOne({
where: {
workflowId,

View File

@ -62,10 +62,9 @@ describe('LicenseMetricsRepository', () => {
StatisticsNames.manualError,
secondWorkflow.id,
),
workflowStatisticsRepository.upsertWorkflowStatistics(
workflowStatisticsRepository.insertWorkflowStatistics(
StatisticsNames.productionSuccess,
secondWorkflow.id,
true,
),
]);

View File

@ -0,0 +1,125 @@
import {
createTestMigrationContext,
initDbUpToMigration,
runSingleMigration,
undoLastSingleMigration,
type TestMigrationContext,
} from '@n8n/backend-test-utils';
import { DbConnection } from '@n8n/db';
import { Container } from '@n8n/di';
import { DataSource } from '@n8n/typeorm';
const MIGRATION_NAME = 'CreateWorkflowStatisticsDeltaTable1784000000043';
// The delta table + fold are Postgres-only; the migration is not registered for SQLite.
const runOnPostgres = (process.env.DB_TYPE ?? 'sqlite') === 'postgresdb';
// eslint-disable-next-line n8n-local-rules/no-skipped-tests -- Postgres-only migration, skipped on SQLite
const describePg = runOnPostgres ? describe : describe.skip;
describePg('CreateWorkflowStatisticsDeltaTable migration (Postgres)', () => {
let dataSource: DataSource;
beforeAll(async () => {
const dbConnection = Container.get(DbConnection);
await dbConnection.init();
dataSource = Container.get(DataSource);
});
beforeEach(async () => {
const context = createTestMigrationContext(dataSource);
await context.queryRunner.clearDatabase();
await context.queryRunner.release();
// Run every migration before this one (counter table exists; delta table does not).
await initDbUpToMigration(MIGRATION_NAME);
});
afterAll(async () => {
await Container.get(DbConnection).close();
});
const insertCounter = async (
context: TestMigrationContext,
row: {
workflowId: string;
name: string;
count: number;
rootCount: number;
workflowName: string;
},
) => {
const t = context.escape.tableName('workflow_statistics');
const c = (n: string) => context.escape.columnName(n);
await context.runQuery(
`INSERT INTO ${t} (${c('workflowId')}, ${c('name')}, ${c('count')}, ${c('rootCount')}, ${c('latestEvent')}, ${c('workflowName')})
VALUES (:workflowId, :name, :count, :rootCount, :latestEvent, :workflowName)`,
{ ...row, latestEvent: new Date() },
);
};
const insertDelta = async (
context: TestMigrationContext,
row: { workflowId: string; name: string; rootCountDelta: number; workflowName: string },
) => {
const t = context.escape.tableName('workflow_statistics_delta');
const c = (n: string) => context.escape.columnName(n);
await context.runQuery(
`INSERT INTO ${t} (${c('workflowId')}, ${c('name')}, ${c('rootCountDelta')}, ${c('workflowName')})
VALUES (:workflowId, :name, :rootCountDelta, :workflowName)`,
row,
);
};
test('up creates the delta table; down folds residual deltas into the counter then drops it', async () => {
// up
await runSingleMigration(MIGRATION_NAME);
let context = createTestMigrationContext(dataSource);
// A workflow already activated before rollback, plus three un-folded deltas (2 root, 1 non-root).
await insertCounter(context, {
workflowId: 'wf-1',
name: 'production_success',
count: 10,
rootCount: 4,
workflowName: 'WF One (old)',
});
await insertDelta(context, {
workflowId: 'wf-1',
name: 'production_success',
rootCountDelta: 1,
workflowName: 'WF One',
});
await insertDelta(context, {
workflowId: 'wf-1',
name: 'production_success',
rootCountDelta: 1,
workflowName: 'WF One',
});
await insertDelta(context, {
workflowId: 'wf-1',
name: 'production_success',
rootCountDelta: 0,
workflowName: 'WF One',
});
await context.queryRunner.release();
// down: must fold (count += 3, rootCount += 2) before dropping — no counts lost on rollback.
await undoLastSingleMigration();
context = createTestMigrationContext(dataSource);
const counterTable = context.escape.tableName('workflow_statistics');
const countCol = context.escape.columnName('count');
const rootCountCol = context.escape.columnName('rootCount');
const wfIdCol = context.escape.columnName('workflowId');
const counter = await context.runQuery<Array<{ count: number; rootCount: number }>>(
`SELECT ${countCol}::int AS count, ${rootCountCol}::int AS "rootCount" FROM ${counterTable} WHERE ${wfIdCol} = :id`,
{ id: 'wf-1' },
);
expect(counter).toHaveLength(1);
expect(counter[0]).toMatchObject({ count: 13, rootCount: 6 });
// delta table is gone
const deltaTable = context.escape.tableName('workflow_statistics_delta');
await expect(context.runQuery(`SELECT 1 FROM ${deltaTable}`)).rejects.toThrow();
await context.queryRunner.release();
});
});

View File

@ -8606,6 +8606,7 @@ packages:
'@langchain/community@1.1.27':
resolution: {integrity: sha512-s2U3w7QV7QpkFtY1eZMni4poz+nKLFclpDi3a7hUbZ67ttsGaU9WkZ2BiLuzLIs+IFaUvON/KcGkE8EqAl9aPA==}
engines: {node: '>=20'}
deprecated: This package has been deprecated. See https://github.com/langchain-ai/langchainjs-community/issues/61 for more info
peerDependencies:
'@arcjet/redact': ^v1.2.0
'@aws-crypto/sha256-js': ^5.0.0