Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 69 additions & 6 deletions packages/angular/build/src/tools/esbuild/sqlite-cache-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -88,16 +110,26 @@ export class SqliteCacheStore implements PersistentCacheStore<unknown> {
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 = ?',
Expand Down Expand Up @@ -206,14 +238,31 @@ export class SqliteCacheStore implements PersistentCacheStore<unknown> {

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.
}
Expand Down Expand Up @@ -245,7 +294,21 @@ export class SqliteCacheStore implements PersistentCacheStore<unknown> {

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.
}
Expand Down
188 changes: 188 additions & 0 deletions packages/angular/build/src/tools/esbuild/sqlite-cache-store_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down