From 6bca7f71908ec4c37531373da747a1f231afcb93 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Thu, 3 Sep 2026 10:57:13 -0400 Subject: [PATCH] perf(@angular/build): selectively compress large entries in SQLite cache store Transformed JavaScript files from node_modules and other cached assets stored in the persistent SQLite cache store can range from tens to hundreds of kilobytes. Because SQLite rows exceeding the page size spill into overflow b-tree pages, storing large uncompressed entries increases disk I/O and cache file bloat. Serialized entries and binary payloads of 32 KB or greater are now selectively compressed asynchronously using raw DEFLATE at level 1. Performing compression asynchronously offloads CPU-intensive compression work to worker threads, preventing blocking of the main JavaScript event loop during builds. Smaller payloads and incompressible entries remain uncompressed. A dedicated `format` column stores the encoding format (`CacheFormat`), distinguishing between V8 serialized and raw binary (`Uint8Array`) data in both uncompressed and compressed states. This allows raw binary payloads (such as transformed JavaScript files) to be stored and retrieved with zero serialization overhead and zero memory copies. For backward compatibility with existing databases on disk, the database schema migration adds the `format` column with a default value of 0 (`V8`), ensuring pre-existing cache entries continue to deserialize seamlessly. --- .../src/tools/esbuild/sqlite-cache-store.ts | 75 ++++++- .../tools/esbuild/sqlite-cache-store_spec.ts | 188 ++++++++++++++++++ 2 files changed, 257 insertions(+), 6 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 981bafb9db74..abf1620be8d9 100644 --- a/packages/angular/build/src/tools/esbuild/sqlite-cache-store.ts +++ b/packages/angular/build/src/tools/esbuild/sqlite-cache-store.ts @@ -9,9 +9,31 @@ import { mkdirSync, rmSync } from 'node:fs'; import { dirname } from 'node:path'; import { DatabaseSync, StatementSync } from 'node:sqlite'; +import { promisify } from 'node:util'; import { deserialize, serialize } from 'node:v8'; +import { deflateRaw, inflateRawSync } from 'node:zlib'; import { Cache, PersistentCacheStore } from './cache'; +const deflateRawAsync = promisify(deflateRaw); + +/** + * Minimum payload size (in bytes) required to attempt compression. + * Smaller payloads generally do not achieve meaningful compression ratios, while larger payloads + * (such as transformed JavaScript modules from node_modules) achieve 70-80% size reduction + * and drastically reduce SQLite overflow pages. + */ +const COMPRESSION_THRESHOLD = 32 * 1024; // 32 KB + +/** + * Storage format discriminator values for cache table entries. + */ +const enum CacheFormat { + V8 = 0, + V8Compressed = 1, + RawBinary = 2, + RawBinaryCompressed = 3, +} + /** * Common SQLite primary result codes. * @see https://www.sqlite.org/rescode.html @@ -88,16 +110,26 @@ export class SqliteCacheStore implements PersistentCacheStore { db.exec('PRAGMA temp_store = MEMORY;'); db.exec('PRAGMA mmap_size = 268435456;'); db.exec( - 'CREATE TABLE IF NOT EXISTS cache (key TEXT PRIMARY KEY, value BLOB, last_accessed INTEGER NOT NULL) WITHOUT ROWID;', + 'CREATE TABLE IF NOT EXISTS cache (' + + 'key TEXT PRIMARY KEY, ' + + 'value BLOB, ' + + 'format INTEGER NOT NULL DEFAULT 0, ' + + 'last_accessed INTEGER NOT NULL' + + ') WITHOUT ROWID;', ); + try { + db.exec('ALTER TABLE cache ADD COLUMN format INTEGER NOT NULL DEFAULT 0;'); + } catch { + // Ignore error if format column already exists + } db.exec( 'CREATE INDEX IF NOT EXISTS idx_cache_accessed ON cache (last_accessed DESC, key DESC);', ); - this.#getStmt = db.prepare('SELECT value FROM cache WHERE key = ?'); + this.#getStmt = db.prepare('SELECT value, format FROM cache WHERE key = ?'); this.#hasStmt = db.prepare('SELECT 1 FROM cache WHERE key = ?'); this.#setStmt = db.prepare( - 'INSERT OR REPLACE INTO cache (key, value, last_accessed) VALUES (?, ?, unixepoch())', + 'INSERT OR REPLACE INTO cache (key, value, format, last_accessed) VALUES (?, ?, ?, unixepoch())', ); this.#updateAccessedStmt = db.prepare( 'UPDATE cache SET last_accessed = unixepoch() WHERE key = ?', @@ -206,14 +238,31 @@ export class SqliteCacheStore implements PersistentCacheStore { try { // SQLite column types are dynamic, so the stored value is only known at runtime. - const row = this.#getStmt?.get(key) as { value: unknown } | undefined; + const row = this.#getStmt?.get(key) as { value: unknown; format: CacheFormat } | undefined; if (row) { this.#queueAccessUpdate(key); if (row.value instanceof Uint8Array) { try { - return deserialize(row.value); + switch (row.format) { + case CacheFormat.RawBinary: + return row.value; + case CacheFormat.RawBinaryCompressed: { + const decompressed = inflateRawSync(row.value); + + return new Uint8Array( + decompressed.buffer, + decompressed.byteOffset, + decompressed.byteLength, + ); + } + case CacheFormat.V8Compressed: + return deserialize(inflateRawSync(row.value)); + case CacheFormat.V8: + default: + return deserialize(row.value); + } } catch { // Treat corrupt or unparseable cached payloads as a cache miss. } @@ -245,7 +294,21 @@ export class SqliteCacheStore implements PersistentCacheStore { try { this.#pendingAccessedKeys.delete(key); - this.#setStmt?.run(key, serialize(value)); + + const isBinary = value instanceof Uint8Array; + const data = isBinary ? value : serialize(value); + let format = isBinary ? CacheFormat.RawBinary : CacheFormat.V8; + let payload: Uint8Array = data; + + if (data.byteLength >= COMPRESSION_THRESHOLD) { + const compressed = await deflateRawAsync(data, { level: 1 }); + if (compressed.byteLength < data.byteLength) { + payload = compressed; + format = isBinary ? CacheFormat.RawBinaryCompressed : CacheFormat.V8Compressed; + } + } + + this.#setStmt?.run(key, payload, format); } catch { // Writing to cache is non-fatal and should not fail the build. } 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 f5b9ccd1960d..4eac21f3b34b 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 @@ -224,6 +224,194 @@ describe('SqliteCacheStore', () => { expect(row.last_accessed).toBeGreaterThan(pastTimestamp); }); + it('should selectively compress entries that exceed the 32KB threshold', async () => { + const largeContent = + 'export const testFunction = () => { console.log("hello world"); };\n'.repeat(1000); + await store.set('large-key', largeContent); + store.close(); + + const { DatabaseSync } = await import('node:sqlite'); + const directDb = new DatabaseSync(cachePath); + const row = directDb + .prepare('SELECT value, format FROM cache WHERE key = ?') + .get('large-key') as { + value: Uint8Array; + format: number; + }; + directDb.close(); + + // Verify format flag is 1 (CacheFormat.V8Compressed) and compressed size is drastically smaller + expect(row.format).toBe(1); + expect(row.value.byteLength).toBeLessThan(largeContent.length / 5); + + const reopenedStore = new SqliteCacheStore(cachePath); + try { + const result = await reopenedStore.get('large-key'); + expect(result).toBe(largeContent); + } finally { + reopenedStore.close(); + } + }); + + it('should not compress entries below the 32KB threshold', async () => { + const smallContent = 'export const small = true;'; + await store.set('small-key', smallContent); + store.close(); + + const { DatabaseSync } = await import('node:sqlite'); + const directDb = new DatabaseSync(cachePath); + const row = directDb + .prepare('SELECT value, format FROM cache WHERE key = ?') + .get('small-key') as { + value: Uint8Array; + format: number; + }; + directDb.close(); + + // Verify format flag is 0 (CacheFormat.V8) + expect(row.format).toBe(0); + + const reopenedStore = new SqliteCacheStore(cachePath); + try { + const result = await reopenedStore.get('small-key'); + expect(result).toBe(smallContent); + } finally { + reopenedStore.close(); + } + }); + + it('should not compress entries exceeding the 32KB threshold if compression does not reduce size', async () => { + const { randomBytes } = await import('node:crypto'); + // High-entropy random bytes cannot be compressed by DEFLATE + const incompressibleData = new Uint8Array(randomBytes(48 * 1024)); + await store.set('incompressible-key', incompressibleData); + store.close(); + + const { DatabaseSync } = await import('node:sqlite'); + const directDb = new DatabaseSync(cachePath); + const row = directDb + .prepare('SELECT value, format FROM cache WHERE key = ?') + .get('incompressible-key') as { + value: Uint8Array; + format: number; + }; + directDb.close(); + + // Verify entry is stored uncompressed as raw binary (format 2 = CacheFormat.RawBinary) + expect(row.format).toBe(2); + + const reopenedStore = new SqliteCacheStore(cachePath); + try { + const result = await reopenedStore.get('incompressible-key'); + expect(result).toEqual(incompressibleData); + } finally { + reopenedStore.close(); + } + }); + + it('should store raw Uint8Array directly with zero serialization', async () => { + const binaryData = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]); + await store.set('binary-key', binaryData); + store.close(); + + const { DatabaseSync } = await import('node:sqlite'); + const directDb = new DatabaseSync(cachePath); + const row = directDb + .prepare('SELECT value, format FROM cache WHERE key = ?') + .get('binary-key') as { + value: Uint8Array; + format: number; + }; + directDb.close(); + + // Verify format is 2 (CacheFormat.RawBinary) and value is the exact raw bytes + expect(row.format).toBe(2); + expect(row.value).toEqual(binaryData); + + const reopenedStore = new SqliteCacheStore(cachePath); + try { + const result = await reopenedStore.get('binary-key'); + expect(result).toEqual(binaryData); + } finally { + reopenedStore.close(); + } + }); + + it('should selectively compress large Uint8Array entries', async () => { + const largeBinary = new Uint8Array(Buffer.from('export const x = 1;\n'.repeat(2000))); + await store.set('large-binary-key', largeBinary); + store.close(); + + const { DatabaseSync } = await import('node:sqlite'); + const directDb = new DatabaseSync(cachePath); + const row = directDb + .prepare('SELECT value, format FROM cache WHERE key = ?') + .get('large-binary-key') as { + value: Uint8Array; + format: number; + }; + directDb.close(); + + // Verify format is 3 (CacheFormat.RawBinaryCompressed) + expect(row.format).toBe(3); + expect(row.value.byteLength).toBeLessThan(largeBinary.byteLength / 5); + + const reopenedStore = new SqliteCacheStore(cachePath); + try { + const result = await reopenedStore.get('large-binary-key'); + expect(result).toEqual(largeBinary); + } finally { + reopenedStore.close(); + } + }); + + it('should seamlessly read legacy entries from databases created without a format column', async () => { + const { serialize } = await import('node:v8'); + const { DatabaseSync } = await import('node:sqlite'); + + // Create a legacy database without a format column + const directDb = new DatabaseSync(cachePath); + directDb.exec( + 'CREATE TABLE cache (key TEXT PRIMARY KEY, value BLOB, last_accessed INTEGER NOT NULL) WITHOUT ROWID;', + ); + const legacyPayload = serialize('legacy-content'); + directDb + .prepare('INSERT INTO cache (key, value, last_accessed) VALUES (?, ?, unixepoch())') + .run('legacy-key', legacyPayload); + directDb.close(); + + const reopenedStore = new SqliteCacheStore(cachePath); + try { + const result = await reopenedStore.get('legacy-key'); + expect(result).toBe('legacy-content'); + } finally { + reopenedStore.close(); + } + }); + + it('should treat a corrupted compressed payload as a cache miss', async () => { + // Initialize database + await store.set('init-key', 'init-val'); + store.close(); + + // Insert an entry with format = 1 (V8Compressed) followed by garbage + const { DatabaseSync } = await import('node:sqlite'); + const directDb = new DatabaseSync(cachePath); + directDb + .prepare( + 'INSERT INTO cache (key, value, format, last_accessed) VALUES (?, ?, 1, unixepoch())', + ) + .run('corrupt-compressed-key', new Uint8Array([0xde, 0xad, 0xbe, 0xef])); + directDb.close(); + + const reopenedStore = new SqliteCacheStore(cachePath); + try { + expect(await reopenedStore.get('corrupt-compressed-key')).toBeUndefined(); + } finally { + reopenedStore.close(); + } + }); + it('should treat a non-binary payload as a cache miss', async () => { await store.set('text-key', 'value'); store.close();