Skip to content

Commit 625ee76

Browse files
committed
perf(@angular/build): implement sliding-window batching and worker translation eviction
In enterprise Angular applications with a high number of locales (e.g. 20–50+), deserializing translation dictionaries into native JavaScript objects across all worker threads simultaneously can lead to multi-gigabyte resident memory footprints in V8 worker heaps. This change introduces sliding-window locale batching and lock-free translation eviction: - `I18nInliner.inlineAll` processes locales in sliding windows of up to 8 locales each (`DEFAULT_LOCALE_WINDOW_SIZE`), capping peak memory while retaining maximum multi-locale batching throughput. - Worker tasks receive an `activeLocales` array on each batch request. Workers automatically purge any cached translation dictionaries in `deserializedTranslations` that are not part of the active window when transitioning across window boundaries.
1 parent 9a2ed62 commit 625ee76

3 files changed

Lines changed: 140 additions & 79 deletions

File tree

packages/angular/build/src/tools/esbuild/i18n-inliner-worker.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,12 @@ interface InlineFileBatchRequest {
8484
* Typically true when all remaining locales for the file are processed in a single batch.
8585
*/
8686
ephemeral?: boolean;
87+
88+
/**
89+
* The list of active locales in the current inlining window. Any cached translation dictionaries
90+
* not present in this list will be evicted from the Worker's memory cache.
91+
*/
92+
activeLocales?: string[];
8793
}
8894

8995
/**
@@ -237,6 +243,15 @@ export default async function inlineFile(request: InlineFileRequest) {
237243
export async function inlineFileBatch(
238244
request: InlineFileBatchRequest,
239245
): Promise<InlineFileBatchResult> {
246+
if (request.activeLocales) {
247+
const activeSet = new Set(request.activeLocales);
248+
for (const locale of deserializedTranslations.keys()) {
249+
if (!activeSet.has(locale)) {
250+
deserializedTranslations.delete(locale);
251+
}
252+
}
253+
}
254+
240255
const { code, metadata } = await loadFileData(request.filename, !request.ephemeral);
241256

242257
// Parse the sourcemap once for the entire batch.

packages/angular/build/src/tools/esbuild/i18n-inliner.ts

Lines changed: 101 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ import { type PersistentCacheStore, createPersistentCacheStore } from './cache';
2020
*/
2121
const LOCALIZE_KEYWORD = '$localize';
2222

23+
/**
24+
* The maximum number of locales to process concurrently in a single sliding window.
25+
* This caps peak worker memory while maintaining multi-locale batching throughput.
26+
*/
27+
const DEFAULT_LOCALE_WINDOW_SIZE = 8;
28+
2329
/**
2430
* Serializes the translation messages for a locale for transfer to an inliner Worker.
2531
*
@@ -218,101 +224,114 @@ export class I18nInliner {
218224
fileResultsByLocale.set(locale, new Map());
219225
}
220226

221-
// Pre-calculate cache key bases and serialized Blobs for each requested locale
222-
const localeCacheBases = new Map<string, string>();
223-
const localeBlobs = new Map<string, Blob | undefined>();
224-
225-
for (const { locale, translation, translationIntegrity } of localeList) {
226-
localeBlobs.set(locale, serializeTranslation(translation));
227-
228-
if (this.#cache) {
229-
localeCacheBases.set(
230-
locale,
231-
calculateHash(
232-
JSON.stringify({
233-
locale,
234-
translation: translationIntegrity || translation,
235-
missingTranslation,
236-
shouldOptimize,
237-
localizeVersion,
238-
}),
239-
),
240-
);
241-
}
242-
}
243-
244227
const filenames = Array.from(this.#localizeFiles.keys()).filter(
245228
(name) => !name.endsWith('.map'),
246229
);
247230

248-
const cacheChecks: CacheCheckItem[] = [];
231+
// Process locales in sliding windows to cap peak worker memory
232+
for (let i = 0; i < localeList.length; i += DEFAULT_LOCALE_WINDOW_SIZE) {
233+
const windowLocales = localeList.slice(i, i + DEFAULT_LOCALE_WINDOW_SIZE);
234+
const activeLocales = windowLocales.map((item) => item.locale);
235+
const isLastWindow = i + DEFAULT_LOCALE_WINDOW_SIZE >= localeList.length;
249236

250-
for (const filename of filenames) {
251-
const file = this.#localizeFiles.get(filename);
252-
assert(file !== undefined, 'Localize file must exist: ' + filename);
237+
// Pre-calculate cache key bases and serialized Blobs for each locale in this window
238+
const localeCacheBases = new Map<string, string>();
239+
const localeBlobs = new Map<string, Blob | undefined>();
253240

254-
for (const { locale } of localeList) {
255-
let cacheKey: string | undefined;
256-
let cachedResultPromise: Promise<TransformedFileResult | null> = Promise.resolve(null);
241+
for (const { locale, translation, translationIntegrity } of windowLocales) {
242+
localeBlobs.set(locale, serializeTranslation(translation));
257243

258244
if (this.#cache) {
259-
const fileCacheKeyBase = localeCacheBases.get(locale);
260-
assert(fileCacheKeyBase !== undefined, 'Cache base must exist for locale: ' + locale);
245+
localeCacheBases.set(
246+
locale,
247+
calculateHash(
248+
JSON.stringify({
249+
locale,
250+
translation: translationIntegrity || translation,
251+
missingTranslation,
252+
shouldOptimize,
253+
localizeVersion,
254+
}),
255+
),
256+
);
257+
}
258+
}
261259

262-
const hasher = createContentHash();
263-
hasher.update(file.hash);
264-
hasher.update(filename);
265-
hasher.update(fileCacheKeyBase);
266-
cacheKey = hasher.digest();
260+
const cacheChecks: CacheCheckItem[] = [];
267261

268-
cachedResultPromise = this.#cache.get(cacheKey).catch(() => null);
269-
}
262+
for (const filename of filenames) {
263+
const file = this.#localizeFiles.get(filename);
264+
assert(file !== undefined, 'Localize file must exist: ' + filename);
270265

271-
cacheChecks.push({
272-
filename,
273-
locale,
274-
cacheKey,
275-
cachedResult: cachedResultPromise,
276-
});
277-
}
278-
}
266+
for (const { locale } of windowLocales) {
267+
let cacheKey: string | undefined;
268+
let cachedResultPromise: Promise<TransformedFileResult | null> = Promise.resolve(null);
279269

280-
// Await all cache checks
281-
const resolvedChecks = await Promise.all(
282-
cacheChecks.map(async (item) => ({
283-
...item,
284-
result: await item.cachedResult,
285-
})),
286-
);
270+
if (this.#cache) {
271+
const fileCacheKeyBase = localeCacheBases.get(locale);
272+
assert(fileCacheKeyBase !== undefined, 'Cache base must exist for locale: ' + locale);
287273

288-
// Group uncached items by filename
289-
const uncachedByFile = new Map<
290-
string,
291-
Array<{ locale: string; cacheKey?: string; translation?: Blob }>
292-
>();
274+
const hasher = createContentHash();
275+
hasher.update(file.hash);
276+
hasher.update(filename);
277+
hasher.update(fileCacheKeyBase);
278+
cacheKey = hasher.digest();
293279

294-
for (const item of resolvedChecks) {
295-
if (item.result) {
296-
// Cache hit: store directly in locale file results
297-
fileResultsByLocale.get(item.locale)?.set(item.filename, item.result);
298-
} else {
299-
// Cache miss: needs worker processing
300-
let fileEntries = uncachedByFile.get(item.filename);
301-
if (!fileEntries) {
302-
fileEntries = [];
303-
uncachedByFile.set(item.filename, fileEntries);
280+
cachedResultPromise = this.#cache.get(cacheKey).catch(() => null);
281+
}
282+
283+
cacheChecks.push({
284+
filename,
285+
locale,
286+
cacheKey,
287+
cachedResult: cachedResultPromise,
288+
});
304289
}
305-
fileEntries.push({
306-
locale: item.locale,
307-
cacheKey: item.cacheKey,
308-
translation: localeBlobs.get(item.locale),
309-
});
310290
}
311-
}
312291

313-
// Adaptive 2D Sharding for uncached tasks
314-
if (uncachedByFile.size > 0) {
315-
await this.#processUncachedBatches(uncachedByFile, localeList.length, fileResultsByLocale);
292+
// Await all cache checks for this window
293+
const resolvedChecks = await Promise.all(
294+
cacheChecks.map(async (item) => ({
295+
...item,
296+
result: await item.cachedResult,
297+
})),
298+
);
299+
300+
// Group uncached items by filename for this window
301+
const uncachedByFile = new Map<
302+
string,
303+
Array<{ locale: string; cacheKey?: string; translation?: Blob }>
304+
>();
305+
306+
for (const item of resolvedChecks) {
307+
if (item.result) {
308+
// Cache hit: store directly in locale file results
309+
fileResultsByLocale.get(item.locale)?.set(item.filename, item.result);
310+
} else {
311+
// Cache miss: needs worker processing
312+
let fileEntries = uncachedByFile.get(item.filename);
313+
if (!fileEntries) {
314+
fileEntries = [];
315+
uncachedByFile.set(item.filename, fileEntries);
316+
}
317+
fileEntries.push({
318+
locale: item.locale,
319+
cacheKey: item.cacheKey,
320+
translation: localeBlobs.get(item.locale),
321+
});
322+
}
323+
}
324+
325+
// Adaptive 2D Sharding for uncached tasks in this window
326+
if (uncachedByFile.size > 0) {
327+
await this.#processUncachedBatches(
328+
uncachedByFile,
329+
windowLocales.length,
330+
fileResultsByLocale,
331+
activeLocales,
332+
isLastWindow,
333+
);
334+
}
316335
}
317336

318337
// Assemble final results in deterministic order per locale
@@ -366,6 +385,8 @@ export class I18nInliner {
366385
uncachedByFile: Map<string, Array<{ locale: string; cacheKey?: string; translation?: Blob }>>,
367386
localeCount: number,
368387
fileResultsByLocale: Map<string, Map<string, TransformedFileResult>>,
388+
activeLocales?: string[],
389+
isLastWindow = true,
369390
): Promise<void> {
370391
const workerCount = this.#workerPool.maxThreads || 1;
371392
const targetTaskCount = Math.max(uncachedByFile.size, workerCount * 2);
@@ -377,7 +398,7 @@ export class I18nInliner {
377398
const workerTasks: Promise<void>[] = [];
378399

379400
for (const [filename, entries] of uncachedByFile) {
380-
const ephemeral = entries.length <= localesPerBatch;
401+
const ephemeral = isLastWindow && entries.length <= localesPerBatch;
381402
for (let i = 0; i < entries.length; i += localesPerBatch) {
382403
const batchEntries = entries.slice(i, i + localesPerBatch);
383404
const task = (async () => {
@@ -389,6 +410,7 @@ export class I18nInliner {
389410
translation: e.translation,
390411
})),
391412
ephemeral,
413+
activeLocales,
392414
},
393415
{ name: 'inlineFileBatch' },
394416
)) as {

packages/angular/build/src/tools/esbuild/i18n-inliner_spec.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -516,4 +516,28 @@ describe('I18nInliner', () => {
516516
await fs.rm(cacheDir, { recursive: true, force: true });
517517
}
518518
});
519+
520+
it('inlines across sliding windows when locale count exceeds window size', async () => {
521+
const locales = Array.from({ length: 20 }, (_, i) => ({
522+
locale: `locale-${i}`,
523+
translation: { greeting: translationFor(`Hello ${i}`) },
524+
}));
525+
526+
const inliner = new I18nInliner(
527+
{
528+
missingTranslation: 'warning',
529+
outputFiles: [browserFile('main.js', GREETING_SOURCE)],
530+
},
531+
2,
532+
);
533+
534+
const results = await inliner.inlineAll(locales);
535+
536+
expect(results.size).toBe(20);
537+
for (let i = 0; i < 20; i++) {
538+
const localeResult = results.get(`locale-${i}`);
539+
expect(localeResult?.errors).toEqual([]);
540+
expect(findFile(localeResult?.outputFiles ?? [], 'main.js').text).toContain(`"Hello ${i}"`);
541+
}
542+
});
519543
});

0 commit comments

Comments
 (0)