Skip to content

fix(cache): ignore SQLITE_BUSY in the sqlite cache store - #6049

Merged
mcollina merged 1 commit into
nodejs:mainfrom
isHeSatoshi:fix/sqlite-cache-store-busy
Oct 22, 2026
Merged

mcollina merged 1 commit into
nodejs:mainfrom
isHeSatoshi:fix/sqlite-cache-store-busy

Conversation

@isHeSatoshi

@isHeSatoshi isHeSatoshi commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

What was wrong

SqliteCacheStore is meant to be shared, but it does not tolerate SQLite's
contention errors. When another connection (another process, or another store
instance in the same process) is writing the same database file, node:sqlite
throws:

Error: database is locked
  code: 'ERR_SQLITE_ERROR'
  errcode: 5

That error escapes get(), set() and delete(), and it also escapes the
pruning that set() performs, which is what the report in #4124 shows:

at get size (.../sqlite-cache-store.js:298:47)
at #prune (.../sqlite-cache-store.js:270:14)
at SqliteCacheStore.set (.../sqlite-cache-store.js:238:16)
at CacheHandler.onComplete (.../lib/interceptor/cache.js:159:19)

So a transient lock in a shared cache database fails the request that triggered
the write, instead of just skipping the cache.

Root cause

SQLITE_BUSY (5) / SQLITE_LOCKED (6) are transient: they only mean another
connection currently holds the lock. The store has no handling for them, so
every statement is able to throw.

The change

lib/cache/sqlite-cache-store.js:

  • A small isLockedError() helper that recognises the two transient result
    codes (errcode 5 and 6), so unrelated SQLite errors still propagate.
  • A private #lockSafe() wrapper that runs a statement and turns a lock error
    into "operation skipped".
  • It is used for the statements that database is locked #4124 reports and the other statements on
    the same paths: the lookup in get(), the lookup/update/insert in set(),
    delete(), and the pruning statements (#prune() now skips pruning when the
    size cannot be read because the database is locked).

A locked database is therefore a cache miss and a skipped write; the next
request retries. This follows the direction of the discussion in #4124
("we should either yield and try again or just ignore the error ... I would
recommend the latter").

Verification

New regression test in test/cache-interceptor/sqlite-cache-store-tests.js: a
second DatabaseSync connection opens the same file and holds the write lock
with BEGIN IMMEDIATE, then set(), get() and delete() are called while
the lock is held, and a normal round-trip is checked after COMMIT.

Before the change:

$ NODE_OPTIONS=--experimental-sqlite node --test test/cache-interceptor/sqlite-cache-store-tests.js
not ok 11 - SqliteCacheStore ignores a database locked by another connection
  error: 'database is locked'
  code: 'ERR_SQLITE_ERROR'
# tests 16
# pass 15
# fail 1

After the change:

$ NODE_OPTIONS=--experimental-sqlite node --test test/cache-interceptor/sqlite-cache-store-tests.js
ok 11 - SqliteCacheStore ignores a database locked by another connection
# tests 16
# pass 16
# fail 0

Suites:

$ NODE_OPTIONS=--experimental-sqlite npx borp --timeout 180000 -p "test/cache-interceptor/*.js"
ℹ tests 86
ℹ pass 86
ℹ fail 0

$ npx borp --timeout 180000 -p "test/cache/*.js"
ℹ tests 5  pass 5  fail 0

$ npx borp --timeout 180000 -p "test/interceptors/*.js"
ℹ tests 482  pass 466  fail 0  skipped 16

$ npx eslint lib/cache/sqlite-cache-store.js test/cache-interceptor/sqlite-cache-store-tests.js
(no output)

Fixes #4124

AI assistance (disclosure): this change was prepared with an AI coding agent (Factory Droid) working in my checkout. Every result quoted above comes from actually running the commands shown, not from the agent's description. Happy to answer questions or rework anything.

The store is meant to be shared between processes. SQLite reports a
database another connection is writing as SQLITE_BUSY/SQLITE_LOCKED
("database is locked"), and those errors currently propagate out of
get(), set(), delete() and the pruning that set() performs, failing the
request that triggered the write.

Caching is best effort, so skip the operation instead of throwing: a
locked database is a cache miss, and the next request retries.
@codecov-commenter

codecov-commenter commented Oct 20, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.36364% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 91.56%. Comparing base (b777e40) to head (0b61c13).
⚠️ Report is 11 commits behind head on main.

Files with missing lines Patch % Lines
lib/cache/sqlite-cache-store.js 96.36% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #6049      +/-   ##
==========================================
- Coverage   91.56%   91.56%   -0.01%     
==========================================
  Files         113      113              
  Lines       43839    43883      +44     
==========================================
+ Hits        40143    40183      +40     
- Misses       3696     3700       +4     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@mcollina
mcollina merged commit b558824 into nodejs:main Oct 22, 2026
38 checks passed
@github-actions github-actions Bot mentioned this pull request Nov 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

database is locked

4 participants