Skip to content

Commit ea5f8d0

Browse files
committed
fix(@angular/build): preserve binary values in the SQLite cache store
The SQLite cache store serialized values with `JSON.stringify` and read them back with `JSON.parse`. Several cached values contain binary data: the JavaScript transformer stores its worker output as a `Uint8Array` (`Cache<Uint8Array>`), and `CachedLoadResultEntry.contents` is typed as `string | Uint8Array`. A JSON round trip cannot represent typed arrays, so those values came back from disk as plain objects (`{"0":105,"1":109,...}`) and were handed to esbuild as load result contents, failing the build with `"contents" must be a string or a Uint8Array`. The failure only appeared from the second build onwards, because the first build serves the value from the in-memory cache layer and the value is only corrupted once it is read back from disk. Values are now persisted using the V8 structured clone serialization API (`node:v8`), which supports typed arrays natively and matches the behavior of the LMDB store. The `value` column is declared as `BLOB` accordingly. Entries written by a previous version of the store contain JSON text, which fails to deserialize and is already handled as a cache miss, so those entries are simply recreated. The store is only reached when LMDB fails to load, which is why this went unnoticed on most systems. A common trigger is a prebuilt `@lmdb/lmdb-linux-x64` binary requiring a newer glibc than the host provides, for example on Ubuntu 20.04, Debian 11, or RHEL/CentOS 8. The fallback can also be selected explicitly with `NG_BUILD_CACHE_STORE=sqlite`. Closes #33841
1 parent e3d55b2 commit ea5f8d0

2 files changed

Lines changed: 72 additions & 6 deletions

File tree

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

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,17 @@
77
*/
88

99
import { DatabaseSync, StatementSync } from 'node:sqlite';
10+
import { deserialize, serialize } from 'node:v8';
1011
import { Cache, PersistentCacheStore } from './cache';
1112

13+
/**
14+
* A persistent cache store backed by SQLite.
15+
*
16+
* Values are persisted with the V8 structured clone serialization API instead of JSON. Cached
17+
* values include binary data such as the `Uint8Array` output of the JavaScript transformer and
18+
* the `contents` of an esbuild load result. A JSON round-trip converts those into plain objects
19+
* (`{"0":105,"1":109,...}`), which breaks consumers on any build that reads them back from disk.
20+
*/
1221
export class SqliteCacheStore implements PersistentCacheStore<unknown> {
1322
#db: DatabaseSync | undefined;
1423
#getStmt: StatementSync | undefined;
@@ -35,7 +44,7 @@ export class SqliteCacheStore implements PersistentCacheStore<unknown> {
3544
this.#db.exec('PRAGMA temp_store = MEMORY;');
3645
this.#db.exec('PRAGMA mmap_size = 268435456;');
3746
this.#db.exec(
38-
'CREATE TABLE IF NOT EXISTS cache (key TEXT PRIMARY KEY, value TEXT, last_accessed INTEGER NOT NULL) WITHOUT ROWID;',
47+
'CREATE TABLE IF NOT EXISTS cache (key TEXT PRIMARY KEY, value BLOB, last_accessed INTEGER NOT NULL) WITHOUT ROWID;',
3948
);
4049

4150
this.#getStmt = this.#db.prepare('SELECT value FROM cache WHERE key = ?');
@@ -92,14 +101,16 @@ export class SqliteCacheStore implements PersistentCacheStore<unknown> {
92101
// eslint-disable-next-line @typescript-eslint/no-explicit-any
93102
async get(key: string): Promise<any> {
94103
this.#ensureDb();
95-
const row = this.#getStmt?.get(key) as { value: string } | undefined;
104+
const row = this.#getStmt?.get(key) as { value: Uint8Array } | undefined;
96105

97106
if (row) {
98107
this.#queueAccessUpdate(key);
99108

100109
try {
101-
return JSON.parse(row.value);
110+
return deserialize(row.value);
102111
} catch {
112+
// Entries written by a previous version of the store contain JSON text instead of a
113+
// structured clone payload. Treating them as a cache miss causes them to be recreated.
103114
return undefined;
104115
}
105116
}
@@ -116,7 +127,7 @@ export class SqliteCacheStore implements PersistentCacheStore<unknown> {
116127
async set(key: string, value: unknown): Promise<this> {
117128
this.#ensureDb();
118129
this.#pendingAccessedKeys.delete(key);
119-
this.#setStmt?.run(key, JSON.stringify(value));
130+
this.#setStmt?.run(key, serialize(value));
120131

121132
return this;
122133
}

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

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,61 @@ describe('SqliteCacheStore', () => {
3636
expect(result).toEqual(data);
3737
});
3838

39+
it('should preserve binary values', async () => {
40+
const data = new TextEncoder().encode('export const value = 1;\n');
41+
await store.set('binary-key', data);
42+
43+
const result = await store.get('binary-key');
44+
expect(result).toBeInstanceOf(Uint8Array);
45+
expect(result).toEqual(data);
46+
});
47+
48+
it('should preserve binary values nested within an object', async () => {
49+
const data = {
50+
contents: new TextEncoder().encode('export const value = 1;\n'),
51+
loader: 'js',
52+
watchFiles: ['/some/file.js'],
53+
};
54+
await store.set('nested-binary-key', data);
55+
56+
const result = await store.get('nested-binary-key');
57+
expect(result.contents).toBeInstanceOf(Uint8Array);
58+
expect(result).toEqual(data);
59+
});
60+
61+
it('should preserve binary values across store instances', async () => {
62+
const data = new TextEncoder().encode('export const value = 1;\n');
63+
await store.set('persisted-binary-key', data);
64+
store.close();
65+
66+
const reopenedStore = new SqliteCacheStore(cachePath);
67+
try {
68+
const result = await reopenedStore.get('persisted-binary-key');
69+
expect(result).toBeInstanceOf(Uint8Array);
70+
expect(result).toEqual(data);
71+
} finally {
72+
reopenedStore.close();
73+
}
74+
});
75+
76+
it('should treat entries that cannot be deserialized as a cache miss', async () => {
77+
await store.set('legacy-key', 'value');
78+
store.close();
79+
80+
// Simulate an entry written by a previous version of the store which used JSON text.
81+
const { DatabaseSync } = await import('node:sqlite');
82+
const directDb = new DatabaseSync(cachePath);
83+
directDb.prepare('UPDATE cache SET value = ? WHERE key = ?').run('"value"', 'legacy-key');
84+
directDb.close();
85+
86+
const reopenedStore = new SqliteCacheStore(cachePath);
87+
try {
88+
expect(await reopenedStore.get('legacy-key')).toBeUndefined();
89+
} finally {
90+
reopenedStore.close();
91+
}
92+
});
93+
3994
it('should return undefined for non-existent key', async () => {
4095
const result = await store.get('missing-key');
4196
expect(result).toBeUndefined();
@@ -89,8 +144,8 @@ describe('SqliteCacheStore', () => {
89144
store.close();
90145

91146
// Create a store with a tiny size limit (e.g. 25 bytes)
92-
// Keys 'k1', 'k2', 'k3' are small (each is 10 bytes: key + JSON.stringify(value)).
93-
// Total size of k1 + k2 + k3 is 30 bytes, which exceeds the 25 bytes limit.
147+
// Keys 'k1', 'k2', 'k3' are small (each is 12 bytes: 2 byte key + 10 byte serialized value).
148+
// Total size of k1 + k2 + k3 is 36 bytes, which exceeds the 25 bytes limit.
94149
const sizeStore = new SqliteCacheStore(cachePath, 25);
95150

96151
// Set k1, then k2, then k3.

0 commit comments

Comments
 (0)