Skip to content

Commit 58d93fa

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 58d93fa

6 files changed

Lines changed: 290 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] ?? lmdbError}\n` +
262+
`SQLite error: ${(sqliteError as Error)?.message?.split('\n')[0] ?? sqliteError}`,
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: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
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, StatementSync } from 'node:sqlite';
10+
import { Cache, PersistentCacheStore } from './cache';
11+
12+
export class SqliteCacheStore implements PersistentCacheStore<unknown> {
13+
#db: DatabaseSync | undefined;
14+
#getStmt: StatementSync | undefined;
15+
#hasStmt: StatementSync | undefined;
16+
#setStmt: StatementSync | undefined;
17+
#updateAccessedStmt: StatementSync | undefined;
18+
19+
constructor(
20+
readonly cachePath: string,
21+
private readonly maxPayloadSize = 1024 * 1024 * 1024,
22+
private readonly ttlDays = 14,
23+
) {}
24+
25+
#ensureDb(): DatabaseSync {
26+
if (!this.#db) {
27+
this.#db = new DatabaseSync(this.cachePath);
28+
// Optimize SQLite for cache usage
29+
this.#db.exec('PRAGMA auto_vacuum = FULL;');
30+
this.#db.exec('PRAGMA journal_mode = WAL;');
31+
this.#db.exec('PRAGMA synchronous = NORMAL;');
32+
this.#db.exec(
33+
'CREATE TABLE IF NOT EXISTS cache (key TEXT PRIMARY KEY, value TEXT, last_accessed INTEGER NOT NULL) WITHOUT ROWID;',
34+
);
35+
36+
this.#getStmt = this.#db.prepare('SELECT value FROM cache WHERE key = ?');
37+
this.#hasStmt = this.#db.prepare('SELECT 1 FROM cache WHERE key = ?');
38+
this.#setStmt = this.#db.prepare(
39+
'INSERT OR REPLACE INTO cache (key, value, last_accessed) VALUES (?, ?, unixepoch())',
40+
);
41+
this.#updateAccessedStmt = this.#db.prepare(
42+
'UPDATE cache SET last_accessed = unixepoch() WHERE key = ?',
43+
);
44+
}
45+
46+
return this.#db;
47+
}
48+
49+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
50+
async get(key: string): Promise<any> {
51+
this.#ensureDb();
52+
const row = this.#getStmt?.get(key) as { value: string } | undefined;
53+
54+
if (row) {
55+
this.#updateAccessedStmt?.run(key);
56+
57+
try {
58+
return JSON.parse(row.value);
59+
} catch {
60+
return undefined;
61+
}
62+
}
63+
64+
return undefined;
65+
}
66+
67+
has(key: string): boolean {
68+
this.#ensureDb();
69+
70+
return !!this.#hasStmt?.get(key);
71+
}
72+
73+
async set(key: string, value: unknown): Promise<this> {
74+
this.#ensureDb();
75+
this.#setStmt?.run(key, JSON.stringify(value));
76+
77+
return this;
78+
}
79+
80+
createCache<V = unknown>(namespace: string): Cache<V> {
81+
return new Cache(this, namespace);
82+
}
83+
84+
close(): void {
85+
if (this.#db) {
86+
try {
87+
// 1. Delete items older than N days
88+
this.#db
89+
.prepare("DELETE FROM cache WHERE last_accessed < unixepoch('now', ?);")
90+
.run(`-${this.ttlDays} days`);
91+
92+
// 2. Prune oldest items if payload exceeds maxPayloadSize
93+
const pruneStmt = this.#db.prepare(`
94+
DELETE FROM cache WHERE key IN (
95+
SELECT key FROM (
96+
SELECT key,
97+
sum(length(key) + length(value)) OVER (ORDER BY last_accessed DESC, key DESC) as running_size
98+
FROM cache
99+
) WHERE running_size > ?
100+
);
101+
`);
102+
pruneStmt.run(this.maxPayloadSize);
103+
} catch {
104+
// Pruning errors should not block build success
105+
} finally {
106+
this.#getStmt = undefined;
107+
this.#hasStmt = undefined;
108+
this.#setStmt = undefined;
109+
this.#updateAccessedStmt = undefined;
110+
111+
this.#db.close();
112+
this.#db = undefined;
113+
}
114+
}
115+
}
116+
}
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)