From 29b9bff268344801e2ccae8342fc3ecd312a0a68 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:06:03 -0400 Subject: [PATCH 1/2] refactor(@angular/build): add composite index on last_accessed in sqlite cache store Previously, the SQLite cache table had no secondary index on `last_accessed`. Cache eviction routines during `close()` required full table scans for both TTL-based pruning and LRU size-based pruning. Specifically, calculating running sizes using a window function ordered by `last_accessed DESC, key DESC` required SQLite to materialize and sort the entire table in temporary memory on every cache close. In large persistent caches, these full scans and temporary sorts introduced unnecessary CPU and I/O overhead on shutdown. A composite index `idx_cache_accessed` on `(last_accessed DESC, key DESC)` is now created on the cache table. This enables SQLite to directly seek entries older than the TTL limit using an indexed binary search and stream rows in descending access order for the window aggregate calculation without requiring an in-memory sort pass. --- .../src/tools/esbuild/sqlite-cache-store.ts | 3 +++ .../tools/esbuild/sqlite-cache-store_spec.ts | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+) 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 cbd51d345ddd..e07eac515a3a 100644 --- a/packages/angular/build/src/tools/esbuild/sqlite-cache-store.ts +++ b/packages/angular/build/src/tools/esbuild/sqlite-cache-store.ts @@ -46,6 +46,9 @@ export class SqliteCacheStore implements PersistentCacheStore { this.#db.exec( 'CREATE TABLE IF NOT EXISTS cache (key TEXT PRIMARY KEY, value BLOB, last_accessed INTEGER NOT NULL) WITHOUT ROWID;', ); + this.#db.exec( + 'CREATE INDEX IF NOT EXISTS idx_cache_accessed ON cache (last_accessed DESC, key DESC);', + ); this.#getStmt = this.#db.prepare('SELECT value FROM cache WHERE key = ?'); this.#hasStmt = this.#db.prepare('SELECT 1 FROM cache WHERE key = ?'); diff --git a/packages/angular/build/src/tools/esbuild/sqlite-cache-store_spec.ts b/packages/angular/build/src/tools/esbuild/sqlite-cache-store_spec.ts index 75ab1a358573..9ac9bee23ddb 100644 --- a/packages/angular/build/src/tools/esbuild/sqlite-cache-store_spec.ts +++ b/packages/angular/build/src/tools/esbuild/sqlite-cache-store_spec.ts @@ -187,6 +187,25 @@ describe('SqliteCacheStore', () => { checkStore.close(); }); + it('should create an index on last_accessed and key', async () => { + // Trigger db initialization + await store.set('test-key', 'test-value'); + store.close(); + + const { DatabaseSync } = await import('node:sqlite'); + const directDb = new DatabaseSync(cachePath); + try { + const indexRows = directDb + .prepare( + "SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'cache' AND name = 'idx_cache_accessed'", + ) + .all(); + expect(indexRows.length).toBe(1); + } finally { + directDb.close(); + } + }); + describe('NG_BUILD_CACHE_STORE env variable option', () => { it('should force SQLite when NG_BUILD_CACHE_STORE=sqlite', () => { const code = ` From eb75e903d5ae6d01bade1663ed37b6921e66de93 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:38:54 -0400 Subject: [PATCH 2/2] refactor(@angular/build): optimize pruning in sqlite cache store Previously, cache eviction routines during close executed as independent autocommit transactions. In addition, the LRU size pruning query calculated running payload sizes with a window function across the entire cache table on every build, even when total database size was well below maxPayloadSize. Pruning operations in close are now wrapped in an immediate transaction to ensure atomicity and reduce transaction commit overhead. Furthermore, total database size is checked using SQLite page pragmas prior to size-based pruning, allowing the expensive window aggregate query to be skipped when the total cache size is already within limits. --- .../src/tools/esbuild/sqlite-cache-store.ts | 61 +++++++++++++------ .../tools/esbuild/sqlite-cache-store_spec.ts | 14 +++++ 2 files changed, 58 insertions(+), 17 deletions(-) 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 e07eac515a3a..a46251ce4d31 100644 --- a/packages/angular/build/src/tools/esbuild/sqlite-cache-store.ts +++ b/packages/angular/build/src/tools/esbuild/sqlite-cache-store.ts @@ -146,22 +146,45 @@ export class SqliteCacheStore implements PersistentCacheStore { // Flush any pending access updates in one transaction before pruning this.#flushAccessUpdates(); - // 1. Delete items older than N days - this.#db - .prepare("DELETE FROM cache WHERE last_accessed < unixepoch('now', ?);") - .run(`-${this.ttlDays} days`); - - // 2. Prune oldest items if payload exceeds maxPayloadSize - const pruneStmt = this.#db.prepare(` - DELETE FROM cache WHERE key IN ( - SELECT key FROM ( - SELECT key, - sum(length(key) + length(value)) OVER (ORDER BY last_accessed DESC, key DESC) as running_size - FROM cache - ) WHERE running_size > ? - ); - `); - pruneStmt.run(this.maxPayloadSize); + this.#db.exec('BEGIN IMMEDIATE TRANSACTION;'); + try { + // 1. Delete items older than N days + this.#db + .prepare("DELETE FROM cache WHERE last_accessed < unixepoch('now', ?);") + .run(`-${this.ttlDays} days`); + + // 2. Prune oldest items if payload exceeds maxPayloadSize + // Skip the expensive window aggregate query if total database size is below maxPayloadSize + const sizeResult = this.#db + .prepare( + 'SELECT (page_count - freelist_count) * page_size AS total_size ' + + 'FROM pragma_page_count(), pragma_freelist_count(), pragma_page_size();', + ) + .get() as { total_size?: number } | undefined; + + if ((sizeResult?.total_size ?? 0) > this.maxPayloadSize) { + this.#db + .prepare( + `DELETE FROM cache WHERE key IN ( + SELECT key FROM ( + SELECT key, + sum(length(key) + length(value)) OVER (ORDER BY last_accessed DESC, key DESC) as running_size + FROM cache + ) WHERE running_size > ? + );`, + ) + .run(this.maxPayloadSize); + } + + this.#db.exec('COMMIT;'); + } catch (error) { + try { + this.#db.exec('ROLLBACK;'); + } catch { + // Ignore rollback errors if transaction was not active + } + throw error; + } } catch { // Pruning errors should not block build success } finally { @@ -176,7 +199,11 @@ export class SqliteCacheStore implements PersistentCacheStore { this.#setStmt = undefined; this.#updateAccessedStmt = undefined; - this.#db.close(); + try { + this.#db.close(); + } catch { + // Failure to close should not block build success + } this.#db = undefined; } } diff --git a/packages/angular/build/src/tools/esbuild/sqlite-cache-store_spec.ts b/packages/angular/build/src/tools/esbuild/sqlite-cache-store_spec.ts index 9ac9bee23ddb..c76394cb7ee3 100644 --- a/packages/angular/build/src/tools/esbuild/sqlite-cache-store_spec.ts +++ b/packages/angular/build/src/tools/esbuild/sqlite-cache-store_spec.ts @@ -187,6 +187,20 @@ describe('SqliteCacheStore', () => { checkStore.close(); }); + it('should not prune items when total database size is within maxPayloadSize on close', async () => { + store.close(); + + const sizeStore = new SqliteCacheStore(cachePath, 1024 * 1024); + await sizeStore.set('k1', 'value1'); + await sizeStore.set('k2', 'value2'); + sizeStore.close(); + + const checkStore = new SqliteCacheStore(cachePath); + expect(checkStore.has('k1')).toBeTrue(); + expect(checkStore.has('k2')).toBeTrue(); + checkStore.close(); + }); + it('should create an index on last_accessed and key', async () => { // Trigger db initialization await store.set('test-key', 'test-value');