Skip to content

Commit aa5b08b

Browse files
committed
feat(@angular/build): add built-in SQLite cache store fallback
To improve toolchain robustness in environments where native dependencies (like lmdb) fail to compile or load (e.g. Docker, locked-down CI environments), this change introduces a built-in SQLite cache store using Node.js v22's node:sqlite module.
1 parent 5c77c33 commit aa5b08b

6 files changed

Lines changed: 273 additions & 19 deletions

File tree

packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import * as path from 'node:path';
2424
import { maxWorkers, useTypeChecking } from '../../../utils/environment-options';
2525
import { AngularHostOptions } from '../../angular/angular-host';
2626
import { AngularCompilation, DiagnosticModes, NoopCompilation } from '../../angular/compilation';
27+
import { type PersistentCacheStore, createPersistentCacheStore } from '../cache';
2728
import { JavaScriptTransformer } from '../javascript-transformer';
2829
import { LoadResultCache, createCachedLoad } from '../load-result-cache';
2930
import { logCumulativeDurations, profileAsync, resetCumulativeDurations } from '../profiling';
@@ -62,9 +63,7 @@ export interface CompilerPluginOptions {
6263
export function createCompilerPlugin(
6364
pluginOptions: CompilerPluginOptions,
6465
compilationContextOrCompilation:
65-
| AngularCompilationContext
66-
| AngularCompilation
67-
| (() => Promise<AngularCompilation>),
66+
AngularCompilationContext | AngularCompilation | (() => Promise<AngularCompilation>),
6867
stylesheetBundler: ComponentStylesheetBundler,
6968
): Plugin {
7069
return {
@@ -76,20 +75,21 @@ export function createCompilerPlugin(
7675

7776
// Initialize a worker pool for JavaScript transformations.
7877
// Webcontainers currently do not support this persistent cache store.
79-
let cacheStore: import('../lmdb-cache-store').LmdbCacheStore | undefined;
78+
let cacheStore: PersistentCacheStore | undefined;
8079
if (pluginOptions.sourceFileCache?.persistentCachePath && !process.versions.webcontainer) {
8180
try {
82-
const { LmdbCacheStore } = await import('../lmdb-cache-store');
83-
cacheStore = new LmdbCacheStore(
84-
path.join(pluginOptions.sourceFileCache.persistentCachePath, 'angular-compiler.db'),
81+
cacheStore = await createPersistentCacheStore(
82+
path.join(pluginOptions.sourceFileCache.persistentCachePath, 'angular-compiler'),
8583
);
8684
} catch (e) {
8785
setupWarnings.push({
8886
text: 'Unable to initialize JavaScript cache storage.',
8987
location: null,
9088
notes: [
91-
// Only show first line of lmdb load error which has platform support listed
92-
{ text: (e as Error)?.message.split('\n')[0] ?? `${e}` },
89+
...(e as Error).message
90+
.split('\n')
91+
.slice(1)
92+
.map((text) => ({ text })),
9393
{
9494
text: 'This will not affect the build output content but may result in slower builds.',
9595
},
@@ -666,7 +666,7 @@ function createCompilerOptionsTransformer(
666666
// If 'useDefineForClassFields' is already defined in the users project leave the value as is.
667667
// Otherwise fallback to false due to https://github.com/microsoft/TypeScript/issues/45995
668668
// which breaks the deprecated `@Effects` NGRX decorator and potentially other existing code as well.
669-
compilerOptions.target = 9 /** ES2022 */;
669+
compilerOptions.target = 9; /** ES2022 */
670670
compilerOptions.useDefineForClassFields ??= false;
671671

672672
// Only add the warning on the initial build

packages/angular/build/src/tools/esbuild/cache.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,16 @@ export interface CacheStore<V> {
3838
set(key: string, value: V): this | Promise<this>;
3939
}
4040

41+
/**
42+
* A persistent backing data store that supports namespace partitioning
43+
* and manual lifecycle close operations.
44+
*/
45+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
46+
export interface PersistentCacheStore<V = any> extends CacheStore<V> {
47+
createCache<T = V>(namespace: string): Cache<T>;
48+
close(): void | Promise<void>;
49+
}
50+
4151
/**
4252
* A cache object that allows accessing and storing key/value pairs in
4353
* an underlying CacheStore. This class is the primary method for consumers
@@ -224,3 +234,34 @@ export class MemoryCache<V> extends Cache<V, Map<string, V>> {
224234
return this.store.entries();
225235
}
226236
}
237+
238+
/**
239+
* Creates and returns a persistent cache store.
240+
* Attempts to use the native LMDB store first, and falls back to the built-in SQLite store
241+
* if LMDB fails to initialize.
242+
*
243+
* @param baseCachePath The base path of the cache file/directory without suffix/extension.
244+
* @returns A promise resolving to a PersistentCacheStore instance.
245+
*/
246+
export async function createPersistentCacheStore(
247+
baseCachePath: string,
248+
): Promise<PersistentCacheStore> {
249+
try {
250+
const { LmdbCacheStore } = await import('./lmdb-cache-store');
251+
252+
return new LmdbCacheStore(baseCachePath + '.db');
253+
} catch (lmdbError) {
254+
try {
255+
const { SqliteCacheStore } = await import('./sqlite-cache-store');
256+
257+
return new SqliteCacheStore(baseCachePath + '-sqlite.db');
258+
} catch (sqliteError) {
259+
throw new Error(
260+
'Unable to initialize JavaScript cache storage.\n' +
261+
`LMDB error: ${(lmdbError as Error).message.split('\n')[0]}\n` +
262+
`SQLite error: ${(sqliteError as Error).message.split('\n')[0]}`,
263+
{ cause: sqliteError },
264+
);
265+
}
266+
}
267+
}

packages/angular/build/src/tools/esbuild/i18n-inliner.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { createHash } from 'node:crypto';
1111
import { extname, join } from 'node:path';
1212
import { WorkerPool } from '../../utils/worker-pool';
1313
import { type BuildOutputFile, BuildOutputFileType, createOutputFile } from './bundler-files';
14-
import type { LmdbCacheStore } from './lmdb-cache-store';
14+
import { type PersistentCacheStore, createPersistentCacheStore } from './cache';
1515

1616
/**
1717
* A keyword used to indicate if a JavaScript file may require inlining of translations.
@@ -38,7 +38,7 @@ export interface I18nInlinerOptions {
3838
export class I18nInliner {
3939
#cacheInitFailed = false;
4040
#workerPool: WorkerPool;
41-
#cache: LmdbCacheStore | undefined;
41+
#cache: PersistentCacheStore | undefined;
4242
readonly #localizeFiles: ReadonlyMap<string, BuildOutputFile>;
4343
readonly #unmodifiedFiles: Array<BuildOutputFile>;
4444

@@ -162,8 +162,10 @@ export class I18nInliner {
162162

163163
const result = await this.#workerPool.run({ filename, locale, translation });
164164
if (this.#cache && cacheKey) {
165-
// Failure to set the value should not fail the transform
166-
await this.#cache.set(cacheKey, result).catch(() => {});
165+
try {
166+
// Failure to set the value should not fail the transform
167+
await this.#cache.set(cacheKey, result);
168+
} catch {}
167169
}
168170

169171
return result;
@@ -273,9 +275,7 @@ export class I18nInliner {
273275

274276
// Initialize a persistent cache for i18n transformations.
275277
try {
276-
const { LmdbCacheStore } = await import('./lmdb-cache-store');
277-
278-
this.#cache = new LmdbCacheStore(join(persistentCachePath, 'angular-i18n.db'));
278+
this.#cache = await createPersistentCacheStore(join(persistentCachePath, 'angular-i18n'));
279279
} catch {
280280
this.#cacheInitFailed = true;
281281

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@
77
*/
88

99
import { RootDatabase, open } from 'lmdb';
10-
import { Cache, CacheStore } from './cache';
10+
import { Cache, PersistentCacheStore } from './cache';
1111

12-
export class LmdbCacheStore implements CacheStore<unknown> {
12+
export class LmdbCacheStore implements PersistentCacheStore<unknown> {
1313
readonly #cacheFileUrl;
1414
#db: RootDatabase | undefined;
1515

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import { DatabaseSync } from 'node:sqlite';
10+
import { Cache, PersistentCacheStore } from './cache';
11+
12+
export class SqliteCacheStore implements PersistentCacheStore<unknown> {
13+
#db: DatabaseSync | undefined;
14+
15+
constructor(
16+
readonly cachePath: string,
17+
private readonly maxPayloadSize = 1024 * 1024 * 1024,
18+
private readonly ttlDays = 14,
19+
) {}
20+
21+
#ensureDb(): DatabaseSync {
22+
if (!this.#db) {
23+
this.#db = new DatabaseSync(this.cachePath);
24+
// Optimize SQLite for cache usage
25+
this.#db.exec('PRAGMA auto_vacuum = FULL;');
26+
this.#db.exec('PRAGMA journal_mode = WAL;');
27+
this.#db.exec('PRAGMA synchronous = NORMAL;');
28+
this.#db.exec(
29+
'CREATE TABLE IF NOT EXISTS cache (key TEXT PRIMARY KEY, value TEXT, last_accessed INTEGER NOT NULL) WITHOUT ROWID;',
30+
);
31+
}
32+
33+
return this.#db;
34+
}
35+
36+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
37+
async get(key: string): Promise<any> {
38+
const db = this.#ensureDb();
39+
const statement = db.prepare('SELECT value FROM cache WHERE key = ?');
40+
const row = statement.get(key) as { value: string } | undefined;
41+
42+
if (row) {
43+
db.prepare('UPDATE cache SET last_accessed = unixepoch() WHERE key = ?').run(key);
44+
45+
return JSON.parse(row.value);
46+
}
47+
48+
return undefined;
49+
}
50+
51+
has(key: string): boolean {
52+
const db = this.#ensureDb();
53+
const statement = db.prepare('SELECT 1 FROM cache WHERE key = ?');
54+
55+
return !!statement.get(key);
56+
}
57+
58+
async set(key: string, value: unknown): Promise<this> {
59+
const db = this.#ensureDb();
60+
const statement = db.prepare(
61+
'INSERT OR REPLACE INTO cache (key, value, last_accessed) VALUES (?, ?, unixepoch())',
62+
);
63+
statement.run(key, JSON.stringify(value));
64+
65+
return this;
66+
}
67+
68+
createCache<V = unknown>(namespace: string): Cache<V> {
69+
return new Cache(this, namespace);
70+
}
71+
72+
close(): void {
73+
if (this.#db) {
74+
try {
75+
// 1. Delete items older than N days
76+
this.#db
77+
.prepare("DELETE FROM cache WHERE last_accessed < unixepoch('now', ?);")
78+
.run(`-${this.ttlDays} days`);
79+
80+
// 2. Prune oldest items if payload exceeds maxPayloadSize
81+
const pruneStmt = this.#db.prepare(`
82+
DELETE FROM cache WHERE key IN (
83+
SELECT key FROM (
84+
SELECT key,
85+
sum(length(key) + length(value)) OVER (ORDER BY last_accessed DESC, key DESC) as running_size
86+
FROM cache
87+
) WHERE running_size > ?
88+
);
89+
`);
90+
pruneStmt.run(this.maxPayloadSize);
91+
} catch {
92+
// Pruning errors should not block build success
93+
} finally {
94+
this.#db.close();
95+
this.#db = undefined;
96+
}
97+
}
98+
}
99+
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import { promises as fs } from 'node:fs';
10+
import { join } from 'node:path';
11+
import { SqliteCacheStore } from './sqlite-cache-store';
12+
13+
describe('SqliteCacheStore', () => {
14+
let tempDir: string;
15+
let cachePath: string;
16+
let store: SqliteCacheStore;
17+
18+
beforeEach(async () => {
19+
// Create a temporary directory in the workspace for testing
20+
tempDir = join(__dirname, `sqlite-test-temp-${Date.now()}`);
21+
await fs.mkdir(tempDir, { recursive: true });
22+
cachePath = join(tempDir, 'test-cache.db');
23+
store = new SqliteCacheStore(cachePath);
24+
});
25+
26+
afterEach(async () => {
27+
store.close();
28+
await fs.rm(tempDir, { recursive: true, force: true });
29+
});
30+
31+
it('should store and retrieve a value', async () => {
32+
const data = { foo: 'bar', list: [1, 2, 3] };
33+
await store.set('test-key', data);
34+
35+
const result = await store.get('test-key');
36+
expect(result).toEqual(data);
37+
});
38+
39+
it('should return undefined for non-existent key', async () => {
40+
const result = await store.get('missing-key');
41+
expect(result).toBeUndefined();
42+
});
43+
44+
it('should correctly report existence of a key', async () => {
45+
expect(store.has('exist-key')).toBeFalse();
46+
47+
await store.set('exist-key', 'value');
48+
expect(store.has('exist-key')).toBeTrue();
49+
});
50+
51+
it('should overwrite values for existing keys', async () => {
52+
await store.set('overwrite-key', 'initial');
53+
await store.set('overwrite-key', 'updated');
54+
55+
const result = await store.get('overwrite-key');
56+
expect(result).toBe('updated');
57+
});
58+
59+
it('should prune items older than TTL on close', async () => {
60+
// Write two items
61+
await store.set('new-key', 'new-val');
62+
await store.set('old-key', 'old-val');
63+
64+
// Close the store so we can modify the DB safely
65+
store.close();
66+
67+
// Directly open database to update timestamp of 'old-key' to 15 days ago
68+
const { DatabaseSync } = await import('node:sqlite');
69+
const directDb = new DatabaseSync(cachePath);
70+
directDb
71+
.prepare('UPDATE cache SET last_accessed = unixepoch() - 15 * 24 * 3600 WHERE key = ?')
72+
.run('old-key');
73+
directDb.close();
74+
75+
// Reopen store with a 14-day TTL, access it to open connection, then close to trigger pruning
76+
const pruneStore = new SqliteCacheStore(cachePath, undefined, 14);
77+
expect(pruneStore.has('new-key')).toBeTrue();
78+
pruneStore.close();
79+
80+
// Verify 'old-key' is gone but 'new-key' remains
81+
const checkStore = new SqliteCacheStore(cachePath);
82+
expect(checkStore.has('old-key')).toBeFalse();
83+
expect(checkStore.has('new-key')).toBeTrue();
84+
checkStore.close();
85+
});
86+
87+
it('should prune oldest items when total payload size exceeds maximum on close', async () => {
88+
// Close the default store so we can instantiate one with a small limit
89+
store.close();
90+
91+
// 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.
94+
const sizeStore = new SqliteCacheStore(cachePath, 25);
95+
96+
// Set k1, then k2, then k3.
97+
// Order of inserts: k1 (oldest), k2 (middle), k3 (newest)
98+
await sizeStore.set('k1', 'value1');
99+
await sizeStore.set('k2', 'value2');
100+
await sizeStore.set('k3', 'value3');
101+
102+
// Close sizeStore to trigger pruning
103+
sizeStore.close();
104+
105+
// Reopen to check which keys were kept
106+
const checkStore = new SqliteCacheStore(cachePath);
107+
// k3 (newest) and k2 (middle) should be kept (~20 bytes total)
108+
// k1 (oldest) should be pruned to get under 25 bytes.
109+
expect(checkStore.has('k3')).toBeTrue();
110+
expect(checkStore.has('k2')).toBeTrue();
111+
expect(checkStore.has('k1')).toBeFalse();
112+
checkStore.close();
113+
});
114+
});

0 commit comments

Comments
 (0)