Skip to content

Commit 0b4a48a

Browse files
committed
perf(@angular/build): implement semaphore backpressure throttling in JavaScriptTransformer
Throttle active esbuild transformation requests higher in the pipeline using an asynchronous semaphore queue bounded to maxThreads * 2. In large monorepo builds (thousands of files), esbuild crawls the import graph via parallel goroutines much faster than Node.js workers can process downleveling and linking. Unthrottled onLoad calls flood libuv's file read pool and accumulate thousands of source buffers in Piscina's task queue. Throttling active requests higher in the chain keeps libuv's I/O pool free, caps Buffer memory overhead at ~10MB–30MB, and ensures file buffers remain short-lived for quicker GC reclamation. (cherry picked from commit 583736a)
1 parent 136fc27 commit 0b4a48a

1 file changed

Lines changed: 100 additions & 52 deletions

File tree

packages/angular/build/src/tools/esbuild/javascript-transformer.ts

Lines changed: 100 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,22 @@ export class JavaScriptTransformer {
3434
#commonOptions: Required<JavaScriptTransformerOptions>;
3535
#fileCacheKeyBase: Uint8Array;
3636

37+
/** Queue of pending transformation tasks waiting for an active concurrency slot. */
38+
#pendingTasks: { resolve: () => void; reject: (reason: Error) => void }[] = [];
39+
40+
/** Current count of actively executing transformation tasks. */
41+
#activeTasks = 0;
42+
43+
/** Maximum number of transformation tasks allowed to execute concurrently. */
44+
#maxConcurrent: number;
45+
3746
constructor(
3847
options: JavaScriptTransformerOptions,
3948
readonly maxThreads: number,
4049
private readonly cache?: Cache<Uint8Array>,
4150
) {
51+
// Maintain 2 active tasks per worker thread to keep transformation pipelines fully saturated
52+
this.#maxConcurrent = Math.max(1, maxThreads * 2);
4253
// Extract options to ensure only the named options are serialized and sent to the worker
4354
const {
4455
sourcemap,
@@ -55,6 +66,33 @@ export class JavaScriptTransformer {
5566
this.#fileCacheKeyBase = Buffer.from(JSON.stringify(this.#commonOptions), 'utf-8');
5667
}
5768

69+
/**
70+
* Executes a transformation action using a semaphore-based backpressure throttle.
71+
* Prevents libuv thread pool saturation and excessive V8 heap accumulation.
72+
* @param action A callback that produces a promise for the transformation result.
73+
* @returns A promise resolving to the transformation result.
74+
*/
75+
async #runWithThrottle<T>(action: () => Promise<T>): Promise<T> {
76+
if (this.#activeTasks >= this.#maxConcurrent) {
77+
await new Promise<void>((resolve, reject) => {
78+
this.#pendingTasks.push({ resolve, reject });
79+
});
80+
} else {
81+
this.#activeTasks++;
82+
}
83+
84+
try {
85+
return await action();
86+
} finally {
87+
const next = this.#pendingTasks.shift();
88+
if (next) {
89+
next.resolve();
90+
} else {
91+
this.#activeTasks--;
92+
}
93+
}
94+
}
95+
5896
#ensureWorkerPool(): WorkerPool {
5997
if (this.#workerPool) {
6098
return this.#workerPool;
@@ -90,56 +128,58 @@ export class JavaScriptTransformer {
90128
sideEffects?: boolean,
91129
instrumentForCoverage?: boolean,
92130
): Promise<Uint8Array> {
93-
const data = await readFile(filename);
94-
95-
let result;
96-
let cacheKey;
97-
if (this.cache) {
98-
// Create a cache key from the file data and options that effect the output.
99-
// NOTE: If additional options are added, this may need to be updated.
100-
// TODO: Consider xxhash or similar instead of SHA256
101-
const hash = createHash('sha256');
102-
hash.update(`${!!skipLinker}--${!!sideEffects}`);
103-
hash.update(data);
104-
hash.update(this.#fileCacheKeyBase);
105-
cacheKey = hash.digest('hex');
131+
return this.#runWithThrottle(async () => {
132+
const data = await readFile(filename);
133+
134+
let result;
135+
let cacheKey;
136+
if (this.cache) {
137+
// Create a cache key from the file data and options that effect the output.
138+
// NOTE: If additional options are added, this may need to be updated.
139+
// TODO: Consider xxhash or similar instead of SHA256
140+
const hash = createHash('sha256');
141+
hash.update(`${!!skipLinker}--${!!sideEffects}`);
142+
hash.update(data);
143+
hash.update(this.#fileCacheKeyBase);
144+
cacheKey = hash.digest('hex');
106145

107-
try {
108-
result = await this.cache?.get(cacheKey);
109-
} catch {
110-
// Failure to get the value should not fail the transform
111-
}
112-
}
113-
114-
if (result === undefined) {
115-
// If there is no cache or no cached entry, process the file
116-
result = (await this.#ensureWorkerPool().run(
117-
{
118-
filename,
119-
data,
120-
skipLinker,
121-
sideEffects,
122-
instrumentForCoverage,
123-
...this.#commonOptions,
124-
},
125-
{
126-
// The below is disable as with Yarn PNP this causes build failures with the below message
127-
// `Unable to deserialize cloned data`.
128-
transferList: process.versions.pnp ? undefined : [data.buffer],
129-
},
130-
)) as Uint8Array;
131-
132-
// If there is a cache then store the result
133-
if (this.cache && cacheKey) {
134146
try {
135-
await this.cache.put(cacheKey, result);
147+
result = await this.cache?.get(cacheKey);
136148
} catch {
137-
// Failure to store the value in the cache should not fail the transform
149+
// Failure to get the value should not fail the transform
138150
}
139151
}
140-
}
141152

142-
return result;
153+
if (result === undefined) {
154+
// If there is no cache or no cached entry, process the file
155+
result = (await this.#ensureWorkerPool().run(
156+
{
157+
filename,
158+
data,
159+
skipLinker,
160+
sideEffects,
161+
instrumentForCoverage,
162+
...this.#commonOptions,
163+
},
164+
{
165+
// The below is disable as with Yarn PNP this causes build failures with the below message
166+
// `Unable to deserialize cloned data`.
167+
transferList: process.versions.pnp ? undefined : [data.buffer],
168+
},
169+
)) as Uint8Array;
170+
171+
// If there is a cache then store the result
172+
if (this.cache && cacheKey) {
173+
try {
174+
await this.cache.put(cacheKey, result);
175+
} catch {
176+
// Failure to store the value in the cache should not fail the transform
177+
}
178+
}
179+
}
180+
181+
return result;
182+
});
143183
}
144184

145185
/**
@@ -171,21 +211,29 @@ export class JavaScriptTransformer {
171211
);
172212
}
173213

174-
return this.#ensureWorkerPool().run({
175-
filename,
176-
data,
177-
skipLinker,
178-
sideEffects,
179-
instrumentForCoverage,
180-
...this.#commonOptions,
181-
});
214+
return this.#runWithThrottle(() =>
215+
this.#ensureWorkerPool().run({
216+
filename,
217+
data,
218+
skipLinker,
219+
sideEffects,
220+
instrumentForCoverage,
221+
...this.#commonOptions,
222+
}),
223+
);
182224
}
183225

184226
/**
185227
* Stops all active transformation tasks and shuts down all workers.
186228
* @returns A void promise that resolves when closing is complete.
187229
*/
188230
async close(): Promise<void> {
231+
const pending = this.#pendingTasks;
232+
this.#pendingTasks = [];
233+
for (const task of pending) {
234+
task.reject(new Error('JavaScriptTransformer closed.'));
235+
}
236+
189237
if (this.#workerPool) {
190238
try {
191239
await this.#workerPool.destroy();

0 commit comments

Comments
 (0)