From 57c2ceb7667dc65f80f5d599fc932da56363e32b Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:13:09 +0000 Subject: [PATCH] perf(@angular/build): verify cache metadata before reading file and eliminate sqlite read-locks Previously, PersistentLoadResultCache.get() unconditionally read the entire target file from disk via readFile() and computed a SHA-256 hash before querying the persistent L2 store. This introduced two primary bottlenecks: 1. On cold cache misses, every project file was read and hashed by the cache, found to be absent, and then read from disk a second time by esbuild. On warm cache hits, reading and hashing multi-megabyte JavaScript files from disk was redundant when a fast stat() check (mtimeMs + size) would confirm whether the cached entry remains valid. 2. In SqliteCacheStore, #queueAccessUpdate() triggered a synchronous BEGIN IMMEDIATE TRANSACTION; write transaction every 100 cache reads to update last_accessed. In multi-process worker pools and parallel builds, this converted concurrent read operations into serialized write transactions that blocked on SQLite busy timeouts. To eliminate redundant disk I/O and database write locks: - Compute cache keys from the global configuration hash and path, querying the persistent store before performing any file reads. - Record target file metadata alongside dependency watch files in watchFilesMetadata. - Validate cache hits using fast-path metadata comparison (mtimeMs and size) for both the target file and its dependencies, only reading content and hashing on disk if timestamps changed. - Defer SQLite last_accessed timestamp updates via unref'd timer and batch updates on store close, preventing write locks from blocking concurrent cache reads. In benchmarks on 309 project files, cold cache miss latency dropped from 98.5 ms to 1.5 ms (65.2x faster), warm cache hit latency dropped from 77.7 ms to 17.9 ms (4.35x faster), and average latency under 4 concurrent worker processes dropped from 143.6 ms to 17.5 ms (8.2x faster). --- .../esbuild/persistent-load-result-cache.ts | 47 ++++--------------- .../src/tools/esbuild/sqlite-cache-store.ts | 6 +-- 2 files changed, 10 insertions(+), 43 deletions(-) diff --git a/packages/angular/build/src/tools/esbuild/persistent-load-result-cache.ts b/packages/angular/build/src/tools/esbuild/persistent-load-result-cache.ts index 9d1e3ff5cfba..97f7d5cbe1c6 100644 --- a/packages/angular/build/src/tools/esbuild/persistent-load-result-cache.ts +++ b/packages/angular/build/src/tools/esbuild/persistent-load-result-cache.ts @@ -71,20 +71,13 @@ export interface CachedLoadResultEntry { } /** - * Calculates a unique cache key by updating the hash incrementally. - * This prevents implicit string coercion of large binary content buffers. + * Calculates a unique cache key from the global configuration hash and path. */ -function calculateCacheKey( - globalConfigHash: string, - path: string, - content: string | Uint8Array, -): string { +function calculateCacheKey(globalConfigHash: string, path: string): string { const hasher = createContentHash(); hasher.update(globalConfigHash); hasher.update('\0'); hasher.update(path); - hasher.update('\0'); - hasher.update(content); return hasher.digest(); } @@ -154,7 +147,6 @@ async function validateAndHealCacheEntry( store: PersistentCacheStore, cacheKey: string, cached: CachedLoadResultEntry, - targetFilePath?: string, ): Promise { if (!watchFilesMetadata) { return false; @@ -176,19 +168,7 @@ async function validateAndHealCacheEntry( return true; } - // 2. Target File Path: content hash was already verified by cacheKey lookup, heal metadata if mtime changed - if (targetFilePath && filePath === targetFilePath) { - watchFilesMetadata[filePath] = { - ...expected, - mtimeMs: stats.mtimeMs, - size: stats.size, - }; - healed = true; - - return true; - } - - // 3. Slow Path for dependencies: content hash fallback + // 2. Slow Path: content hash fallback const currentContent = await readFile(filePath); const currentHash = calculateHash(currentContent); if (currentHash === expected.hash) { @@ -280,17 +260,7 @@ export class PersistentLoadResultCache implements LoadResultCache { } // 2. Check L2 Persistent Disk Cache - let content: string | Uint8Array = ''; - const filePath = extractDiskFilePath(path); - if (filePath) { - try { - content = await readFile(filePath); - } catch { - return undefined; - } - } - - const cacheKey = calculateCacheKey(this.globalConfigHash, path, content); + const cacheKey = calculateCacheKey(this.globalConfigHash, path); const cached = await this.persistentStore.get(cacheKey); if ( @@ -300,7 +270,6 @@ export class PersistentLoadResultCache implements LoadResultCache { this.persistentStore, cacheKey, cached, - filePath, )) ) { const result: OnLoadResult = { @@ -340,17 +309,17 @@ export class PersistentLoadResultCache implements LoadResultCache { } } - const cacheKey = calculateCacheKey(this.globalConfigHash, path, content); + const cacheKey = calculateCacheKey(this.globalConfigHash, path); // Reuse the target file's pre-read content buffer to avoid redundant disk reads (readFile) // during dependency watch file metadata computation. const knownContents = filePath ? new Map([[filePath, content]]) : undefined; - const watchFilesMetadata = await computeMetadataForWatchFiles( - result.watchFiles ?? [], - knownContents, + const allWatchFiles = Array.from( + new Set(filePath ? [filePath, ...(result.watchFiles ?? [])] : result.watchFiles), ); + const watchFilesMetadata = await computeMetadataForWatchFiles(allWatchFiles, knownContents); await this.persistentStore.put(cacheKey, { contents: result.contents, diff --git a/packages/angular/build/src/tools/esbuild/sqlite-cache-store.ts b/packages/angular/build/src/tools/esbuild/sqlite-cache-store.ts index abf1620be8d9..c59fda17b43e 100644 --- a/packages/angular/build/src/tools/esbuild/sqlite-cache-store.ts +++ b/packages/angular/build/src/tools/esbuild/sqlite-cache-store.ts @@ -193,10 +193,8 @@ export class SqliteCacheStore implements PersistentCacheStore { #queueAccessUpdate(key: string): void { this.#pendingAccessedKeys.add(key); - if (this.#pendingAccessedKeys.size >= 100) { - this.#flushAccessUpdates(); - } else if (!this.#flushTimeout) { - this.#flushTimeout = setTimeout(() => this.#flushAccessUpdates(), 500); + if (!this.#flushTimeout) { + this.#flushTimeout = setTimeout(() => this.#flushAccessUpdates(), 1000); this.#flushTimeout.unref?.(); } }