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' ;
1010import { dirname } from 'node:path' ;
1111import { DatabaseSync , StatementSync } from 'node:sqlite' ;
1212import { deserialize , serialize } from 'node:v8' ;
1313import { 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 */
2346export 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