mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-05 11:09:31 +02:00
ci: Speed up DB integration tests via Postgres template DB + persistent Jest workers (#31778)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
be01de6143
commit
a83ea70bbc
7
.github/workflows/ci-pull-requests.yml
vendored
7
.github/workflows/ci-pull-requests.yml
vendored
|
|
@ -120,7 +120,14 @@ jobs:
|
|||
packages/cli/test/integration/**
|
||||
packages/cli/test/migration/**
|
||||
packages/cli/test/shared/db/**
|
||||
packages/cli/test/setup-testcontainers.js
|
||||
packages/cli/test/teardown-testcontainers.js
|
||||
packages/cli/jest.config.integration.js
|
||||
packages/cli/jest.config.integration.testcontainers.js
|
||||
packages/cli/jest.config.migration.js
|
||||
packages/cli/jest.config.migration.testcontainers.js
|
||||
packages/@n8n/db/**
|
||||
packages/@n8n/backend-test-utils/**
|
||||
packages/cli/**/__tests__/**
|
||||
packages/testing/containers/services/postgres.ts
|
||||
.github/workflows/test-db-reusable.yml
|
||||
|
|
|
|||
6
.github/workflows/test-db-reusable.yml
vendored
6
.github/workflows/test-db-reusable.yml
vendored
|
|
@ -26,7 +26,11 @@ jobs:
|
|||
migration-cmd: pnpm test:sqlite:migrations
|
||||
collectCoverage: 'false'
|
||||
- name: Postgres 16
|
||||
runner: blacksmith-8vcpu-ubuntu-2204
|
||||
# 4vcpu is enough since persistent-worker + template-DB make PG16
|
||||
# complete in ~480s, which is now close to the SQLite leg (~360s)
|
||||
# — the matrix wall is bounded by the slower leg, so the larger
|
||||
# 8vcpu runner buys headroom we can't cash in anywhere.
|
||||
runner: blacksmith-4vcpu-ubuntu-2204
|
||||
test-cmd: pnpm test:postgres:integration:tc
|
||||
migration-cmd: pnpm test:postgres:migrations:tc
|
||||
TEST_IMAGE_POSTGRES: 'postgres:16'
|
||||
|
|
|
|||
|
|
@ -30,6 +30,10 @@ export const getBootstrapDBOptions = (): DataSourceOptions => {
|
|||
|
||||
/**
|
||||
* Initialize one test DB per suite run, with bootstrap connection if needed.
|
||||
*
|
||||
* When `N8N_TEST_TEMPLATE_DB` is set (Postgres only), the new test DB is created
|
||||
* via `CREATE DATABASE ... TEMPLATE <name>`, which clones the schema as a file
|
||||
* copy and skips the multi-second migration replay per file.
|
||||
*/
|
||||
export async function init() {
|
||||
if (isInitialized) return;
|
||||
|
|
@ -38,10 +42,16 @@ export async function init() {
|
|||
const dbType = globalConfig.database.type;
|
||||
testDbName = `${testDbPrefix}${randomString(6, 10).toLowerCase()}_${Date.now()}`;
|
||||
|
||||
const templateDb = dbType === 'postgresdb' ? process.env.N8N_TEST_TEMPLATE_DB : undefined;
|
||||
|
||||
if (dbType === 'postgresdb') {
|
||||
originalDatabase = globalConfig.database.postgresdb.database;
|
||||
const bootstrapPostgres = await new Connection(getBootstrapDBOptions()).initialize();
|
||||
await bootstrapPostgres.query(`CREATE DATABASE ${testDbName}`);
|
||||
if (templateDb) {
|
||||
await bootstrapPostgres.query(`CREATE DATABASE ${testDbName} TEMPLATE ${templateDb}`);
|
||||
} else {
|
||||
await bootstrapPostgres.query(`CREATE DATABASE ${testDbName}`);
|
||||
}
|
||||
await bootstrapPostgres.destroy();
|
||||
|
||||
globalConfig.database.postgresdb.database = testDbName;
|
||||
|
|
@ -49,13 +59,57 @@ export async function init() {
|
|||
|
||||
const dbConnection = Container.get(DbConnection);
|
||||
await dbConnection.init();
|
||||
await dbConnection.migrate();
|
||||
|
||||
await Container.get(AuthRolesService).init();
|
||||
if (templateDb) {
|
||||
// Template already carries migrations + seeded roles — just mark state.
|
||||
dbConnection.connectionState.migrated = true;
|
||||
} else {
|
||||
await dbConnection.migrate();
|
||||
await Container.get(AuthRolesService).init();
|
||||
}
|
||||
|
||||
isInitialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Postgres template DB with all migrations + auth roles seeded.
|
||||
* Idempotent: drops any existing DB with the same name first.
|
||||
* Called from Jest globalSetup (orchestrator process) before workers fork —
|
||||
* each worker's `init()` then clones from the template instead of replaying
|
||||
* the full migration history.
|
||||
*/
|
||||
export async function initTemplateDb(templateName: string): Promise<void> {
|
||||
const globalConfig = Container.get(GlobalConfig);
|
||||
if (globalConfig.database.type !== 'postgresdb') {
|
||||
throw new Error('initTemplateDb only supports postgresdb');
|
||||
}
|
||||
|
||||
const originalDb = globalConfig.database.postgresdb.database;
|
||||
|
||||
const bootstrap = await new Connection(getBootstrapDBOptions()).initialize();
|
||||
await bootstrap.query(
|
||||
`UPDATE pg_database SET datistemplate = false WHERE datname = '${templateName}'`,
|
||||
);
|
||||
await bootstrap.query(`DROP DATABASE IF EXISTS ${templateName}`);
|
||||
await bootstrap.query(`CREATE DATABASE ${templateName}`);
|
||||
await bootstrap.destroy();
|
||||
|
||||
globalConfig.database.postgresdb.database = templateName;
|
||||
const dbConnection = Container.get(DbConnection);
|
||||
await dbConnection.init();
|
||||
await dbConnection.migrate();
|
||||
await Container.get(AuthRolesService).init();
|
||||
await dbConnection.close();
|
||||
globalConfig.database.postgresdb.database = originalDb;
|
||||
|
||||
// Mark as template so CREATE DATABASE ... TEMPLATE will accept it.
|
||||
const finalizer = await new Connection(getBootstrapDBOptions()).initialize();
|
||||
await finalizer.query(
|
||||
`UPDATE pg_database SET datistemplate = true WHERE datname = '${templateName}'`,
|
||||
);
|
||||
await finalizer.destroy();
|
||||
}
|
||||
|
||||
export function isReady() {
|
||||
const { connectionState } = Container.get(DbConnection);
|
||||
return connectionState.connected && connectionState.migrated;
|
||||
|
|
@ -85,6 +139,15 @@ export async function terminate() {
|
|||
testDbName = undefined;
|
||||
}
|
||||
|
||||
// Clear all cached DI singletons (DbConnection, DataSource, GlobalConfig,
|
||||
// AuthRolesService, …). With persistent Jest workers (no per-file process
|
||||
// recycling), the next test file's testDb.init() would otherwise reuse the
|
||||
// DbConnection instance whose DataSource we just destroyed — and try to
|
||||
// .initialize() it again, which hangs. Resetting forces the next get() to
|
||||
// rebuild the whole chain from the freshly-set env vars (e.g. the new
|
||||
// per-file Postgres database name we just switched to).
|
||||
Container.reset();
|
||||
|
||||
isInitialized = false;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,27 @@
|
|||
// The root jest config sets `workerIdleMemoryLimit: '1MB'` which forces a
|
||||
// fresh worker process for every test file. Removing it lets workers persist
|
||||
// across files and skip the per-file module-load cost (TypeORM, @n8n/di, the
|
||||
// n8n CLI surface), which dominates wall time on the Postgres 16 integration
|
||||
// job. On SQLite we can't yet remove it: TypeORM/sqlite-pooled leaks state
|
||||
// across migrate() calls and per-file migration time grows linearly until it
|
||||
// hits the 10s hook timeout. So we keep the 1 MB limit (effective recycling)
|
||||
// for SQLite and uncap for Postgres. `JEST_WORKER_IDLE_MEMORY_LIMIT` overrides
|
||||
// both (use empty string to disable; any other value sets the limit).
|
||||
const { workerIdleMemoryLimit: _drop, ...rootConfig } = require('../../jest.config');
|
||||
|
||||
function resolveWorkerIdleMemoryLimit() {
|
||||
const override = process.env.JEST_WORKER_IDLE_MEMORY_LIMIT;
|
||||
if (override !== undefined) {
|
||||
return override === '' ? undefined : override;
|
||||
}
|
||||
return process.env.DB_TYPE === 'sqlite' ? '1MB' : undefined;
|
||||
}
|
||||
|
||||
const workerIdleMemoryLimit = resolveWorkerIdleMemoryLimit();
|
||||
|
||||
/** @type {import('jest').Config} */
|
||||
module.exports = {
|
||||
...require('../../jest.config'),
|
||||
const config = {
|
||||
...rootConfig,
|
||||
testEnvironmentOptions: {
|
||||
url: 'http://localhost/',
|
||||
},
|
||||
|
|
@ -14,12 +35,18 @@ module.exports = {
|
|||
coveragePathIgnorePatterns: ['/src/databases/migrations/'],
|
||||
testTimeout: 10_000,
|
||||
prettierPath: null,
|
||||
// Run integration tests from test/integration, test/migration and src/ directories
|
||||
// Run integration tests from test/integration and src/ directories.
|
||||
// Migration tests have their own config (jest.config.migration.js) with
|
||||
// maxWorkers: 1 because `initDbUpToMigration` rolls the schema back,
|
||||
// which pollutes any DB shared by parallel suites. They're invoked via
|
||||
// the dedicated `Run Migration Tests` CI step.
|
||||
testRegex: undefined, // Override base config testRegex
|
||||
testMatch: [
|
||||
'<rootDir>/test/integration/**/*.test.ts',
|
||||
'<rootDir>/test/migration/**/*.test.ts',
|
||||
'<rootDir>/src/**/*.integration.test.ts',
|
||||
],
|
||||
testMatch: ['<rootDir>/test/integration/**/*.test.ts', '<rootDir>/src/**/*.integration.test.ts'],
|
||||
testPathIgnorePatterns: ['/dist/', '/node_modules/'],
|
||||
};
|
||||
|
||||
if (workerIdleMemoryLimit !== undefined) {
|
||||
config.workerIdleMemoryLimit = workerIdleMemoryLimit;
|
||||
}
|
||||
|
||||
module.exports = config;
|
||||
|
|
|
|||
|
|
@ -4,8 +4,10 @@ import { WorkflowRepository } from '@n8n/db';
|
|||
import { Container } from '@n8n/di';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import { NodeConnectionTypes } from 'n8n-workflow';
|
||||
import nock from 'nock';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import * as constants from '@/constants';
|
||||
import { INSTANCE_REPORT, WEBHOOK_VALIDATOR_NODE_TYPES } from '@/security-audit/constants';
|
||||
import { SecurityAuditService } from '@/security-audit/security-audit.service';
|
||||
import { toReportTitle } from '@/security-audit/utils';
|
||||
|
|
@ -19,20 +21,34 @@ import {
|
|||
} from './utils';
|
||||
|
||||
let securityAuditService: SecurityAuditService;
|
||||
let originalN8nVersion: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await testDb.init();
|
||||
|
||||
securityAuditService = new SecurityAuditService(Container.get(WorkflowRepository), mock());
|
||||
|
||||
originalN8nVersion = constants.N8N_VERSION;
|
||||
});
|
||||
|
||||
// Reset nock + the mutated N8N_VERSION constant between tests so each test
|
||||
// starts from the up-to-date baseline. `simulateOutdatedInstanceOnce` mutates
|
||||
// `constants.N8N_VERSION` and registers a `.once()` interceptor; without this
|
||||
// reset, later tests inherit the stale version + a consumed interceptor and
|
||||
// fail on requests to api.n8n.io for the leftover version. Previously masked
|
||||
// by the `workerIdleMemoryLimit: '1MB'` per-file worker recycling.
|
||||
beforeEach(async () => {
|
||||
await testDb.truncate(['WorkflowEntity', 'WorkflowHistory', 'WorkflowPublishHistory']);
|
||||
nock.cleanAll();
|
||||
// @ts-expect-error readonly export
|
||||
constants.N8N_VERSION = originalN8nVersion;
|
||||
simulateUpToDateInstance();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await testDb.truncate(['WorkflowEntity', 'WorkflowHistory', 'WorkflowPublishHistory']);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
nock.cleanAll();
|
||||
// @ts-expect-error readonly export
|
||||
constants.N8N_VERSION = originalN8nVersion;
|
||||
await testDb.terminate();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -369,8 +369,21 @@ export const setupTestServer = ({
|
|||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Close the HTTP server first so any in-flight requests can't reach the
|
||||
// DI container after testDb.terminate() resets it. Await the close so
|
||||
// pending handlers drain before the next file's beforeAll runs in
|
||||
// persistent Jest workers — otherwise stale handlers call
|
||||
// Container.get(Logger), construct a fresh Logger, and trip Jest's
|
||||
// "environment torn down" guard when winston is imported.
|
||||
// Skip when the server never started listening (some suites bail in
|
||||
// beforeAll); calling close() on a non-listening server throws
|
||||
// "Server is not running" and would mask the real beforeAll failure.
|
||||
if (testServer.httpServer.listening) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
testServer.httpServer.close((err) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
}
|
||||
await testDb.terminate();
|
||||
testServer.httpServer.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
|
|||
|
|
@ -34,4 +34,18 @@ module.exports = async () => {
|
|||
console.log(
|
||||
`\n✓ Postgres ready at ${process.env.DB_POSTGRESDB_HOST}:${process.env.DB_POSTGRESDB_PORT}\n`,
|
||||
);
|
||||
|
||||
// Build a template DB once, then each test file's testDb.init() clones it via
|
||||
// CREATE DATABASE ... TEMPLATE instead of replaying the full migration history.
|
||||
// Set N8N_TEST_DISABLE_TEMPLATE_DB=1 to opt out (e.g. when bisecting migration bugs).
|
||||
if (process.env.N8N_TEST_DISABLE_TEMPLATE_DB !== '1') {
|
||||
const templateName = `n8n_test_template_${suffix}`;
|
||||
const tplStart = Date.now();
|
||||
const { testDb } = require('@n8n/backend-test-utils');
|
||||
await testDb.initTemplateDb(templateName);
|
||||
process.env.N8N_TEST_TEMPLATE_DB = templateName;
|
||||
console.log(
|
||||
`✓ Template DB ${templateName} ready (${Date.now() - tplStart}ms) — workers will clone instead of migrate\n`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user