Skip to content

Commit 6b0bc31

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 6b0bc31

5 files changed

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

0 commit comments

Comments
 (0)