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:
Declan Carroll 2026-06-05 07:27:41 +01:00 committed by GitHub
parent be01de6143
commit a83ea70bbc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 161 additions and 17 deletions

View File

@ -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

View File

@ -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'

View File

@ -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;
}

View File

@ -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;

View File

@ -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();
});

View File

@ -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(() => {

View File

@ -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`,
);
}
};