From cb0da79c87cd0f17c15badea5827da4c3a3afffb Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:22:33 -0400 Subject: [PATCH] refactor(@angular/build): remove arbitrary cap from maxWorkers and localize bundling concurrency Previously, maxWorkers was clamped to a maximum of 4 globally across the entire build system. This limitation was originally introduced to mitigate memory pressure from Babel transforms, but subsequent optimizations such as the OXC linker migration, zero-copy shared memory for i18n translations, and bounded memory buffers have eliminated those memory constraints. Consequently, clamping maxWorkers artificially restricted post-bundle operations like translation inlining and route prerendering on high-core systems. This change decouples the global default of maxWorkers so that it scales with available parallelism minus one, ensuring the main thread is not starved while allowing parallel tasks to utilize full hardware capacity. When NG_BUILD_MAX_WORKERS is specified, it is safely parsed as a positive integer or falls back to the default available parallelism. To avoid CPU contention during bundling when esbuild concurrently executes its internal multi-threaded Go routine across all cores, transformation concurrency for JavaScriptTransformer is now locally capped to at most 4 unless NG_BUILD_MAX_WORKERS has been explicitly provided, tracked via the exported hasCustomMaxWorkers option. --- .../tools/esbuild/angular/compiler-plugin.ts | 12 +- .../build/src/utils/environment-options.ts | 26 ++-- .../src/utils/environment-options_spec.ts | 116 ++++++++++++++++++ 3 files changed, 141 insertions(+), 13 deletions(-) create mode 100644 packages/angular/build/src/utils/environment-options_spec.ts diff --git a/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts b/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts index 5ce1130d093a..f80f3c78afca 100644 --- a/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts +++ b/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts @@ -20,7 +20,11 @@ import type { import assert from 'node:assert'; import { readFile } from 'node:fs/promises'; import * as path from 'node:path'; -import { maxWorkers, useTypeChecking } from '../../../utils/environment-options'; +import { + hasCustomMaxWorkers, + maxWorkers, + useTypeChecking, +} from '../../../utils/environment-options'; import { calculateHash, initializeHash } from '../../../utils/hash'; import { AngularHostOptions } from '../../angular/angular-host'; import { AngularCompilation, DiagnosticModes } from '../../angular/compilation'; @@ -97,6 +101,10 @@ export function createCompilerPlugin( }); } } + // During bundling, esbuild runs its own multi-threaded Go process across all available cores. + // Unless explicitly configured via NG_BUILD_MAX_WORKERS, cap transformation concurrency to at + // most 4 to prevent CPU contention during bundling. + const maxTransformWorkers = hasCustomMaxWorkers ? maxWorkers : Math.min(4, maxWorkers); const javascriptTransformer = new JavaScriptTransformer( { sourcemap: !!pluginOptions.sourcemap, @@ -104,7 +112,7 @@ export function createCompilerPlugin( advancedOptimizations: pluginOptions.advancedOptimizations, jit: pluginOptions.jit || pluginOptions.includeTestMetadata, }, - maxWorkers, + maxTransformWorkers, cacheStore?.createCache('jstransformer'), ); diff --git a/packages/angular/build/src/utils/environment-options.ts b/packages/angular/build/src/utils/environment-options.ts index f008673a1552..1e225d6b7386 100644 --- a/packages/angular/build/src/utils/environment-options.ts +++ b/packages/angular/build/src/utils/environment-options.ts @@ -108,24 +108,28 @@ export const allowMinify = debugOptimize.minify; */ export const useRolldownChunks = parseTristate(process.env['NG_BUILD_CHUNKS_ROLLDOWN']) ?? true; +const maxWorkersVariable = process.env['NG_BUILD_MAX_WORKERS']; + +let customMaxWorkers: number | undefined; +if (isPresent(maxWorkersVariable)) { + const parsed = +maxWorkersVariable; + if (Number.isInteger(parsed) && parsed >= 1) { + customMaxWorkers = parsed; + } +} + /** - * Some environments, like CircleCI which use Docker report a number of CPUs by the host and not the count of available. - * This cause `Error: Call retries were exceeded` errors when trying to use them. - * - * @see https://github.com/nodejs/node/issues/28762 - * @see https://github.com/webpack-contrib/terser-webpack-plugin/issues/143 - * @see https://ithub.com/angular/angular-cli/issues/16860#issuecomment-588828079 - * + * Whether the maximum number of workers was explicitly configured via the + * `NG_BUILD_MAX_WORKERS` environment variable. */ -const maxWorkersVariable = process.env['NG_BUILD_MAX_WORKERS']; +export const hasCustomMaxWorkers = customMaxWorkers !== undefined; /** * The maximum number of workers to use for parallel processing. * This can be controlled by the `NG_BUILD_MAX_WORKERS` environment variable. + * When not set, defaults to available parallelism minus one to ensure the main thread is not starved. */ -export const maxWorkers = isPresent(maxWorkersVariable) - ? +maxWorkersVariable - : Math.min(4, Math.max(availableParallelism() - 1, 1)); +export const maxWorkers = customMaxWorkers ?? Math.max(availableParallelism() - 1, 1); /** * When `NG_BUILD_PARALLEL_TS` is set to `0` or `false`, parallel TypeScript compilation is disabled. diff --git a/packages/angular/build/src/utils/environment-options_spec.ts b/packages/angular/build/src/utils/environment-options_spec.ts new file mode 100644 index 000000000000..0d2ac7fa0c61 --- /dev/null +++ b/packages/angular/build/src/utils/environment-options_spec.ts @@ -0,0 +1,116 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { availableParallelism } from 'node:os'; + +describe('environment options - maxWorkers', () => { + const originalEnvValue = process.env['NG_BUILD_MAX_WORKERS']; + + function loadEnvironmentOptions(): typeof import('./environment-options') { + delete require.cache[require.resolve('./environment-options')]; + + return require('./environment-options'); + } + + afterEach(() => { + if (originalEnvValue !== undefined) { + process.env['NG_BUILD_MAX_WORKERS'] = originalEnvValue; + } else { + delete process.env['NG_BUILD_MAX_WORKERS']; + } + delete require.cache[require.resolve('./environment-options')]; + }); + + it('defaults maxWorkers to availableParallelism - 1 when NG_BUILD_MAX_WORKERS is unset', () => { + delete process.env['NG_BUILD_MAX_WORKERS']; + const { maxWorkers, hasCustomMaxWorkers } = loadEnvironmentOptions(); + + expect(hasCustomMaxWorkers).toBeFalse(); + expect(maxWorkers).toBe(Math.max(availableParallelism() - 1, 1)); + }); + + it('uses configured positive integer when NG_BUILD_MAX_WORKERS is set', () => { + process.env['NG_BUILD_MAX_WORKERS'] = '8'; + const { maxWorkers, hasCustomMaxWorkers } = loadEnvironmentOptions(); + + expect(hasCustomMaxWorkers).toBeTrue(); + expect(maxWorkers).toBe(8); + }); + + it('allows maxWorkers greater than 4 when explicitly configured', () => { + process.env['NG_BUILD_MAX_WORKERS'] = '32'; + const { maxWorkers, hasCustomMaxWorkers } = loadEnvironmentOptions(); + + expect(hasCustomMaxWorkers).toBeTrue(); + expect(maxWorkers).toBe(32); + }); + + it('supports maxWorkers set to 1', () => { + process.env['NG_BUILD_MAX_WORKERS'] = '1'; + const { maxWorkers, hasCustomMaxWorkers } = loadEnvironmentOptions(); + + expect(hasCustomMaxWorkers).toBeTrue(); + expect(maxWorkers).toBe(1); + }); + + it('falls back to availableParallelism - 1 when NG_BUILD_MAX_WORKERS is 0', () => { + process.env['NG_BUILD_MAX_WORKERS'] = '0'; + const { maxWorkers, hasCustomMaxWorkers } = loadEnvironmentOptions(); + + expect(hasCustomMaxWorkers).toBeFalse(); + expect(maxWorkers).toBe(Math.max(availableParallelism() - 1, 1)); + }); + + it('falls back to availableParallelism - 1 when NG_BUILD_MAX_WORKERS is negative', () => { + process.env['NG_BUILD_MAX_WORKERS'] = '-4'; + const { maxWorkers, hasCustomMaxWorkers } = loadEnvironmentOptions(); + + expect(hasCustomMaxWorkers).toBeFalse(); + expect(maxWorkers).toBe(Math.max(availableParallelism() - 1, 1)); + }); + + it('falls back to availableParallelism - 1 when NG_BUILD_MAX_WORKERS is not a number', () => { + process.env['NG_BUILD_MAX_WORKERS'] = 'invalid'; + const { maxWorkers, hasCustomMaxWorkers } = loadEnvironmentOptions(); + + expect(hasCustomMaxWorkers).toBeFalse(); + expect(maxWorkers).toBe(Math.max(availableParallelism() - 1, 1)); + }); + + it('falls back to availableParallelism - 1 when NG_BUILD_MAX_WORKERS is a float', () => { + process.env['NG_BUILD_MAX_WORKERS'] = '2.5'; + const { maxWorkers, hasCustomMaxWorkers } = loadEnvironmentOptions(); + + expect(hasCustomMaxWorkers).toBeFalse(); + expect(maxWorkers).toBe(Math.max(availableParallelism() - 1, 1)); + }); + + it('falls back to availableParallelism - 1 when NG_BUILD_MAX_WORKERS is an empty string', () => { + process.env['NG_BUILD_MAX_WORKERS'] = ''; + const { maxWorkers, hasCustomMaxWorkers } = loadEnvironmentOptions(); + + expect(hasCustomMaxWorkers).toBeFalse(); + expect(maxWorkers).toBe(Math.max(availableParallelism() - 1, 1)); + }); + + it('falls back to availableParallelism - 1 when NG_BUILD_MAX_WORKERS is whitespace only', () => { + process.env['NG_BUILD_MAX_WORKERS'] = ' '; + const { maxWorkers, hasCustomMaxWorkers } = loadEnvironmentOptions(); + + expect(hasCustomMaxWorkers).toBeFalse(); + expect(maxWorkers).toBe(Math.max(availableParallelism() - 1, 1)); + }); + + it('parses positive integers with surrounding whitespace', () => { + process.env['NG_BUILD_MAX_WORKERS'] = ' 8 '; + const { maxWorkers, hasCustomMaxWorkers } = loadEnvironmentOptions(); + + expect(hasCustomMaxWorkers).toBeTrue(); + expect(maxWorkers).toBe(8); + }); +});