Skip to content

Commit 34d558c

Browse files
clydinalan-agius4
authored andcommitted
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 6d9b360 commit 34d558c

6 files changed

Lines changed: 295 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: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
* Provides infrastructure for common caching functionality within the build system.
1212
*/
1313

14+
import { assertIsError } from '../../utils/error';
15+
1416
/**
1517
* A backing data store for one or more Cache instances.
1618
* The interface is intentionally designed to support using a JavaScript
@@ -38,6 +40,16 @@ export interface CacheStore<V> {
3840
set(key: string, value: V): this | Promise<this>;
3941
}
4042

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

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+
}

0 commit comments

Comments
 (0)