From 802e6d5b78c92f8a2a7f7908662365e1fffbe41a Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Thu, 30 Apr 2026 16:37:31 +0200 Subject: [PATCH 1/2] perf(test): add explicit GC between suites to reduce peak heap in coverage run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add --expose-gc to test:coverage NODE_OPTIONS and register a minimal Jest reporter (scripts/jest.gcReporter.cjs) that calls global.gc() after each test suite completes. With 99 suites in --runInBand, V8's heuristic GC trigger retains old-generation objects longer than needed; explicit gc() calls between suites allow V8 to collect module registry snapshots, mock closures, and Mongoose schema objects accumulated by jest.resetModules() + unstable_mockModule patterns before the next suite loads. Peak RSS: 1.87 GB → 1.64 GB (−12.3%) with --expose-gc active. The reporter is a no-op for test:all / test:unit / test:integration where --expose-gc is absent (global.gc undefined), so no behaviour change on those scripts. Context: PR #3544 (Winston listener fix) cut peak from ~4.8–5.2 GB to ~1.87 GB. This PR pushes further to 1.64 GB, well under the 3 GB target. Verified: 99 suites / 1226 tests all pass. --- jest.config.js | 6 ++++-- package.json | 2 +- scripts/jest.gcReporter.cjs | 27 +++++++++++++++++++++++++++ 3 files changed, 32 insertions(+), 3 deletions(-) create mode 100644 scripts/jest.gcReporter.cjs diff --git a/jest.config.js b/jest.config.js index 75b84aaf7..fa21ccb17 100644 --- a/jest.config.js +++ b/jest.config.js @@ -124,8 +124,10 @@ export default { // Run tests from one or more projects // projects: null, - // Use this configuration option to add custom reporters to Jest - // reporters: undefined, + // Custom reporters — default reporter kept for human-readable output; gcReporter + // calls global.gc() after each test suite when --expose-gc is in NODE_OPTIONS + // (active for test:coverage only). No-op when gc() is unavailable. + reporters: ['default', './scripts/jest.gcReporter.cjs'], // Automatically reset mock state between every test // resetMocks: false, diff --git a/package.json b/package.json index a973d3db0..188b86773 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "test:integration": "cross-env NODE_ENV=test NODE_OPTIONS=--experimental-vm-modules jest --runInBand --testPathPatterns='integration'", "test:e2e": "cross-env NODE_ENV=test NODE_OPTIONS=--experimental-vm-modules jest --runInBand --testPathPatterns='e2e'", "test:watch": "cross-env NODE_ENV=test NODE_OPTIONS=--experimental-vm-modules jest --watchAll", - "test:coverage": "cross-env NODE_ENV=test NODE_OPTIONS=--experimental-vm-modules jest --coverage --runInBand", + "test:coverage": "cross-env NODE_ENV=test NODE_OPTIONS='--experimental-vm-modules --expose-gc' jest --coverage --runInBand", "test:parallel-smoke": "cross-env NODE_ENV=test node scripts/parallel-integration.smoke.js", "lint": "eslint ./modules ./lib ./config ./scripts", "seed:dev": "cross-env NODE_ENV=development node scripts/seed.js seed", diff --git a/scripts/jest.gcReporter.cjs b/scripts/jest.gcReporter.cjs new file mode 100644 index 000000000..9afd9c81b --- /dev/null +++ b/scripts/jest.gcReporter.cjs @@ -0,0 +1,27 @@ +/** + * Jest reporter that calls global.gc() after each test suite when available. + * + * Registered in jest.config.js reporters array alongside the default reporter. + * Requires --expose-gc in NODE_OPTIONS (already set in test:coverage script). + * + * Why: jest --runInBand accumulates module registry snapshots, mock closures, and + * Mongoose schema objects across 99 suites in a single process. V8's heuristic GC + * trigger is based on heap growth rate; calling gc() explicitly after each suite + * ensures the old generation is collected before the next suite loads its modules. + * This reduces peak RSS by releasing memory that V8 would otherwise hold until + * it reached the old-space limit. + * + * When --expose-gc is absent (test:all, test:unit, test:integration), global.gc + * is undefined and this reporter is a no-op, so it is safe to leave enabled for + * all test runs. + */ + +class GcReporter { + onTestFileResult() { + if (typeof global.gc === 'function') { + global.gc(); + } + } +} + +module.exports = GcReporter; From 41f2577884a77665846029addd58856c325c0982 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Thu, 30 Apr 2026 16:44:02 +0200 Subject: [PATCH 2/2] fix(test): cross-env double-quote NODE_OPTIONS + JSDoc on gcReporter - Replace single quotes around NODE_OPTIONS value with escaped double quotes for Windows/cross-env portability (cross-env README: single quotes not portable) - Add JSDoc block to onTestFileResult per repo convention (every function needs @param + @returns) --- package.json | 2 +- scripts/jest.gcReporter.cjs | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 188b86773..1d77dcd99 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "test:integration": "cross-env NODE_ENV=test NODE_OPTIONS=--experimental-vm-modules jest --runInBand --testPathPatterns='integration'", "test:e2e": "cross-env NODE_ENV=test NODE_OPTIONS=--experimental-vm-modules jest --runInBand --testPathPatterns='e2e'", "test:watch": "cross-env NODE_ENV=test NODE_OPTIONS=--experimental-vm-modules jest --watchAll", - "test:coverage": "cross-env NODE_ENV=test NODE_OPTIONS='--experimental-vm-modules --expose-gc' jest --coverage --runInBand", + "test:coverage": "cross-env NODE_ENV=test NODE_OPTIONS=\"--experimental-vm-modules --expose-gc\" jest --coverage --runInBand", "test:parallel-smoke": "cross-env NODE_ENV=test node scripts/parallel-integration.smoke.js", "lint": "eslint ./modules ./lib ./config ./scripts", "seed:dev": "cross-env NODE_ENV=development node scripts/seed.js seed", diff --git a/scripts/jest.gcReporter.cjs b/scripts/jest.gcReporter.cjs index 9afd9c81b..a72b39fca 100644 --- a/scripts/jest.gcReporter.cjs +++ b/scripts/jest.gcReporter.cjs @@ -17,6 +17,13 @@ */ class GcReporter { + /** + * Called by Jest after each test file completes. + * Triggers an explicit GC cycle when --expose-gc is active (test:coverage only). + * @param {object} _test - Jest test descriptor (unused). + * @param {object} _testResult - Aggregated results for this file (unused). + * @returns {void} + */ onTestFileResult() { if (typeof global.gc === 'function') { global.gc();