Skip to content

Commit edabae0

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 9671cc6 commit edabae0

5 files changed

Lines changed: 430 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: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
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 { existsSync } from 'node:fs';
12+
import { readFile } from 'node:fs/promises';
13+
import type { Cache as PersistentCacheStore } from './cache';
14+
import { LoadResultCache, MemoryLoadResultCache } from './load-result-cache';
15+
16+
/**
17+
* Serialized representation of any esbuild load result stored in persistent cache.
18+
*/
19+
export interface CachedLoadResultEntry {
20+
/** Compiled output string or binary data */
21+
contents: string | Uint8Array;
22+
23+
/** esbuild loader type */
24+
loader?: Loader;
25+
26+
/** Absolute paths of all imported/watched dependency files */
27+
watchFiles: string[];
28+
29+
/** Map of watchFile absolute paths to content hashes */
30+
watchFilesHashes: Record<string, string>;
31+
32+
/** Warnings emitted during load processing */
33+
warnings?: PartialMessage[];
34+
35+
/** Errors emitted during load processing */
36+
errors?: PartialMessage[];
37+
}
38+
39+
function hashContent(content: string | Uint8Array): string {
40+
return createHash('sha256').update(content).digest('hex');
41+
}
42+
43+
/**
44+
* Calculates a unique cache key by updating the hash incrementally.
45+
* This prevents implicit string coercion of large binary content buffers.
46+
*/
47+
function calculateCacheKey(
48+
globalConfigHash: string,
49+
path: string,
50+
content: string | Uint8Array,
51+
): string {
52+
return createHash('sha256').update(globalConfigHash).update(path).update(content).digest('hex');
53+
}
54+
55+
/**
56+
* Validates that all imported watch files exist on disk and their content hashes match.
57+
* Processes files in parallel chunks of 8 to avoid exhausting file descriptors.
58+
*/
59+
async function isCacheEntryValid(watchFilesHashes: Record<string, string>): Promise<boolean> {
60+
const watchFiles = Object.keys(watchFilesHashes);
61+
const concurrencyLimit = 8;
62+
63+
for (let i = 0; i < watchFiles.length; i += concurrencyLimit) {
64+
const chunk = watchFiles.slice(i, i + concurrencyLimit);
65+
const results = await Promise.all(
66+
chunk.map(async (filePath) => {
67+
if (!existsSync(filePath)) {
68+
return false;
69+
}
70+
try {
71+
const currentContent = await readFile(filePath);
72+
73+
return hashContent(currentContent) === watchFilesHashes[filePath];
74+
} catch {
75+
return false;
76+
}
77+
}),
78+
);
79+
80+
if (results.some((isValid) => !isValid)) {
81+
return false;
82+
}
83+
}
84+
85+
return true;
86+
}
87+
88+
/**
89+
* Computes hashes for an array of watch file paths.
90+
* Processes files in parallel chunks of 8 to avoid exhausting file descriptors.
91+
*/
92+
async function computeHashesForWatchFiles(watchFiles: string[]): Promise<Record<string, string>> {
93+
const watchFilesHashes: Record<string, string> = {};
94+
const concurrencyLimit = 8;
95+
96+
for (let i = 0; i < watchFiles.length; i += concurrencyLimit) {
97+
const chunk = watchFiles.slice(i, i + concurrencyLimit);
98+
await Promise.all(
99+
chunk.map(async (filePath) => {
100+
if (existsSync(filePath)) {
101+
try {
102+
const content = await readFile(filePath);
103+
watchFilesHashes[filePath] = hashContent(content);
104+
} catch {
105+
// Ignore unreadable files
106+
}
107+
}
108+
}),
109+
);
110+
}
111+
112+
return watchFilesHashes;
113+
}
114+
115+
export class PersistentLoadResultCache implements LoadResultCache {
116+
private readonly memoryCache = new MemoryLoadResultCache();
117+
118+
constructor(
119+
private readonly persistentStore?: PersistentCacheStore<CachedLoadResultEntry>,
120+
private readonly globalConfigHash: string = '',
121+
) {}
122+
123+
/**
124+
* Retrieves a load result from cache.
125+
* Checks L1 memory cache first for immediate watch-mode speed, falling back to L2 persistent disk
126+
* store on L1 cache miss. L2 persistent cache entries are validated against dependency content hashes.
127+
*/
128+
async get(path: string): Promise<OnLoadResult | undefined> {
129+
// 1. Check L1 Memory Cache
130+
const memoryResult = this.memoryCache.get(path);
131+
if (memoryResult) {
132+
return memoryResult;
133+
}
134+
135+
if (!this.persistentStore) {
136+
return undefined;
137+
}
138+
139+
// 2. Check L2 Persistent Disk Cache
140+
let content: string | Uint8Array = '';
141+
const filePath = path.startsWith('file:') ? path.slice(5) : path;
142+
if (existsSync(filePath)) {
143+
try {
144+
content = await readFile(filePath);
145+
} catch {
146+
return undefined;
147+
}
148+
}
149+
150+
const cacheKey = calculateCacheKey(this.globalConfigHash, path, content);
151+
const cached = await this.persistentStore.get(cacheKey);
152+
153+
if (cached && (await isCacheEntryValid(cached.watchFilesHashes))) {
154+
const result: OnLoadResult = {
155+
contents: cached.contents,
156+
loader: cached.loader,
157+
watchFiles: cached.watchFiles,
158+
warnings: cached.warnings,
159+
errors: cached.errors,
160+
};
161+
162+
// Populate L1 Memory Cache for subsequent lookups
163+
await this.memoryCache.put(path, result);
164+
165+
return result;
166+
}
167+
168+
return undefined;
169+
}
170+
171+
/**
172+
* Stores a load result in both L1 memory cache and L2 persistent disk store.
173+
*/
174+
async put(path: string, result: OnLoadResult): Promise<void> {
175+
await this.memoryCache.put(path, result);
176+
177+
if (this.persistentStore && result.contents) {
178+
let content: string | Uint8Array = '';
179+
const filePath = path.startsWith('file:') ? path.slice(5) : path;
180+
if (existsSync(filePath)) {
181+
try {
182+
content = await readFile(filePath);
183+
} catch {
184+
// ignore
185+
}
186+
}
187+
188+
const cacheKey = calculateCacheKey(this.globalConfigHash, path, content);
189+
const watchFilesHashes = await computeHashesForWatchFiles(result.watchFiles ?? []);
190+
191+
await this.persistentStore.put(cacheKey, {
192+
contents: result.contents,
193+
loader: result.loader,
194+
watchFiles: result.watchFiles ?? [],
195+
watchFilesHashes,
196+
warnings: result.warnings,
197+
errors: result.errors,
198+
});
199+
}
200+
}
201+
202+
/**
203+
* Invalidates cached entries affected by a modified dependency file during watch mode.
204+
*
205+
* Note: Invalidation of L1 memory cache is sufficient for active watch mode.
206+
* Cross-process/cold start stale entries in L2 persistent store are automatically handled
207+
* during `get()` via dependency content hash verification (`isCacheEntryValid`).
208+
*/
209+
invalidate(path: string): boolean {
210+
return this.memoryCache.invalidate(path);
211+
}
212+
213+
get watchFiles(): ReadonlyArray<string> {
214+
return this.memoryCache.watchFiles;
215+
}
216+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
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 { OnLoadResult } from 'esbuild';
10+
import fs from 'node:fs';
11+
import os from 'node:os';
12+
import path from 'node:path';
13+
import type { Cache as PersistentCacheStore } from './cache';
14+
import {
15+
type CachedLoadResultEntry,
16+
PersistentLoadResultCache,
17+
} from './persistent-load-result-cache';
18+
19+
describe('PersistentLoadResultCache', () => {
20+
let mockStore: Map<string, CachedLoadResultEntry>;
21+
let persistentStore: PersistentCacheStore<CachedLoadResultEntry>;
22+
let tmpDir: string;
23+
let file1: string;
24+
25+
beforeEach(() => {
26+
tmpDir = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'persistent-cache-test-'));
27+
file1 = path.join(tmpDir, 'test.js');
28+
fs.writeFileSync(file1, 'console.log("hello");');
29+
30+
mockStore = new Map();
31+
persistentStore = {
32+
async get(key: string) {
33+
return mockStore.get(key);
34+
},
35+
async put(key: string, value: CachedLoadResultEntry) {
36+
mockStore.set(key, value);
37+
},
38+
} as unknown as PersistentCacheStore<CachedLoadResultEntry>;
39+
});
40+
41+
afterEach(() => {
42+
fs.rmSync(tmpDir, { recursive: true, force: true });
43+
});
44+
45+
it('should return undefined on L1 and L2 cache miss', async () => {
46+
const cache = new PersistentLoadResultCache(persistentStore, 'global-hash');
47+
const result = await cache.get(file1);
48+
expect(result).toBeUndefined();
49+
});
50+
51+
it('should hit L2 persistent store and return cached output when dependencies are valid', async () => {
52+
const cache = new PersistentLoadResultCache(persistentStore, 'global-hash');
53+
const sampleResult: OnLoadResult = {
54+
contents: 'console.log("hello");',
55+
loader: 'js',
56+
watchFiles: [file1],
57+
};
58+
59+
await cache.put(file1, sampleResult);
60+
61+
// Create a second cache instance (simulating cold start)
62+
const coldCache = new PersistentLoadResultCache(persistentStore, 'global-hash');
63+
const hit = await coldCache.get(file1);
64+
65+
expect(hit).toBeDefined();
66+
expect(hit?.contents).toBe('console.log("hello");');
67+
expect(hit?.loader).toBe('js');
68+
});
69+
70+
it('should invalidate L2 persistent cache hit if a watch dependency file is modified', async () => {
71+
const cache = new PersistentLoadResultCache(persistentStore, 'global-hash');
72+
const sampleResult: OnLoadResult = {
73+
contents: 'console.log("hello");',
74+
loader: 'js',
75+
watchFiles: [file1],
76+
};
77+
78+
await cache.put(file1, sampleResult);
79+
80+
// Modify dependency file
81+
fs.writeFileSync(file1, 'console.log("world");');
82+
83+
const coldCache = new PersistentLoadResultCache(persistentStore, 'global-hash');
84+
const hit = await coldCache.get(file1);
85+
86+
expect(hit).toBeUndefined();
87+
});
88+
});
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
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 { createHash } from 'node:crypto';
10+
import type { BundleStylesheetOptions } from './bundle-options';
11+
12+
/**
13+
* Generates a global hash based on all build options that affect stylesheet compilation.
14+
*
15+
* IMPORTANT: This hash acts as the root cache key prefix for all persistent stylesheet cache
16+
* entries. Any change in the values hashed below will invalidate all cached stylesheet outputs
17+
* across the entire application.
18+
*
19+
* Included build options and their rationale:
20+
* - `optimization`: Affects CSS minification, dead code elimination, and whitespace stripping.
21+
* - `sourcemap`: Toggles inline/external source map generation and comment insertion.
22+
* - `includePaths`: Changes Sass/Less `@import` and `@use` path resolution search directories.
23+
* - `sassOptions`: Changes Sass compiler deprecations and behavior flags.
24+
* - `target`: Affects CSS property lowering and browser vendor prefixing in esbuild.
25+
* - `publicPath`: Affects relative asset URL rewriting (`url('...')`) inside CSS output.
26+
* - `outputNames`: Affects asset output filename hashing schemes.
27+
* - `inlineFonts`: Controls whether external web font `@import` / `<link>` directives are inlined.
28+
* - `preserveSymlinks`: Controls symlink realpath resolution in monorepos/pnpm workspace packages.
29+
* - `externalDependencies`: Controls which CSS modules/urls are excluded from bundling.
30+
* - `postcssConfig`: Path to custom PostCSS configuration file.
31+
* - `tailwindConfig`: Path to Tailwind CSS configuration file.
32+
* - `packageVersion`: Invalidates cache across `@angular/build` compiler toolchain updates.
33+
*
34+
* @note Maintainers: If any new build option is added to `BundleStylesheetOptions` or `@angular/build`
35+
* that alters generated stylesheet code or asset outputs, it MUST be added to this hash object.
36+
*/
37+
export function calculateGlobalStylesheetConfigHash(
38+
options: BundleStylesheetOptions,
39+
packageVersion: string = '',
40+
): string {
41+
return createHash('sha256')
42+
.update(
43+
JSON.stringify({
44+
optimization: options.optimization,
45+
sourcemap: options.sourcemap,
46+
includePaths: options.includePaths,
47+
sassOptions: options.sass,
48+
target: options.target,
49+
publicPath: options.publicPath,
50+
outputNames: options.outputNames,
51+
inlineFonts: options.inlineFonts,
52+
preserveSymlinks: options.preserveSymlinks,
53+
externalDependencies: options.externalDependencies,
54+
postcssConfig: options.postcssConfiguration?.configPath
55+
? options.postcssConfiguration.configPath
56+
: '',
57+
tailwindConfig: options.tailwindConfiguration?.file
58+
? options.tailwindConfiguration.file
59+
: '',
60+
packageVersion,
61+
}),
62+
)
63+
.digest('hex');
64+
}

0 commit comments

Comments
 (0)