From a83ea70bbca560d052dc5b29d329c3bfb0f79e13 Mon Sep 17 00:00:00 2001 From: Declan Carroll Date: Fri, 5 Jun 2026 07:27:41 +0100 Subject: [PATCH] ci: Speed up DB integration tests via Postgres template DB + persistent Jest workers (#31778) Co-authored-by: Claude Opus 4.7 --- .github/workflows/ci-pull-requests.yml | 7 ++ .github/workflows/test-db-reusable.yml | 6 +- .../@n8n/backend-test-utils/src/test-db.ts | 69 ++++++++++++++++++- packages/cli/jest.config.integration.js | 43 +++++++++--- .../instance-risk-reporter.test.ts | 24 +++++-- .../integration/shared/utils/test-server.ts | 15 +++- packages/cli/test/setup-testcontainers.js | 14 ++++ 7 files changed, 161 insertions(+), 17 deletions(-) diff --git a/.github/workflows/ci-pull-requests.yml b/.github/workflows/ci-pull-requests.yml index 5441cb9b104..117ffb78c92 100644 --- a/.github/workflows/ci-pull-requests.yml +++ b/.github/workflows/ci-pull-requests.yml @@ -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 diff --git a/.github/workflows/test-db-reusable.yml b/.github/workflows/test-db-reusable.yml index 96392052e8d..1ec021c245f 100644 --- a/.github/workflows/test-db-reusable.yml +++ b/.github/workflows/test-db-reusable.yml @@ -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' diff --git a/packages/@n8n/backend-test-utils/src/test-db.ts b/packages/@n8n/backend-test-utils/src/test-db.ts index 9af13a725e1..cacbe2f3136 100644 --- a/packages/@n8n/backend-test-utils/src/test-db.ts +++ b/packages/@n8n/backend-test-utils/src/test-db.ts @@ -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 `, 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 { + 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; } diff --git a/packages/cli/jest.config.integration.js b/packages/cli/jest.config.integration.js index 1b486b842c8..601b54e2727 100644 --- a/packages/cli/jest.config.integration.js +++ b/packages/cli/jest.config.integration.js @@ -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: [ - '/test/integration/**/*.test.ts', - '/test/migration/**/*.test.ts', - '/src/**/*.integration.test.ts', - ], + testMatch: ['/test/integration/**/*.test.ts', '/src/**/*.integration.test.ts'], testPathIgnorePatterns: ['/dist/', '/node_modules/'], }; + +if (workerIdleMemoryLimit !== undefined) { + config.workerIdleMemoryLimit = workerIdleMemoryLimit; +} + +module.exports = config; diff --git a/packages/cli/test/integration/security-audit/instance-risk-reporter.test.ts b/packages/cli/test/integration/security-audit/instance-risk-reporter.test.ts index de96cf731f7..4b4b33a2f2b 100644 --- a/packages/cli/test/integration/security-audit/instance-risk-reporter.test.ts +++ b/packages/cli/test/integration/security-audit/instance-risk-reporter.test.ts @@ -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(); }); diff --git a/packages/cli/test/integration/shared/utils/test-server.ts b/packages/cli/test/integration/shared/utils/test-server.ts index e026f752457..a6f69c4842b 100644 --- a/packages/cli/test/integration/shared/utils/test-server.ts +++ b/packages/cli/test/integration/shared/utils/test-server.ts @@ -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((resolve, reject) => { + testServer.httpServer.close((err) => (err ? reject(err) : resolve())); + }); + } await testDb.terminate(); - testServer.httpServer.close(); }); beforeEach(() => { diff --git a/packages/cli/test/setup-testcontainers.js b/packages/cli/test/setup-testcontainers.js index 7266381cc1e..98d34edd008 100644 --- a/packages/cli/test/setup-testcontainers.js +++ b/packages/cli/test/setup-testcontainers.js @@ -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`, + ); + } };