@@ -6,7 +6,9 @@ const { zstdCompressSync, zstdDecompressSync } = require('node:zlib')
66
77let DatabaseSync
88
9- const VERSION = 4
9+ // Bump when entries written by an older cache policy are unsafe to reuse.
10+ // Version 5 invalidates entries that may have been poisoned through redirects.
11+ const VERSION = 5
1012
1113// 2gb
1214const MAX_ENTRY_SIZE = 2 * 1000 * 1000 * 1000
@@ -18,6 +20,20 @@ const COMPRESS_THRESHOLD = 1024 * 1024
1820// node:sqlite is available.
1921const hasZstd = typeof zstdCompressSync === 'function' && typeof zstdDecompressSync === 'function'
2022
23+ // SQLite reports a database that another connection is writing as SQLITE_BUSY
24+ // (5) or SQLITE_LOCKED (6).
25+ // https://www.sqlite.org/rescode.html#busy
26+ const SQLITE_BUSY = 5
27+ const SQLITE_LOCKED = 6
28+
29+ /**
30+ * @param {Error & { errcode?: number } } err
31+ * @returns {boolean }
32+ */
33+ function isLockedError ( err ) {
34+ return err ?. errcode === SQLITE_BUSY || err ?. errcode === SQLITE_LOCKED
35+ }
36+
2137/**
2238 * @typedef {import('../../types/cache-interceptor.d.ts').default.CacheStore } CacheStore
2339 * @implements {CacheStore}
@@ -259,7 +275,7 @@ module.exports = class SqliteCacheStore {
259275 get ( key ) {
260276 assertCacheKey ( key )
261277
262- const value = this . #findValue( key )
278+ const value = this . #lockSafe ( ( ) => this . # findValue( key ) )
263279 return value
264280 ? {
265281 body : value . body
@@ -307,10 +323,10 @@ module.exports = class SqliteCacheStore {
307323 }
308324 }
309325
310- const existingValue = this . #findValue( key , true )
326+ const existingValue = this . #lockSafe ( ( ) => this . # findValue( key , true ) )
311327 if ( existingValue ) {
312328 // Updating an existing response, let's overwrite it
313- this . #updateValueQuery. run (
329+ this . #lockSafe ( ( ) => this . # updateValueQuery. run (
314330 storedBody ,
315331 compressed ,
316332 value . deleteAt ,
@@ -323,10 +339,10 @@ module.exports = class SqliteCacheStore {
323339 value . cachedAt ,
324340 value . staleAt ,
325341 existingValue . id
326- )
342+ ) )
327343 } else {
328344 // New response, let's insert it
329- this . #insertValueQuery. run (
345+ this . #lockSafe ( ( ) => this . # insertValueQuery. run (
330346 url ,
331347 key . method ,
332348 storedBody ,
@@ -340,7 +356,7 @@ module.exports = class SqliteCacheStore {
340356 value . vary ? JSON . stringify ( value . vary ) : null ,
341357 value . cachedAt ,
342358 value . staleAt
343- )
359+ ) )
344360 this . #prune( )
345361 }
346362 }
@@ -389,23 +405,53 @@ module.exports = class SqliteCacheStore {
389405 throw new TypeError ( `expected key to be object, got ${ typeof key } ` )
390406 }
391407
392- this . #deleteByUrlQuery. run ( this . #makeValueUrl( key ) )
408+ this . #lockSafe( ( ) => this . #deleteByUrlQuery. run ( this . #makeValueUrl( key ) ) )
409+ }
410+
411+ /**
412+ * Runs a database operation, skipping it when another connection holds the
413+ * database lock.
414+ *
415+ * The cache store is meant to be shared between processes, and SQLite reports
416+ * a concurrent writer as SQLITE_BUSY/SQLITE_LOCKED. Those errors are
417+ * transient, and caching is best effort: the entry is skipped and the next
418+ * request retries, rather than failing the request that triggered the write.
419+ *
420+ * @template T
421+ * @param {() => T } fn
422+ * @returns {T | undefined }
423+ */
424+ #lockSafe ( fn ) {
425+ try {
426+ return fn ( )
427+ } catch ( err ) {
428+ if ( ! isLockedError ( err ) ) {
429+ throw err
430+ }
431+
432+ return undefined
433+ }
393434 }
394435
395436 #prune ( ) {
396- if ( Number . isFinite ( this . #maxCount) && this . size <= this . #maxCount) {
397- return 0
437+ if ( Number . isFinite ( this . #maxCount) ) {
438+ // A locked database cannot be measured, so skip pruning instead of
439+ // failing the write that triggered it.
440+ const size = this . #lockSafe( ( ) => this . size )
441+ if ( size === undefined || size <= this . #maxCount) {
442+ return 0
443+ }
398444 }
399445
400446 {
401- const removed = this . #deleteExpiredValuesQuery. run ( Date . now ( ) ) . changes
447+ const removed = this . #lockSafe ( ( ) => this . # deleteExpiredValuesQuery. run ( Date . now ( ) ) . changes )
402448 if ( removed ) {
403449 return removed
404450 }
405451 }
406452
407453 {
408- const removed = this . #deleteOldValuesQuery?. run ( Math . max ( Math . floor ( this . #maxCount * 0.1 ) , 1 ) ) . changes
454+ const removed = this . #lockSafe ( ( ) => this . # deleteOldValuesQuery?. run ( Math . max ( Math . floor ( this . #maxCount * 0.1 ) , 1 ) ) . changes )
409455 if ( removed ) {
410456 return removed
411457 }
0 commit comments