Skip to content

Commit c9efb9b

Browse files
committed
perf(@angular/build): selectively compress large entries in SQLite cache store
Transformed JavaScript files from node_modules stored in the persistent cache often range from tens to hundreds of kilobytes. In SQLite, rows exceeding the 4KB page size spill into overflow pages, resulting in repeated pointer traversals and increased disk I/O. Entries with serialized payloads of 32 KB or greater are now selectively compressed asynchronously using raw DEFLATE at level 1 and marked with a single-byte prefix. Performing compression asynchronously offloads CPU-intensive compression work to background worker threads, avoiding blocking the main JavaScript event loop during parallel file compilation. Smaller payloads and payloads that do not benefit from compression are stored raw with zero copy overhead. Because V8 serialized streams always begin with version byte 0xFF, existing uncompressed cache entries remain fully backward-compatible. This reduces the disk footprint of large modules by 70 to 80 percent and drastically cuts SQLite overflow page allocations while preserving microsecond read performance for small entries.
1 parent fe2f180 commit c9efb9b

2 files changed

Lines changed: 163 additions & 2 deletions

File tree

packages/angular/build/src/tools/esbuild/sqlite-cache-store.ts

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,32 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9+
import { Buffer } from 'node:buffer';
910
import { mkdirSync, rmSync } from 'node:fs';
1011
import { dirname } from 'node:path';
1112
import { DatabaseSync, StatementSync } from 'node:sqlite';
13+
import { promisify } from 'node:util';
1214
import { deserialize, serialize } from 'node:v8';
15+
import { deflateRaw, inflateRawSync } from 'node:zlib';
1316
import { Cache, PersistentCacheStore } from './cache';
1417

18+
const deflateRawAsync = promisify(deflateRaw);
19+
20+
/**
21+
* Minimum serialized payload size (in bytes) required to attempt compression.
22+
* Smaller payloads generally do not achieve meaningful compression ratios, while larger payloads
23+
* (such as transformed JavaScript modules from node_modules) achieve 70-80% size reduction
24+
* and drastically reduce SQLite overflow pages.
25+
*/
26+
const COMPRESSION_THRESHOLD = 32 * 1024; // 32 KB
27+
28+
/**
29+
* Prefix byte used to indicate a compressed payload.
30+
* V8 serialization streams always begin with the version tag byte 0xFF, so 0x01 never collides
31+
* with uncompressed payloads.
32+
*/
33+
const COMPRESSION_FLAG_DEFLATE = 0x01;
34+
1535
/**
1636
* Common SQLite primary result codes.
1737
* @see https://www.sqlite.org/rescode.html
@@ -213,7 +233,11 @@ export class SqliteCacheStore implements PersistentCacheStore<unknown> {
213233

214234
if (row.value instanceof Uint8Array) {
215235
try {
216-
return deserialize(row.value);
236+
const raw = row.value;
237+
const data =
238+
raw[0] === COMPRESSION_FLAG_DEFLATE ? inflateRawSync(raw.subarray(1)) : raw;
239+
240+
return deserialize(data);
217241
} catch {
218242
// Treat corrupt or unparseable cached payloads as a cache miss.
219243
}
@@ -245,7 +269,20 @@ export class SqliteCacheStore implements PersistentCacheStore<unknown> {
245269

246270
try {
247271
this.#pendingAccessedKeys.delete(key);
248-
this.#setStmt?.run(key, serialize(value));
272+
273+
const serialized = serialize(value);
274+
let payload: Uint8Array = serialized;
275+
276+
if (serialized.byteLength >= COMPRESSION_THRESHOLD) {
277+
const compressed = await deflateRawAsync(serialized, { level: 1 });
278+
if (compressed.byteLength + 1 < serialized.byteLength) {
279+
payload = Buffer.allocUnsafe(compressed.byteLength + 1);
280+
payload[0] = COMPRESSION_FLAG_DEFLATE;
281+
payload.set(compressed, 1);
282+
}
283+
}
284+
285+
this.#setStmt?.run(key, payload);
249286
} catch {
250287
// Writing to cache is non-fatal and should not fail the build.
251288
}

packages/angular/build/src/tools/esbuild/sqlite-cache-store_spec.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,130 @@ describe('SqliteCacheStore', () => {
224224
expect(row.last_accessed).toBeGreaterThan(pastTimestamp);
225225
});
226226

227+
it('should selectively compress entries that exceed the 32KB threshold', async () => {
228+
const largeContent =
229+
'export const testFunction = () => { console.log("hello world"); };\n'.repeat(1000);
230+
await store.set('large-key', largeContent);
231+
store.close();
232+
233+
const { DatabaseSync } = await import('node:sqlite');
234+
const directDb = new DatabaseSync(cachePath);
235+
const row = directDb.prepare('SELECT value FROM cache WHERE key = ?').get('large-key') as {
236+
value: Uint8Array;
237+
};
238+
directDb.close();
239+
240+
// Verify format flag is 0x01 (compressed) and compressed size is drastically smaller
241+
expect(row.value[0]).toBe(0x01);
242+
expect(row.value.byteLength).toBeLessThan(largeContent.length / 5);
243+
244+
const reopenedStore = new SqliteCacheStore(cachePath);
245+
try {
246+
const result = await reopenedStore.get('large-key');
247+
expect(result).toBe(largeContent);
248+
} finally {
249+
reopenedStore.close();
250+
}
251+
});
252+
253+
it('should not compress entries below the 32KB threshold', async () => {
254+
const smallContent = 'export const small = true;';
255+
await store.set('small-key', smallContent);
256+
store.close();
257+
258+
const { DatabaseSync } = await import('node:sqlite');
259+
const directDb = new DatabaseSync(cachePath);
260+
const row = directDb.prepare('SELECT value FROM cache WHERE key = ?').get('small-key') as {
261+
value: Uint8Array;
262+
};
263+
directDb.close();
264+
265+
// Verify entry is uncompressed (starts with V8 serialization header 0xFF, not 0x01)
266+
expect(row.value[0]).not.toBe(0x01);
267+
268+
const reopenedStore = new SqliteCacheStore(cachePath);
269+
try {
270+
const result = await reopenedStore.get('small-key');
271+
expect(result).toBe(smallContent);
272+
} finally {
273+
reopenedStore.close();
274+
}
275+
});
276+
277+
it('should not compress entries exceeding the 32KB threshold if compression does not reduce size', async () => {
278+
const { randomBytes } = await import('node:crypto');
279+
// High-entropy random bytes cannot be compressed by DEFLATE
280+
const incompressibleData = randomBytes(48 * 1024);
281+
await store.set('incompressible-key', incompressibleData);
282+
store.close();
283+
284+
const { DatabaseSync } = await import('node:sqlite');
285+
const directDb = new DatabaseSync(cachePath);
286+
const row = directDb
287+
.prepare('SELECT value FROM cache WHERE key = ?')
288+
.get('incompressible-key') as {
289+
value: Uint8Array;
290+
};
291+
directDb.close();
292+
293+
// Verify entry is stored uncompressed (starts with V8 serialization header 0xFF, not 0x01)
294+
expect(row.value[0]).not.toBe(0x01);
295+
296+
const reopenedStore = new SqliteCacheStore(cachePath);
297+
try {
298+
const result = await reopenedStore.get('incompressible-key');
299+
expect(result).toEqual(incompressibleData);
300+
} finally {
301+
reopenedStore.close();
302+
}
303+
});
304+
305+
it('should seamlessly read legacy uncompressed entries without format prefix', async () => {
306+
const { serialize } = await import('node:v8');
307+
const { DatabaseSync } = await import('node:sqlite');
308+
309+
// Initialize database
310+
await store.set('init-key', 'init-val');
311+
store.close();
312+
313+
// Insert a legacy entry directly serialized with v8 without a 1-byte format prefix
314+
const directDb = new DatabaseSync(cachePath);
315+
const legacyPayload = serialize('legacy-content');
316+
directDb
317+
.prepare('INSERT INTO cache (key, value, last_accessed) VALUES (?, ?, unixepoch())')
318+
.run('legacy-key', legacyPayload);
319+
directDb.close();
320+
321+
const reopenedStore = new SqliteCacheStore(cachePath);
322+
try {
323+
const result = await reopenedStore.get('legacy-key');
324+
expect(result).toBe('legacy-content');
325+
} finally {
326+
reopenedStore.close();
327+
}
328+
});
329+
330+
it('should treat a corrupted compressed payload as a cache miss', async () => {
331+
// Initialize database
332+
await store.set('init-key', 'init-val');
333+
store.close();
334+
335+
// Insert an entry with 0x01 compression flag followed by garbage
336+
const { DatabaseSync } = await import('node:sqlite');
337+
const directDb = new DatabaseSync(cachePath);
338+
directDb
339+
.prepare('INSERT INTO cache (key, value, last_accessed) VALUES (?, ?, unixepoch())')
340+
.run('corrupt-compressed-key', new Uint8Array([0x01, 0xde, 0xad, 0xbe, 0xef]));
341+
directDb.close();
342+
343+
const reopenedStore = new SqliteCacheStore(cachePath);
344+
try {
345+
expect(await reopenedStore.get('corrupt-compressed-key')).toBeUndefined();
346+
} finally {
347+
reopenedStore.close();
348+
}
349+
});
350+
227351
it('should treat a non-binary payload as a cache miss', async () => {
228352
await store.set('text-key', 'value');
229353
store.close();

0 commit comments

Comments
 (0)