Skip to content

Commit adcb060

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 adcb060

5 files changed

Lines changed: 548 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: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
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+
): Promise<boolean> {
80+
const watchFiles = Object.keys(watchFilesMetadata);
81+
const concurrencyLimit = 8;
82+
let healed = false;
83+
84+
for (let i = 0; i < watchFiles.length; i += concurrencyLimit) {
85+
const chunk = watchFiles.slice(i, i + concurrencyLimit);
86+
const results = await Promise.all(
87+
chunk.map(async (filePath) => {
88+
try {
89+
const stats = await stat(filePath);
90+
const expected = watchFilesMetadata[filePath];
91+
92+
// 1. Fast Path: size and mtime match
93+
if (stats.size === expected.size && stats.mtimeMs === expected.mtimeMs) {
94+
return true;
95+
}
96+
97+
// 2. Slow Path: content hash fallback
98+
const currentContent = await readFile(filePath);
99+
const currentHash = hashContent(currentContent);
100+
if (currentHash === expected.hash) {
101+
// Heal cache entry with new metadata
102+
watchFilesMetadata[filePath] = {
103+
...expected,
104+
mtimeMs: stats.mtimeMs,
105+
size: stats.size,
106+
};
107+
healed = true;
108+
109+
return true;
110+
}
111+
112+
return false;
113+
} catch {
114+
return false;
115+
}
116+
}),
117+
);
118+
119+
if (results.some((isValid) => !isValid)) {
120+
return false;
121+
}
122+
}
123+
124+
if (healed) {
125+
try {
126+
await store.put(cacheKey, cached);
127+
} catch {
128+
// Ignore errors writing healed entries
129+
}
130+
}
131+
132+
return true;
133+
}
134+
135+
/**
136+
* Computes metadata (content hashes, mtime, size) for an array of watch file paths.
137+
* Processes files in parallel chunks of 8 to avoid exhausting file descriptors.
138+
*/
139+
async function computeMetadataForWatchFiles(
140+
watchFiles: string[],
141+
): Promise<Record<string, CachedDependencyMetadata>> {
142+
const watchFilesMetadata: Record<string, CachedDependencyMetadata> = {};
143+
const concurrencyLimit = 8;
144+
145+
for (let i = 0; i < watchFiles.length; i += concurrencyLimit) {
146+
const chunk = watchFiles.slice(i, i + concurrencyLimit);
147+
await Promise.all(
148+
chunk.map(async (filePath) => {
149+
try {
150+
const [content, stats] = await Promise.all([readFile(filePath), stat(filePath)]);
151+
watchFilesMetadata[filePath] = {
152+
hash: hashContent(content),
153+
mtimeMs: stats.mtimeMs,
154+
size: stats.size,
155+
};
156+
} catch {
157+
// Ignore unreadable files
158+
}
159+
}),
160+
);
161+
}
162+
163+
return watchFilesMetadata;
164+
}
165+
166+
export class PersistentLoadResultCache implements LoadResultCache {
167+
private readonly memoryCache = new MemoryLoadResultCache();
168+
169+
constructor(
170+
private readonly persistentStore?: PersistentCacheStore<CachedLoadResultEntry>,
171+
private readonly globalConfigHash: string = '',
172+
) {}
173+
174+
/**
175+
* Retrieves a load result from cache.
176+
* Checks L1 memory cache first for immediate watch-mode speed, falling back to L2 persistent disk
177+
* store on L1 cache miss. L2 persistent cache entries are validated against dependency metadata.
178+
*/
179+
async get(path: string): Promise<OnLoadResult | undefined> {
180+
// 1. Check L1 Memory Cache
181+
const memoryResult = this.memoryCache.get(path);
182+
if (memoryResult) {
183+
return memoryResult;
184+
}
185+
186+
if (!this.persistentStore) {
187+
return undefined;
188+
}
189+
190+
// 2. Check L2 Persistent Disk Cache
191+
let content: string | Uint8Array;
192+
const filePath = path.startsWith('file:') ? path.slice(5) : path;
193+
try {
194+
content = await readFile(filePath);
195+
} catch {
196+
return undefined;
197+
}
198+
199+
const cacheKey = calculateCacheKey(this.globalConfigHash, path, content);
200+
const cached = await this.persistentStore.get(cacheKey);
201+
202+
if (
203+
cached &&
204+
(await validateAndHealCacheEntry(
205+
cached.watchFilesMetadata,
206+
this.persistentStore,
207+
cacheKey,
208+
cached,
209+
))
210+
) {
211+
const result: OnLoadResult = {
212+
contents: cached.contents,
213+
loader: cached.loader,
214+
watchFiles: cached.watchFiles,
215+
warnings: cached.warnings,
216+
errors: cached.errors,
217+
};
218+
219+
// Populate L1 Memory Cache for subsequent lookups
220+
await this.memoryCache.put(path, result);
221+
222+
return result;
223+
}
224+
225+
return undefined;
226+
}
227+
228+
/**
229+
* Stores a load result in both L1 memory cache and L2 persistent disk store.
230+
*/
231+
async put(path: string, result: OnLoadResult): Promise<void> {
232+
await this.memoryCache.put(path, result);
233+
234+
if (this.persistentStore && result.contents !== undefined) {
235+
let content: string | Uint8Array;
236+
const filePath = path.startsWith('file:') ? path.slice(5) : path;
237+
try {
238+
content = await readFile(filePath);
239+
} catch {
240+
content = '';
241+
}
242+
243+
const cacheKey = calculateCacheKey(this.globalConfigHash, path, content);
244+
const watchFilesMetadata = await computeMetadataForWatchFiles(result.watchFiles ?? []);
245+
246+
await this.persistentStore.put(cacheKey, {
247+
contents: result.contents,
248+
loader: result.loader,
249+
watchFiles: result.watchFiles ?? [],
250+
watchFilesMetadata,
251+
warnings: result.warnings,
252+
errors: result.errors,
253+
});
254+
}
255+
}
256+
257+
/**
258+
* Invalidates cached entries affected by a modified dependency file during watch mode.
259+
*
260+
* Note: Invalidation of L1 memory cache is sufficient for active watch mode.
261+
* Cross-process/cold start stale entries in L2 persistent store are automatically handled
262+
* during `get()` via dependency metadata verification (`validateAndHealCacheEntry`).
263+
*/
264+
invalidate(path: string): boolean {
265+
return this.memoryCache.invalidate(path);
266+
}
267+
268+
get watchFiles(): ReadonlyArray<string> {
269+
return this.memoryCache.watchFiles;
270+
}
271+
}

0 commit comments

Comments
 (0)