Skip to content

Commit fe2f180

Browse files
committed
fix(@angular/build): add automatic corruption recovery in SQLite cache store
Abrupt process terminations (such as canceling a watch mode build or CI runner timeouts) can leave SQLite databases or their write-ahead logs in an inconsistent or corrupted state. Previously, corrupted database files or malformed pages caused subsequent builds to fail continuously until the cache directory was manually deleted. Additionally, uninitializable cache stores on read-only or restricted filesystems would throw errors during build initialization. Corrupted cache files (.db, .db-wal, and .db-shm) are now automatically removed and recreated upon initialization failure. If the cache cannot be initialized or recovered (such as on read-only filesystems or due to permission errors), the cache store gracefully disables itself. Cache operations subsequently degrade cleanly to cache misses, allowing builds to proceed successfully without error.
1 parent fc4de6b commit fe2f180

2 files changed

Lines changed: 278 additions & 49 deletions

File tree

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

Lines changed: 147 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,35 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import { mkdirSync } from 'node:fs';
9+
import { mkdirSync, rmSync } from 'node:fs';
1010
import { dirname } from 'node:path';
1111
import { DatabaseSync, StatementSync } from 'node:sqlite';
1212
import { deserialize, serialize } from 'node:v8';
1313
import { Cache, PersistentCacheStore } from './cache';
1414

15+
/**
16+
* Common SQLite primary result codes.
17+
* @see https://www.sqlite.org/rescode.html
18+
*/
19+
const enum SqliteResultCode {
20+
Busy = 5,
21+
Locked = 6,
22+
}
23+
24+
interface SqliteError extends Error {
25+
code?: string;
26+
errcode?: number;
27+
errstr?: string;
28+
}
29+
30+
function isSqliteError(error: unknown): error is SqliteError {
31+
return (
32+
error instanceof Error &&
33+
('errcode' in error ||
34+
('code' in error && (error as { code: unknown }).code === 'ERR_SQLITE_ERROR'))
35+
);
36+
}
37+
1538
/**
1639
* A persistent cache store backed by SQLite.
1740
*
@@ -22,56 +45,114 @@ import { Cache, PersistentCacheStore } from './cache';
2245
*/
2346
export class SqliteCacheStore implements PersistentCacheStore<unknown> {
2447
#db: DatabaseSync | undefined;
48+
#disabled = false;
2549
#getStmt: StatementSync | undefined;
2650
#hasStmt: StatementSync | undefined;
2751
#setStmt: StatementSync | undefined;
2852
#updateAccessedStmt: StatementSync | undefined;
2953
readonly #pendingAccessedKeys = new Set<string>();
3054
#flushTimeout: NodeJS.Timeout | undefined;
55+
readonly #busyTimeoutMs: number;
3156

3257
constructor(
3358
readonly cachePath: string,
3459
private readonly maxPayloadSize = 1024 * 1024 * 1024,
3560
private readonly ttlDays = 14,
36-
) {}
61+
busyTimeoutMs = 5000,
62+
) {
63+
this.#busyTimeoutMs =
64+
Number.isSafeInteger(busyTimeoutMs) && busyTimeoutMs >= 0 ? busyTimeoutMs : 5000;
65+
}
3766

38-
#ensureDb(): DatabaseSync {
39-
if (!this.#db) {
67+
#openDatabase(): DatabaseSync {
68+
let db: DatabaseSync | undefined;
69+
try {
4070
if (this.cachePath === ':memory:') {
41-
this.#db = new DatabaseSync(this.cachePath);
71+
db = new DatabaseSync(this.cachePath);
4272
} else {
4373
// Optimistically attempt to open the database file first to avoid directory creation
4474
// syscalls on warm builds where the parent directory already exists.
4575
try {
46-
this.#db = new DatabaseSync(this.cachePath);
76+
db = new DatabaseSync(this.cachePath);
4777
} catch {
4878
mkdirSync(dirname(this.cachePath), { recursive: true });
49-
this.#db = new DatabaseSync(this.cachePath);
79+
db = new DatabaseSync(this.cachePath);
5080
}
5181
}
5282

5383
// Optimize SQLite for cache usage
54-
this.#db.exec('PRAGMA auto_vacuum = FULL;');
55-
this.#db.exec('PRAGMA journal_mode = WAL;');
56-
this.#db.exec('PRAGMA synchronous = NORMAL;');
57-
this.#db.exec('PRAGMA busy_timeout = 5000;');
58-
this.#db.exec('PRAGMA temp_store = MEMORY;');
59-
this.#db.exec('PRAGMA mmap_size = 268435456;');
60-
this.#db.exec(
84+
db.exec(`PRAGMA busy_timeout = ${this.#busyTimeoutMs};`);
85+
db.exec('PRAGMA auto_vacuum = FULL;');
86+
db.exec('PRAGMA journal_mode = WAL;');
87+
db.exec('PRAGMA synchronous = NORMAL;');
88+
db.exec('PRAGMA temp_store = MEMORY;');
89+
db.exec('PRAGMA mmap_size = 268435456;');
90+
db.exec(
6191
'CREATE TABLE IF NOT EXISTS cache (key TEXT PRIMARY KEY, value BLOB, last_accessed INTEGER NOT NULL) WITHOUT ROWID;',
6292
);
63-
this.#db.exec(
93+
db.exec(
6494
'CREATE INDEX IF NOT EXISTS idx_cache_accessed ON cache (last_accessed DESC, key DESC);',
6595
);
6696

67-
this.#getStmt = this.#db.prepare('SELECT value FROM cache WHERE key = ?');
68-
this.#hasStmt = this.#db.prepare('SELECT 1 FROM cache WHERE key = ?');
69-
this.#setStmt = this.#db.prepare(
97+
this.#getStmt = db.prepare('SELECT value FROM cache WHERE key = ?');
98+
this.#hasStmt = db.prepare('SELECT 1 FROM cache WHERE key = ?');
99+
this.#setStmt = db.prepare(
70100
'INSERT OR REPLACE INTO cache (key, value, last_accessed) VALUES (?, ?, unixepoch())',
71101
);
72-
this.#updateAccessedStmt = this.#db.prepare(
102+
this.#updateAccessedStmt = db.prepare(
73103
'UPDATE cache SET last_accessed = unixepoch() WHERE key = ?',
74104
);
105+
106+
this.#db = db;
107+
108+
return db;
109+
} catch (error) {
110+
try {
111+
db?.close();
112+
} catch {
113+
// Ignore close error on corrupted handle
114+
}
115+
this.#getStmt = undefined;
116+
this.#hasStmt = undefined;
117+
this.#setStmt = undefined;
118+
this.#updateAccessedStmt = undefined;
119+
throw error;
120+
}
121+
}
122+
123+
#ensureDb(): DatabaseSync | undefined {
124+
if (this.#disabled) {
125+
return undefined;
126+
}
127+
128+
if (!this.#db) {
129+
try {
130+
return this.#openDatabase();
131+
} catch (error) {
132+
// If the database is locked by another active process,
133+
// do not attempt to delete the database files as that could corrupt the active process's database.
134+
const isBusy =
135+
isSqliteError(error) &&
136+
(error.errcode === SqliteResultCode.Busy || error.errcode === SqliteResultCode.Locked);
137+
138+
// Attempt to recover from database corruption by deleting the corrupted files and recreating
139+
if (!isBusy && this.cachePath !== ':memory:') {
140+
try {
141+
rmSync(this.cachePath, { force: true });
142+
rmSync(this.cachePath + '-wal', { force: true });
143+
rmSync(this.cachePath + '-shm', { force: true });
144+
rmSync(this.cachePath + '-journal', { force: true });
145+
146+
return this.#openDatabase();
147+
} catch {
148+
// If recovery fails (e.g. read-only filesystem or permission denied), disable caching
149+
}
150+
}
151+
152+
this.#disabled = true;
153+
154+
return undefined;
155+
}
75156
}
76157

77158
return this.#db;
@@ -94,19 +175,21 @@ export class SqliteCacheStore implements PersistentCacheStore<unknown> {
94175
this.#flushTimeout = undefined;
95176
}
96177

97-
if (!this.#db || this.#pendingAccessedKeys.size === 0 || !this.#updateAccessedStmt) {
178+
if (this.#pendingAccessedKeys.size === 0) {
98179
return;
99180
}
100181

101182
try {
102-
this.#db.exec('BEGIN IMMEDIATE TRANSACTION;');
103-
for (const key of this.#pendingAccessedKeys) {
104-
this.#updateAccessedStmt.run(key);
183+
if (this.#db && this.#updateAccessedStmt) {
184+
this.#db.exec('BEGIN IMMEDIATE TRANSACTION;');
185+
for (const key of this.#pendingAccessedKeys) {
186+
this.#updateAccessedStmt.run(key);
187+
}
188+
this.#db.exec('COMMIT;');
105189
}
106-
this.#db.exec('COMMIT;');
107190
} catch {
108191
try {
109-
this.#db.exec('ROLLBACK;');
192+
this.#db?.exec('ROLLBACK;');
110193
} catch {
111194
// Ignore rollback errors if transaction was not active
112195
}
@@ -117,35 +200,55 @@ export class SqliteCacheStore implements PersistentCacheStore<unknown> {
117200

118201
// eslint-disable-next-line @typescript-eslint/no-explicit-any
119202
async get(key: string): Promise<any> {
120-
this.#ensureDb();
121-
// SQLite column types are dynamic, so the stored value is only known at runtime.
122-
const row = this.#getStmt?.get(key) as { value: unknown } | undefined;
203+
if (!this.#ensureDb()) {
204+
return undefined;
205+
}
206+
207+
try {
208+
// SQLite column types are dynamic, so the stored value is only known at runtime.
209+
const row = this.#getStmt?.get(key) as { value: unknown } | undefined;
123210

124-
if (row) {
125-
this.#queueAccessUpdate(key);
211+
if (row) {
212+
this.#queueAccessUpdate(key);
126213

127-
if (row.value instanceof Uint8Array) {
128-
try {
129-
return deserialize(row.value);
130-
} catch {
131-
// Treat corrupt or unparseable cached payloads as a cache miss.
214+
if (row.value instanceof Uint8Array) {
215+
try {
216+
return deserialize(row.value);
217+
} catch {
218+
// Treat corrupt or unparseable cached payloads as a cache miss.
219+
}
132220
}
133221
}
222+
} catch {
223+
// Treat query errors (e.g. disk read failures) as a cache miss.
134224
}
135225

136226
return undefined;
137227
}
138228

139229
has(key: string): boolean {
140-
this.#ensureDb();
230+
if (!this.#ensureDb()) {
231+
return false;
232+
}
141233

142-
return !!this.#hasStmt?.get(key);
234+
try {
235+
return !!this.#hasStmt?.get(key);
236+
} catch {
237+
return false;
238+
}
143239
}
144240

145241
async set(key: string, value: unknown): Promise<this> {
146-
this.#ensureDb();
147-
this.#pendingAccessedKeys.delete(key);
148-
this.#setStmt?.run(key, serialize(value));
242+
if (!this.#ensureDb()) {
243+
return this;
244+
}
245+
246+
try {
247+
this.#pendingAccessedKeys.delete(key);
248+
this.#setStmt?.run(key, serialize(value));
249+
} catch {
250+
// Writing to cache is non-fatal and should not fail the build.
251+
}
149252

150253
return this;
151254
}
@@ -155,11 +258,10 @@ export class SqliteCacheStore implements PersistentCacheStore<unknown> {
155258
}
156259

157260
close(): void {
261+
this.#flushAccessUpdates();
262+
158263
if (this.#db) {
159264
try {
160-
// Flush any pending access updates in one transaction before pruning
161-
this.#flushAccessUpdates();
162-
163265
this.#db.exec('BEGIN IMMEDIATE TRANSACTION;');
164266
try {
165267
// 1. Delete items older than N days
@@ -202,12 +304,6 @@ export class SqliteCacheStore implements PersistentCacheStore<unknown> {
202304
} catch {
203305
// Pruning errors should not block build success
204306
} finally {
205-
if (this.#flushTimeout) {
206-
clearTimeout(this.#flushTimeout);
207-
this.#flushTimeout = undefined;
208-
}
209-
this.#pendingAccessedKeys.clear();
210-
211307
this.#getStmt = undefined;
212308
this.#hasStmt = undefined;
213309
this.#setStmt = undefined;
@@ -221,5 +317,7 @@ export class SqliteCacheStore implements PersistentCacheStore<unknown> {
221317
this.#db = undefined;
222318
}
223319
}
320+
321+
this.#disabled = false;
224322
}
225323
}

0 commit comments

Comments
 (0)