Skip to content

Commit 3622686

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.
1 parent 5875b60 commit 3622686

1 file changed

Lines changed: 92 additions & 52 deletions

File tree

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

Lines changed: 92 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,20 @@ 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: (() => void)[] = [];
39+
/** Current count of actively executing transformation tasks. */
40+
#activeTasks = 0;
41+
/** Maximum number of transformation tasks allowed to execute concurrently. */
42+
#maxConcurrent: number;
43+
3744
constructor(
3845
options: JavaScriptTransformerOptions,
3946
readonly maxThreads: number,
4047
private readonly cache?: Cache<Uint8Array>,
4148
) {
49+
// Maintain 2 active tasks per worker thread to keep transformation pipelines fully saturated
50+
this.#maxConcurrent = maxThreads * 2;
4251
// Extract options to ensure only the named options are serialized and sent to the worker
4352
const {
4453
sourcemap,
@@ -55,6 +64,33 @@ export class JavaScriptTransformer {
5564
this.#fileCacheKeyBase = Buffer.from(JSON.stringify(this.#commonOptions), 'utf-8');
5665
}
5766

67+
/**
68+
* Executes a transformation action using a semaphore-based backpressure throttle.
69+
* Prevents libuv thread pool saturation and excessive V8 heap accumulation.
70+
* @param action A callback that produces a promise for the transformation result.
71+
* @returns A promise resolving to the transformation result.
72+
*/
73+
async #runWithThrottle<T>(action: () => Promise<T>): Promise<T> {
74+
if (this.#activeTasks >= this.#maxConcurrent) {
75+
await new Promise<void>((resolve) => {
76+
this.#pendingTasks.push(resolve);
77+
});
78+
} else {
79+
this.#activeTasks++;
80+
}
81+
82+
try {
83+
return await action();
84+
} finally {
85+
const next = this.#pendingTasks.shift();
86+
if (next) {
87+
next();
88+
} else {
89+
this.#activeTasks--;
90+
}
91+
}
92+
}
93+
5894
#ensureWorkerPool(): WorkerPool {
5995
if (this.#workerPool) {
6096
return this.#workerPool;
@@ -90,56 +126,58 @@ export class JavaScriptTransformer {
90126
sideEffects?: boolean,
91127
instrumentForCoverage?: boolean,
92128
): 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');
129+
return this.#runWithThrottle(async () => {
130+
const data = await readFile(filename);
131+
132+
let result;
133+
let cacheKey;
134+
if (this.cache) {
135+
// Create a cache key from the file data and options that effect the output.
136+
// NOTE: If additional options are added, this may need to be updated.
137+
// TODO: Consider xxhash or similar instead of SHA256
138+
const hash = createHash('sha256');
139+
hash.update(`${!!skipLinker}--${!!sideEffects}`);
140+
hash.update(data);
141+
hash.update(this.#fileCacheKeyBase);
142+
cacheKey = hash.digest('hex');
106143

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) {
134144
try {
135-
await this.cache.put(cacheKey, result);
145+
result = await this.cache?.get(cacheKey);
136146
} catch {
137-
// Failure to store the value in the cache should not fail the transform
147+
// Failure to get the value should not fail the transform
138148
}
139149
}
140-
}
141150

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

145183
/**
@@ -171,14 +209,16 @@ export class JavaScriptTransformer {
171209
);
172210
}
173211

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

184224
/**

0 commit comments

Comments
 (0)