Skip to content

Commit c21cc7d

Browse files
committed
refactor(@angular/build): implement generic persistent load result cache infrastructure for build pipeline
This change implements a unified, generic persistent caching system for the esbuild builder pipeline. It introduces a two-tier caching mechanism combining in-memory caching with a persistent disk store. Integration into various aspects of the build system will be performed in future changes.
1 parent 9c282d3 commit c21cc7d

5 files changed

Lines changed: 578 additions & 3 deletions

File tree

packages/angular/build/src/tools/esbuild/load-result-cache.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import type { OnLoadResult, PluginBuild } from 'esbuild';
1010
import { normalize } from 'node:path';
1111

1212
export interface LoadResultCache {
13-
get(path: string): OnLoadResult | undefined;
13+
get(path: string): OnLoadResult | Promise<OnLoadResult | undefined> | undefined;
1414
put(path: string, result: OnLoadResult): Promise<void>;
1515
readonly watchFiles: ReadonlyArray<string>;
1616
}
@@ -25,7 +25,7 @@ export function createCachedLoad(
2525

2626
return async (args) => {
2727
const loadCacheKey = `${args.namespace}:${args.path}`;
28-
let result: OnLoadResult | null | undefined = cache.get(loadCacheKey);
28+
let result: OnLoadResult | null | undefined = await cache.get(loadCacheKey);
2929

3030
if (result === undefined) {
3131
result = await callback(args);
@@ -35,7 +35,9 @@ export function createCachedLoad(
3535
// Ensure requested path is included if it was a resolved file
3636
if (args.namespace === 'file') {
3737
result.watchFiles ??= [];
38-
result.watchFiles.push(args.path);
38+
if (!result.watchFiles.includes(args.path)) {
39+
result.watchFiles.push(args.path);
40+
}
3941
}
4042
await cache.put(loadCacheKey, result);
4143
}
Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import type { Loader, OnLoadResult, PartialMessage } from 'esbuild';
10+
import { createHash } from 'node:crypto';
11+
import { readFile, stat } from 'node:fs/promises';
12+
import type { Cache as PersistentCacheStore } from './cache';
13+
import { LoadResultCache, MemoryLoadResultCache } from './load-result-cache';
14+
15+
/**
16+
* Metadata for a single watch file dependency.
17+
*/
18+
export interface CachedDependencyMetadata {
19+
hash: string;
20+
mtimeMs: number;
21+
size: number;
22+
}
23+
24+
/**
25+
* Serialized representation of any esbuild load result stored in persistent cache.
26+
*/
27+
export interface CachedLoadResultEntry {
28+
/** Compiled output string or binary data */
29+
contents: string | Uint8Array;
30+
31+
/** esbuild loader type */
32+
loader?: Loader;
33+
34+
/** Absolute paths of all imported/watched dependency files */
35+
watchFiles: string[];
36+
37+
/** Map of watchFile absolute paths to dependency metadata */
38+
watchFilesMetadata: Record<string, CachedDependencyMetadata>;
39+
40+
/** Warnings emitted during load processing */
41+
warnings?: PartialMessage[];
42+
43+
/** Errors emitted during load processing */
44+
errors?: PartialMessage[];
45+
}
46+
47+
function hashContent(content: string | Uint8Array): string {
48+
return createHash('sha256').update(content).digest('hex');
49+
}
50+
51+
/**
52+
* Calculates a unique cache key by updating the hash incrementally.
53+
* This prevents implicit string coercion of large binary content buffers.
54+
*/
55+
function calculateCacheKey(
56+
globalConfigHash: string,
57+
path: string,
58+
content: string | Uint8Array,
59+
): string {
60+
return createHash('sha256')
61+
.update(globalConfigHash)
62+
.update('\0')
63+
.update(path)
64+
.update('\0')
65+
.update(content)
66+
.digest('hex');
67+
}
68+
69+
/**
70+
* Validates that all imported watch files exist on disk and their contents match.
71+
* Performs a fast-path metadata check (mtime + size) first, falling back to content hashing.
72+
* Heals/updates the cached metadata on disk if the content hash was valid but the metadata changed.
73+
*/
74+
async function validateAndHealCacheEntry(
75+
watchFilesMetadata: Record<string, CachedDependencyMetadata>,
76+
store: PersistentCacheStore<CachedLoadResultEntry>,
77+
cacheKey: string,
78+
cached: CachedLoadResultEntry,
79+
targetFilePath?: string,
80+
): Promise<boolean> {
81+
const watchFiles = Object.keys(watchFilesMetadata);
82+
const concurrencyLimit = 8;
83+
let healed = false;
84+
85+
for (let i = 0; i < watchFiles.length; i += concurrencyLimit) {
86+
const chunk = watchFiles.slice(i, i + concurrencyLimit);
87+
const results = await Promise.all(
88+
chunk.map(async (filePath) => {
89+
try {
90+
const stats = await stat(filePath);
91+
const expected = watchFilesMetadata[filePath];
92+
if (!expected) {
93+
return false;
94+
}
95+
96+
// 1. Fast Path: size and mtime match
97+
if (stats.size === expected.size && stats.mtimeMs === expected.mtimeMs) {
98+
return true;
99+
}
100+
101+
// 2. Target File Path: content hash was already verified by cacheKey lookup, heal metadata if mtime changed
102+
if (targetFilePath && filePath === targetFilePath) {
103+
watchFilesMetadata[filePath] = {
104+
...expected,
105+
mtimeMs: stats.mtimeMs,
106+
size: stats.size,
107+
};
108+
healed = true;
109+
110+
return true;
111+
}
112+
113+
// 3. Slow Path for dependencies: content hash fallback
114+
const currentContent = await readFile(filePath);
115+
const currentHash = hashContent(currentContent);
116+
if (currentHash === expected.hash) {
117+
// Heal cache entry with new metadata
118+
watchFilesMetadata[filePath] = {
119+
...expected,
120+
mtimeMs: stats.mtimeMs,
121+
size: stats.size,
122+
};
123+
healed = true;
124+
125+
return true;
126+
}
127+
128+
return false;
129+
} catch {
130+
return false;
131+
}
132+
}),
133+
);
134+
135+
if (results.some((isValid) => !isValid)) {
136+
return false;
137+
}
138+
}
139+
140+
if (healed) {
141+
try {
142+
await store.put(cacheKey, cached);
143+
} catch {
144+
// Ignore errors writing healed entries
145+
}
146+
}
147+
148+
return true;
149+
}
150+
151+
/**
152+
* Computes metadata (content hashes, mtime, size) for an array of watch file paths.
153+
* Processes files in parallel chunks of 8 to avoid exhausting file descriptors.
154+
*/
155+
async function computeMetadataForWatchFiles(
156+
watchFiles: string[],
157+
knownContents?: Map<string, string | Uint8Array>,
158+
): Promise<Record<string, CachedDependencyMetadata>> {
159+
const watchFilesMetadata: Record<string, CachedDependencyMetadata> = {};
160+
const concurrencyLimit = 8;
161+
162+
for (let i = 0; i < watchFiles.length; i += concurrencyLimit) {
163+
const chunk = watchFiles.slice(i, i + concurrencyLimit);
164+
await Promise.all(
165+
chunk.map(async (filePath) => {
166+
try {
167+
const knownContent = knownContents?.get(filePath);
168+
const [content, stats] = await Promise.all([
169+
knownContent !== undefined ? knownContent : readFile(filePath),
170+
stat(filePath),
171+
]);
172+
watchFilesMetadata[filePath] = {
173+
hash: hashContent(content),
174+
mtimeMs: stats.mtimeMs,
175+
size: stats.size,
176+
};
177+
} catch {
178+
// Ignore unreadable files
179+
}
180+
}),
181+
);
182+
}
183+
184+
return watchFilesMetadata;
185+
}
186+
187+
export class PersistentLoadResultCache implements LoadResultCache {
188+
private readonly memoryCache = new MemoryLoadResultCache();
189+
190+
constructor(
191+
private readonly persistentStore?: PersistentCacheStore<CachedLoadResultEntry>,
192+
private readonly globalConfigHash: string = '',
193+
) {}
194+
195+
/**
196+
* Retrieves a load result from cache.
197+
* Checks L1 memory cache first for immediate watch-mode speed, falling back to L2 persistent disk
198+
* store on L1 cache miss. L2 persistent cache entries are validated against dependency metadata.
199+
*/
200+
async get(path: string): Promise<OnLoadResult | undefined> {
201+
// 1. Check L1 Memory Cache
202+
const memoryResult = this.memoryCache.get(path);
203+
if (memoryResult) {
204+
return memoryResult;
205+
}
206+
207+
if (!this.persistentStore) {
208+
return undefined;
209+
}
210+
211+
// 2. Check L2 Persistent Disk Cache
212+
let content: string | Uint8Array;
213+
const filePath = path.startsWith('file:') ? path.slice(5) : path;
214+
try {
215+
content = await readFile(filePath);
216+
} catch {
217+
return undefined;
218+
}
219+
220+
const cacheKey = calculateCacheKey(this.globalConfigHash, path, content);
221+
const cached = await this.persistentStore.get(cacheKey);
222+
223+
if (
224+
cached &&
225+
(await validateAndHealCacheEntry(
226+
cached.watchFilesMetadata,
227+
this.persistentStore,
228+
cacheKey,
229+
cached,
230+
filePath,
231+
))
232+
) {
233+
const result: OnLoadResult = {
234+
contents: cached.contents,
235+
loader: cached.loader,
236+
watchFiles: cached.watchFiles,
237+
warnings: cached.warnings,
238+
errors: cached.errors,
239+
};
240+
241+
// Populate L1 Memory Cache for subsequent lookups
242+
await this.memoryCache.put(path, result);
243+
244+
return result;
245+
}
246+
247+
return undefined;
248+
}
249+
250+
/**
251+
* Stores a load result in both L1 memory cache and L2 persistent disk store.
252+
*/
253+
async put(path: string, result: OnLoadResult): Promise<void> {
254+
await this.memoryCache.put(path, result);
255+
256+
// Persist to L2 store if persistentStore is configured and contents exist (including empty strings/buffers)
257+
if (this.persistentStore && result.contents !== undefined) {
258+
let content: string | Uint8Array;
259+
const filePath = path.startsWith('file:') ? path.slice(5) : path;
260+
try {
261+
content = await readFile(filePath);
262+
} catch {
263+
content = '';
264+
}
265+
266+
const cacheKey = calculateCacheKey(this.globalConfigHash, path, content);
267+
268+
// Reuse the target file's pre-read content buffer to avoid redundant disk reads (readFile)
269+
// during dependency watch file metadata computation.
270+
const knownContents = new Map<string, string | Uint8Array>([[filePath, content]]);
271+
const watchFilesMetadata = await computeMetadataForWatchFiles(
272+
result.watchFiles ?? [],
273+
knownContents,
274+
);
275+
276+
await this.persistentStore.put(cacheKey, {
277+
contents: result.contents,
278+
loader: result.loader,
279+
watchFiles: result.watchFiles ?? [],
280+
watchFilesMetadata,
281+
warnings: result.warnings,
282+
errors: result.errors,
283+
});
284+
}
285+
}
286+
287+
/**
288+
* Invalidates cached entries affected by a modified dependency file during watch mode.
289+
*
290+
* Note: Invalidation of L1 memory cache is sufficient for active watch mode.
291+
* Cross-process/cold start stale entries in L2 persistent store are automatically handled
292+
* during `get()` via dependency metadata verification (`validateAndHealCacheEntry`).
293+
*/
294+
invalidate(path: string): boolean {
295+
return this.memoryCache.invalidate(path);
296+
}
297+
298+
get watchFiles(): ReadonlyArray<string> {
299+
return this.memoryCache.watchFiles;
300+
}
301+
}

0 commit comments

Comments
 (0)