Skip to content

fix(cache): stop quarantining a healthy database when the native module fails to load - #95

Merged
sjsyrek merged 1 commit into
mainfrom
fix/cache-native-load-failure
Jul 28, 2026
Merged

fix(cache): stop quarantining a healthy database when the native module fails to load#95
sjsyrek merged 1 commit into
mainfrom
fix/cache-native-load-failure

Conversation

@sjsyrek

@sjsyrek sjsyrek commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Summary

src/storage/cache.ts misclassified a native-module load failure as database corruption and responded by renaming the user's healthy cache.db aside and recreating an empty one. Reproduced with real data loss on 2026-07-27: a 1 MB, 2,646-entry cache that passed integrity_check was quarantined as cache.db.corrupt-<timestamp> when node_modules built for Node 24 (NODE_MODULE_VERSION 137) ran under Node 22 (127). This matters for the v2.0.0 release: Homebrew users get Node as a formula dependency, so brew upgrade node would have quarantined a healthy cache on every subsequent invocation. Closes the first gating issue of the v2.0.0 epic (beads sync-8mqh.1).

Changes Made

  • Added src/utils/native-module-error.ts — classifies load failures by error code (ERR_DLOPEN_FAILED, ERR_UNKNOWN_BUILTIN_MODULE, MODULE_NOT_FOUND, ERR_MODULE_NOT_FOUND) or a NODE_MODULE_VERSION message.
  • Updated CacheService constructor — load failures rethrow without touching the database or its -wal/-shm sidecars; genuine corruption still gets the existing rename-aside recovery.
  • Added src/cli/cache-loader.tscreateCacheServiceGetter() wraps the dynamic import + construction in a warn-once latch: on failure it emits one actionable warning ("Caching is disabled for this run…", reinstall/Node-version hint) and returns undefined; the failed import is not retried within the process.
  • Made the optional cache realTranslationService/WriteService no longer fall back to CacheService.getInstance() (which re-triggered the same crash); no cache now means running cacheless. service-factory.ts threads CacheService | undefined through.
  • Updated register-cache.tsdeepl cache stats/clear/enable/disable cannot run cacheless, so they fail with a ConfigError (exit 7) and a reinstall suggestion instead of a stack trace.
  • Updated docs/TROUBLESHOOTING.md — the NODE_MODULE_VERSION section now describes the degraded-but-working behavior instead of the old corruption symptom.

Test Coverage

  • tests/unit/cache-native-failure.test.ts — per error variant: constructor rethrows, DB bytes and sidecars untouched, no .corrupt-* created, no corruption warning; plus a genuine open failure still renames aside.
  • tests/unit/cache-loader.test.ts — import rejection → undefined with exactly one warning; latch does not re-import; construction failure after successful import degrades the same way.
  • tests/unit/translation-service.test.ts / write-service.test.ts — translate/batch/improve succeed with no cache while cache.enabled is true.
  • tests/unit/register-cache-registration.test.ts — all four cache subcommands report a clear error when the backend resolves undefined.
  • tests/e2e/cli-cache-degraded.e2e.test.ts — hijacks module resolution for better-sqlite3 in the CLI subprocess (node:module registerHooks) to simulate the ABI failure end-to-end: translate/write exit 0 with exactly one warning, the pre-seeded cache.db is byte-identical afterwards, cache stats fails actionably, and a healthy backend still caches silently.

Full suite: 235 suites / 5,527 tests green on Node 24.18.0; lint and type-check clean.

Backward Compatibility

Maintained: genuine corruption recovery (rename-aside + recreate) is unchanged, including the double-failure path. Cache hits/misses, TTL, eviction, and all cache subcommand behavior with a working backend are untouched.
Same behavior: deepl --version and metadata commands never load the cache (existing lazy-import design, unchanged).
Breaking changes: None. One internal semantic change: constructing TranslationService/WriteService without a cache now runs cacheless instead of implicitly grabbing the singleton — all production call sites always passed a cache explicitly.

Benefits

  • Data safety: a routine Node upgrade can no longer destroy a warm cache or litter ~1 MB .corrupt-* files on every invocation.
  • Resilience: translate and write — the two most important commands, neither of which needs a cache — survive an unavailable cache backend.
  • Release prerequisite: makes an unavailable cache backend non-fatal before the node:sqlite migration (sync-8mqh.2), and remains the safety net for installs on runtimes without node:sqlite.

Size: Medium ✓

~600 lines across 16 files, but the production diff is small and mechanical; most of the change is test coverage for the two failure classifications and the degraded paths.

🤖 Generated with Claude Code

…le fails to load

A native-module load failure (ERR_DLOPEN_FAILED after a Node major
upgrade, ERR_UNKNOWN_BUILTIN_MODULE on runtimes without node:sqlite,
NODE_MODULE_VERSION mismatch) was caught by the CacheService
constructor's corruption handler, which renamed the user's healthy
cache.db to cache.db.corrupt-<timestamp> and recreated an empty
database. Reproduced with real data loss: a 1MB, 2646-entry cache that
passed integrity_check was quarantined when node_modules built for
Node 24 ran under Node 22.

- classify load failures in CacheService and rethrow without touching
  the database or its -wal/-shm sidecars; genuine corruption still
  gets the rename-aside recovery
- load the cache behind a warn-once latch (cli/cache-loader.ts): on
  failure, warn once with an actionable hint and return undefined so
  translate/write run cacheless with exit code 0
- make the optional cache in TranslationService/WriteService real:
  no cache means cacheless, instead of falling back to
  CacheService.getInstance(), which re-triggered the same crash
- deepl cache subcommands, which cannot run cacheless, now fail with
  a ConfigError suggesting a reinstall or matching Node version

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
@sjsyrek
sjsyrek merged commit 5c6b770 into main Jul 28, 2026
2 checks passed
@sjsyrek
sjsyrek deleted the fix/cache-native-load-failure branch July 28, 2026 12:15
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.

1 participant