From b370ccfd7872c51ab8943426103088cc295cbfee Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:12:43 +0000 Subject: [PATCH] perf(@angular/build): batch last_accessed updates in sqlite cache store Previously, every `get()` call on `SqliteCacheStore` immediately executed an `UPDATE` statement to refresh the `last_accessed` timestamp for the requested cache key. In SQLite WAL mode, each unbatched `UPDATE` starts an implicit write transaction, acquiring exclusive write locks on the WAL and causing repeated disk I/O and fsync operations during cache reads. During parallel builds, this serialized concurrent read operations and introduced unnecessary overhead on hot read paths. To resolve this: - `last_accessed` updates are buffered in an in-memory `Set` and flushed inside a single explicit transaction (`BEGIN TRANSACTION; ... COMMIT;`), periodically debounced (every 500ms or when batch reaches 100 entries) and before pruning on `close()`. - Flushes on `close()` ensure that recently accessed items have their timestamps persisted prior to TTL and LRU size pruning. - SQLite PRAGMAs (`busy_timeout = 5000`, `temp_store = MEMORY`, `mmap_size = 268435456`) are tuned to reduce lock contention and leverage memory-mapped I/O. - The debounced timer uses `unref()` to ensure it does not hold the Node.js process event loop open. --- .../src/tools/esbuild/sqlite-cache-store.ts | 55 ++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) 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 3d594668d44f..4f50515749df 100644 --- a/packages/angular/build/src/tools/esbuild/sqlite-cache-store.ts +++ b/packages/angular/build/src/tools/esbuild/sqlite-cache-store.ts @@ -15,6 +15,8 @@ export class SqliteCacheStore implements PersistentCacheStore { #hasStmt: StatementSync | undefined; #setStmt: StatementSync | undefined; #updateAccessedStmt: StatementSync | undefined; + readonly #pendingAccessedKeys = new Set(); + #flushTimeout: NodeJS.Timeout | undefined; constructor( readonly cachePath: string, @@ -29,6 +31,9 @@ export class SqliteCacheStore implements PersistentCacheStore { this.#db.exec('PRAGMA auto_vacuum = FULL;'); this.#db.exec('PRAGMA journal_mode = WAL;'); this.#db.exec('PRAGMA synchronous = NORMAL;'); + this.#db.exec('PRAGMA busy_timeout = 5000;'); + this.#db.exec('PRAGMA temp_store = MEMORY;'); + this.#db.exec('PRAGMA mmap_size = 268435456;'); this.#db.exec( 'CREATE TABLE IF NOT EXISTS cache (key TEXT PRIMARY KEY, value TEXT, last_accessed INTEGER NOT NULL) WITHOUT ROWID;', ); @@ -46,13 +51,51 @@ export class SqliteCacheStore implements PersistentCacheStore { return this.#db; } + #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); + this.#flushTimeout.unref?.(); + } + } + + #flushAccessUpdates(): void { + if (this.#flushTimeout) { + clearTimeout(this.#flushTimeout); + this.#flushTimeout = undefined; + } + + if (!this.#db || this.#pendingAccessedKeys.size === 0 || !this.#updateAccessedStmt) { + return; + } + + try { + this.#db.exec('BEGIN IMMEDIATE TRANSACTION;'); + for (const key of this.#pendingAccessedKeys) { + this.#updateAccessedStmt.run(key); + } + this.#db.exec('COMMIT;'); + } catch { + try { + this.#db.exec('ROLLBACK;'); + } catch { + // Ignore rollback errors if transaction was not active + } + } finally { + this.#pendingAccessedKeys.clear(); + } + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any async get(key: string): Promise { this.#ensureDb(); const row = this.#getStmt?.get(key) as { value: string } | undefined; if (row) { - this.#updateAccessedStmt?.run(key); + this.#queueAccessUpdate(key); try { return JSON.parse(row.value); @@ -72,6 +115,7 @@ export class SqliteCacheStore implements PersistentCacheStore { async set(key: string, value: unknown): Promise { this.#ensureDb(); + this.#pendingAccessedKeys.delete(key); this.#setStmt?.run(key, JSON.stringify(value)); return this; @@ -84,6 +128,9 @@ export class SqliteCacheStore implements PersistentCacheStore { close(): void { if (this.#db) { try { + // 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', ?);") @@ -103,6 +150,12 @@ export class SqliteCacheStore implements PersistentCacheStore { } catch { // Pruning errors should not block build success } finally { + if (this.#flushTimeout) { + clearTimeout(this.#flushTimeout); + this.#flushTimeout = undefined; + } + this.#pendingAccessedKeys.clear(); + this.#getStmt = undefined; this.#hasStmt = undefined; this.#setStmt = undefined;