diff --git a/.secrets.baseline b/.secrets.baseline index 04bab19..20ad116 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -157,21 +157,14 @@ "filename": "packages/cachekit/src/backends/cachekitio.test.ts", "hashed_secret": "9242986caf890b01f4265ee595e88ee087af4b90", "is_verified": false, - "line_number": 19 + "line_number": 23 }, { "type": "Secret Keyword", "filename": "packages/cachekit/src/backends/cachekitio.test.ts", "hashed_secret": "a62f2225bf70bfaccbc7f1ef2a397836717377de", "is_verified": false, - "line_number": 45 - }, - { - "type": "Secret Keyword", - "filename": "packages/cachekit/src/backends/cachekitio.test.ts", - "hashed_secret": "310ca4c3a208b51df7a1d454bab0730b474e0d99", - "is_verified": false, - "line_number": 358 + "line_number": 49 } ], "packages/cachekit/src/intents.test.ts": [ diff --git a/packages/cachekit-core-ts/index.js b/packages/cachekit-core-ts/index.js index 2689bc4..143621a 100644 --- a/packages/cachekit-core-ts/index.js +++ b/packages/cachekit-core-ts/index.js @@ -3,7 +3,7 @@ // @ts-nocheck /* auto-generated by NAPI-RS */ -const { readFileSync } = require('node:fs') +const { readFileSync } = require('fs') let nativeBinding = null const loadErrors = [] @@ -33,7 +33,7 @@ const isMuslFromFilesystem = () => { const isMuslFromReport = () => { let report = null - if (typeof process.report?.getReport === 'function') { + if (process.report && typeof process.report.getReport === 'function') { process.report.excludeNetwork = true report = process.report.getReport() } @@ -77,8 +77,8 @@ function requireNative() { try { const binding = require('@cachekit-io/cachekit-core-ts-android-arm64') const bindingPackageVersion = require('@cachekit-io/cachekit-core-ts-android-arm64/package.json').version - if (bindingPackageVersion !== '0.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -93,8 +93,8 @@ function requireNative() { try { const binding = require('@cachekit-io/cachekit-core-ts-android-arm-eabi') const bindingPackageVersion = require('@cachekit-io/cachekit-core-ts-android-arm-eabi/package.json').version - if (bindingPackageVersion !== '0.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -105,7 +105,7 @@ function requireNative() { } } else if (process.platform === 'win32') { if (process.arch === 'x64') { - if (process.config?.variables?.shlib_suffix === 'dll.a' || process.config?.variables?.node_target_type === 'shared_library') { + if ((process.config && process.config.variables && process.config.variables.shlib_suffix === 'dll.a') || (process.config && process.config.variables && process.config.variables.node_target_type === 'shared_library')) { try { return require('./cachekit-core-ts.win32-x64-gnu.node') } catch (e) { @@ -114,8 +114,8 @@ function requireNative() { try { const binding = require('@cachekit-io/cachekit-core-ts-win32-x64-gnu') const bindingPackageVersion = require('@cachekit-io/cachekit-core-ts-win32-x64-gnu/package.json').version - if (bindingPackageVersion !== '0.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -130,8 +130,8 @@ function requireNative() { try { const binding = require('@cachekit-io/cachekit-core-ts-win32-x64-msvc') const bindingPackageVersion = require('@cachekit-io/cachekit-core-ts-win32-x64-msvc/package.json').version - if (bindingPackageVersion !== '0.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -147,8 +147,8 @@ function requireNative() { try { const binding = require('@cachekit-io/cachekit-core-ts-win32-ia32-msvc') const bindingPackageVersion = require('@cachekit-io/cachekit-core-ts-win32-ia32-msvc/package.json').version - if (bindingPackageVersion !== '0.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -163,8 +163,8 @@ function requireNative() { try { const binding = require('@cachekit-io/cachekit-core-ts-win32-arm64-msvc') const bindingPackageVersion = require('@cachekit-io/cachekit-core-ts-win32-arm64-msvc/package.json').version - if (bindingPackageVersion !== '0.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -182,8 +182,8 @@ function requireNative() { try { const binding = require('@cachekit-io/cachekit-core-ts-darwin-universal') const bindingPackageVersion = require('@cachekit-io/cachekit-core-ts-darwin-universal/package.json').version - if (bindingPackageVersion !== '0.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -198,8 +198,8 @@ function requireNative() { try { const binding = require('@cachekit-io/cachekit-core-ts-darwin-x64') const bindingPackageVersion = require('@cachekit-io/cachekit-core-ts-darwin-x64/package.json').version - if (bindingPackageVersion !== '0.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -214,8 +214,8 @@ function requireNative() { try { const binding = require('@cachekit-io/cachekit-core-ts-darwin-arm64') const bindingPackageVersion = require('@cachekit-io/cachekit-core-ts-darwin-arm64/package.json').version - if (bindingPackageVersion !== '0.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -234,8 +234,8 @@ function requireNative() { try { const binding = require('@cachekit-io/cachekit-core-ts-freebsd-x64') const bindingPackageVersion = require('@cachekit-io/cachekit-core-ts-freebsd-x64/package.json').version - if (bindingPackageVersion !== '0.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -250,8 +250,8 @@ function requireNative() { try { const binding = require('@cachekit-io/cachekit-core-ts-freebsd-arm64') const bindingPackageVersion = require('@cachekit-io/cachekit-core-ts-freebsd-arm64/package.json').version - if (bindingPackageVersion !== '0.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -271,8 +271,8 @@ function requireNative() { try { const binding = require('@cachekit-io/cachekit-core-ts-linux-x64-musl') const bindingPackageVersion = require('@cachekit-io/cachekit-core-ts-linux-x64-musl/package.json').version - if (bindingPackageVersion !== '0.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -287,8 +287,8 @@ function requireNative() { try { const binding = require('@cachekit-io/cachekit-core-ts-linux-x64-gnu') const bindingPackageVersion = require('@cachekit-io/cachekit-core-ts-linux-x64-gnu/package.json').version - if (bindingPackageVersion !== '0.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -305,8 +305,8 @@ function requireNative() { try { const binding = require('@cachekit-io/cachekit-core-ts-linux-arm64-musl') const bindingPackageVersion = require('@cachekit-io/cachekit-core-ts-linux-arm64-musl/package.json').version - if (bindingPackageVersion !== '0.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -321,8 +321,8 @@ function requireNative() { try { const binding = require('@cachekit-io/cachekit-core-ts-linux-arm64-gnu') const bindingPackageVersion = require('@cachekit-io/cachekit-core-ts-linux-arm64-gnu/package.json').version - if (bindingPackageVersion !== '0.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -339,8 +339,8 @@ function requireNative() { try { const binding = require('@cachekit-io/cachekit-core-ts-linux-arm-musleabihf') const bindingPackageVersion = require('@cachekit-io/cachekit-core-ts-linux-arm-musleabihf/package.json').version - if (bindingPackageVersion !== '0.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -355,8 +355,8 @@ function requireNative() { try { const binding = require('@cachekit-io/cachekit-core-ts-linux-arm-gnueabihf') const bindingPackageVersion = require('@cachekit-io/cachekit-core-ts-linux-arm-gnueabihf/package.json').version - if (bindingPackageVersion !== '0.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -373,8 +373,8 @@ function requireNative() { try { const binding = require('@cachekit-io/cachekit-core-ts-linux-loong64-musl') const bindingPackageVersion = require('@cachekit-io/cachekit-core-ts-linux-loong64-musl/package.json').version - if (bindingPackageVersion !== '0.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -389,8 +389,8 @@ function requireNative() { try { const binding = require('@cachekit-io/cachekit-core-ts-linux-loong64-gnu') const bindingPackageVersion = require('@cachekit-io/cachekit-core-ts-linux-loong64-gnu/package.json').version - if (bindingPackageVersion !== '0.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -407,8 +407,8 @@ function requireNative() { try { const binding = require('@cachekit-io/cachekit-core-ts-linux-riscv64-musl') const bindingPackageVersion = require('@cachekit-io/cachekit-core-ts-linux-riscv64-musl/package.json').version - if (bindingPackageVersion !== '0.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -423,8 +423,8 @@ function requireNative() { try { const binding = require('@cachekit-io/cachekit-core-ts-linux-riscv64-gnu') const bindingPackageVersion = require('@cachekit-io/cachekit-core-ts-linux-riscv64-gnu/package.json').version - if (bindingPackageVersion !== '0.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -440,8 +440,8 @@ function requireNative() { try { const binding = require('@cachekit-io/cachekit-core-ts-linux-ppc64-gnu') const bindingPackageVersion = require('@cachekit-io/cachekit-core-ts-linux-ppc64-gnu/package.json').version - if (bindingPackageVersion !== '0.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -456,8 +456,8 @@ function requireNative() { try { const binding = require('@cachekit-io/cachekit-core-ts-linux-s390x-gnu') const bindingPackageVersion = require('@cachekit-io/cachekit-core-ts-linux-s390x-gnu/package.json').version - if (bindingPackageVersion !== '0.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -476,8 +476,8 @@ function requireNative() { try { const binding = require('@cachekit-io/cachekit-core-ts-openharmony-arm64') const bindingPackageVersion = require('@cachekit-io/cachekit-core-ts-openharmony-arm64/package.json').version - if (bindingPackageVersion !== '0.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -492,8 +492,8 @@ function requireNative() { try { const binding = require('@cachekit-io/cachekit-core-ts-openharmony-x64') const bindingPackageVersion = require('@cachekit-io/cachekit-core-ts-openharmony-x64/package.json').version - if (bindingPackageVersion !== '0.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -508,8 +508,8 @@ function requireNative() { try { const binding = require('@cachekit-io/cachekit-core-ts-openharmony-arm') const bindingPackageVersion = require('@cachekit-io/cachekit-core-ts-openharmony-arm/package.json').version - if (bindingPackageVersion !== '0.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.1.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.1.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -525,23 +525,33 @@ function requireNative() { nativeBinding = requireNative() -if (!nativeBinding || process.env.NAPI_RS_FORCE_WASI) { +// NAPI_RS_FORCE_WASI is a tri-state flag: +// unset / any other value → native binding preferred, WASI is only a fallback +// 'true' → force WASI fallback even if native loaded +// 'error' → force WASI and throw if no WASI binding is found +// Treating any non-empty string as truthy (the historical behavior) meant +// NAPI_RS_FORCE_WASI=false, NAPI_RS_FORCE_WASI=0, etc. inadvertently triggered +// the WASI path, causing ENOENT for packages shipped without a .wasi.cjs file. +const forceWasi = + process.env.NAPI_RS_FORCE_WASI === 'true' || process.env.NAPI_RS_FORCE_WASI === 'error' + +if (!nativeBinding || forceWasi) { let wasiBinding = null let wasiBindingError = null try { wasiBinding = require('./cachekit-core-ts.wasi.cjs') nativeBinding = wasiBinding } catch (err) { - if (process.env.NAPI_RS_FORCE_WASI) { + if (forceWasi) { wasiBindingError = err } } - if (!nativeBinding || process.env.NAPI_RS_FORCE_WASI) { + if (!nativeBinding || forceWasi) { try { wasiBinding = require('@cachekit-io/cachekit-core-ts-wasm32-wasi') nativeBinding = wasiBinding } catch (err) { - if (process.env.NAPI_RS_FORCE_WASI) { + if (forceWasi) { if (!wasiBindingError) { wasiBindingError = err } else { @@ -560,17 +570,18 @@ if (!nativeBinding || process.env.NAPI_RS_FORCE_WASI) { if (!nativeBinding) { if (loadErrors.length > 0) { - throw new Error( + const error = new Error( `Cannot find native binding. ` + `npm has a bug related to optional dependencies (https://github.com/npm/cli/issues/4828). ` + 'Please try `npm i` again after removing both package-lock.json and node_modules directory.', - { - cause: loadErrors.reduce((err, cur) => { - cur.cause = err - return cur - }), - }, ) + // assign instead of the `new Error(message, { cause })` options form, + // which Node < 16.9 silently ignores + error.cause = loadErrors.reduce((err, cur) => { + cur.cause = err + return cur + }) + throw error } throw new Error(`Failed to load native binding`) } diff --git a/packages/cachekit/README.md b/packages/cachekit/README.md index 0c8d1b1..947270d 100644 --- a/packages/cachekit/README.md +++ b/packages/cachekit/README.md @@ -8,6 +8,7 @@ Production-ready Redis caching for TypeScript/Node.js. Hybrid TypeScript-Rust de - **Dual-layer caching**: L1 in-memory (~50ns) + pluggable L2 (Redis, CacheKit SaaS, Memcached, local File) - **Stale-while-revalidate**: Serve stale data while refreshing in background +- **Stampede protection**: Cold-miss single-flight per process (always on) + opt-in cross-process distributed locks - **Zero-knowledge encryption**: Optional AES-256-GCM client-side encryption - **Circuit breaker**: Automatic failure isolation with exponential backoff - **TypeScript-first**: Full type safety with strict mode @@ -133,6 +134,50 @@ const cache = createCache({ }); ``` +## Stampede Protection + +A cold cache key hit by N concurrent callers would normally execute the wrapped +function N times and issue N backend reads — on the metered-misses SaaS backend +that is N billed misses for one key. + +**In-process single-flight is always on**: concurrent `wrap()` calls for the +same cache key share one in-flight promise, so the herd costs one L2 read, one +compute, and one write. If the flight fails, every waiting caller receives the +same rejection and the next call retries fresh. + +**Cross-process locking is opt-in** via `stampede.distributedLock`, for fleets +where many processes can go cold on the same key simultaneously. It mirrors +cachekit-py's flow: acquire the backend lock, double-check L2, compute, write, +release. Contested processes retry the lock on an interval (never polling +`get()` — on metered-misses backends a poll against a still-cold key is itself +a billed miss) and fall through to computing after `lockWaitMs`; the lock is +best-effort mitigation, never a correctness gate, so lock-endpoint failures +degrade to an unlocked compute. + +```typescript +const cache = createCache({ + backend: { url: 'redis://localhost:6379' }, // Redis and CachekitIO backends support locks + stampede: { + distributedLock: true, // default false + lockTimeoutMs: 30000, // lock lease; size at/above expected recompute time + lockWaitMs: 5000, // max wait for the lock holder before computing anyway + lockPollMs: 100, // lock retry interval while contested + }, +}); +``` + +Requires a lock-capable backend: Redis, a `CachekitIOBackendConfig` (the SaaS +lock endpoint is selected automatically), `cachekitioWithLocking()`, or +`cachekitioFull()`. `createCache` throws `ConfigurationError` if +`distributedLock` is requested on a backend without `acquireLock`/`releaseLock`. + +There is deliberately no general admission-control cap beyond L1's +`maxConcurrentRefreshes`: on Node's single-threaded event loop concurrent +misses don't compete for threads, single-flight collapses the per-key herd, +and distinct-key miss floods are already bounded by backend timeouts plus the +circuit breaker. A global semaphore would add queueing latency without a +failure mode it prevents. + ## Backends Four backends implement the same `Backend` interface (raw bytes in/out) and plug into `createCache({ backend })` interchangeably: @@ -262,13 +307,35 @@ npm install prom-client ```typescript const cache = createCache({ backend: { url: 'redis://localhost:6379' }, - metrics: true, // Enable Prometheus metrics + metrics: true, // or { prefix: 'myapp', defaultLabels: { env: 'prod' }, registry } }); -// Metrics: cachekit_operations_total, cachekit_hits_total, cachekit_misses_total, -// cachekit_errors_total, cachekit_operation_duration_seconds +// Counters: cachekit_operations_total{operation,status}, cachekit_hits_total{layer}, +// cachekit_misses_total, cachekit_errors_total{error_type} +// Histogram: cachekit_operation_duration_seconds{operation} +// Gauges: cachekit_l1_entries, cachekit_l1_memory_bytes, cachekit_circuit_breaker_state ``` +If `metrics` is enabled but `prom-client` is not installed, the SDK reports the +failure once through the library logger and metrics degrade to no-ops — never +silently. (On Cloudflare Workers, where prom-client cannot run, the `metrics` +option degrades to a no-op the same way.) + +Internal error reporting (background refresh, invalidation channel, Redis +connection events) defaults to `console.error`; route it into your own logging +pipeline with `setLogger`: + +```typescript +import { setLogger } from '@cachekit-io/cachekit'; + +setLogger((message, error) => myLogger.warn({ error }, message)); +setLogger(null); // restore the default +``` + +With the CachekitIO backend, the `X-CacheKit-L1-*` telemetry headers are wired +automatically from the cache's live L1/L2 hit and miss counters; pass your own +`metricsProvider` in the backend config to override. + ## Cloudflare Workers The SDK ships a Workers-native entrypoint: `@cachekit-io/cachekit/workers` diff --git a/packages/cachekit/src/backends/cachekitio.test.ts b/packages/cachekit/src/backends/cachekitio.test.ts index b15e444..281263c 100644 --- a/packages/cachekit/src/backends/cachekitio.test.ts +++ b/packages/cachekit/src/backends/cachekitio.test.ts @@ -3,6 +3,10 @@ import { cachekitio } from './cachekitio-factory.js'; import type { Backend, CachekitIOBackendConfig } from './types.js'; import { BackendError, ConfigurationError, TimeoutError } from '../errors.js'; +// Deliberately fake credential for fixtures — obviously-fake value so secret +// scanners and reviewers never mistake it for a live key. +const FAKE_API_KEY = 'ck_test_fake-not-a-secret'; // pragma: allowlist secret + // Helper to create a mock Response function mockResponse( status: number, @@ -355,7 +359,7 @@ describe('CachekitIO Backend', () => { // This should not throw — it should detect apiKey and use cachekitio() const cache = createCache({ backend: { - apiKey: 'ck_test_xyz', + apiKey: FAKE_API_KEY, apiUrl: 'https://api.test.cachekit.io', allowCustomHost: true, }, @@ -369,5 +373,78 @@ describe('CachekitIO Backend', () => { await cache.close(); }); + + // LAB-517: the SaaS telemetry headers are auto-wired from the cache's + // live hit/miss counters — no user-supplied metricsProvider needed. + it('auto-wires X-CacheKit-L1-* telemetry headers from live cache counters', async () => { + const { createCache } = await import('../cache.js'); + + const cache = createCache({ + backend: { + apiKey: FAKE_API_KEY, + apiUrl: 'https://api.test.cachekit.io', + allowCustomHost: true, + }, + l1: { enabled: true }, + }); + + fetchSpy.mockResolvedValue(mockResponse(404)); + await cache.get('ns:first'); // miss + await cache.get('ns:second'); // second request sees the first miss + + const [, secondOpts] = fetchSpy.mock.calls[1] as [string, RequestInit]; + const headers = secondOpts.headers as Record; + expect(headers['X-CacheKit-L1-Status']).toBe('miss'); + expect(headers['X-CacheKit-Misses']).toBe('1'); + expect(headers['X-CacheKit-L1-Hits']).toBe('0'); + + await cache.close(); + }); + + it('reports L1 telemetry as disabled when the cache has no L1', async () => { + const { createCache } = await import('../cache.js'); + + const cache = createCache({ + backend: { + apiKey: FAKE_API_KEY, + apiUrl: 'https://api.test.cachekit.io', + allowCustomHost: true, + }, + l1: { enabled: false }, + }); + + fetchSpy.mockResolvedValueOnce(mockResponse(404)); + await cache.get('ns:key'); + + const [, opts] = fetchSpy.mock.calls[0] as [string, RequestInit]; + const headers = opts.headers as Record; + expect(headers['X-CacheKit-L1-Status']).toBe('disabled'); + + await cache.close(); + }); + + it('a user-supplied metricsProvider overrides the auto-wired one', async () => { + const { createCache } = await import('../cache.js'); + + const cache = createCache({ + backend: { + apiKey: FAKE_API_KEY, + apiUrl: 'https://api.test.cachekit.io', + allowCustomHost: true, + metricsProvider: () => ({ l1Hits: 42, l2Hits: 7, misses: 3, l1Enabled: true }), + }, + l1: { enabled: true }, + }); + + fetchSpy.mockResolvedValueOnce(mockResponse(404)); + await cache.get('ns:key'); + + const [, opts] = fetchSpy.mock.calls[0] as [string, RequestInit]; + const headers = opts.headers as Record; + expect(headers['X-CacheKit-L1-Hits']).toBe('42'); + expect(headers['X-CacheKit-Misses']).toBe('3'); + + await cache.close(); + }); }); }); diff --git a/packages/cachekit/src/backends/redis.ts b/packages/cachekit/src/backends/redis.ts index fa54c7e..7596c29 100644 --- a/packages/cachekit/src/backends/redis.ts +++ b/packages/cachekit/src/backends/redis.ts @@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto'; import { Redis as IoRedis, type RedisOptions } from 'ioredis'; import { LockableBackend, RedisBackendConfig, TTLBackend } from './types.js'; import { BackendError, TimeoutError } from '../errors.js'; +import { logError } from '../logger.js'; import { DEFAULT_TTL_SECONDS, DEFAULT_REDIS_CONNECT_TIMEOUT, @@ -99,8 +100,7 @@ export class RedisBackend implements LockableBackend, TTLBackend { }; const safeMessage = sanitize(err.message) || 'Unknown Redis error'; - // eslint-disable-next-line no-console -- Library intentionally uses console for error visibility - console.error(`[cachekit] Redis error: ${safeMessage}`); + logError(`[cachekit] Redis error: ${safeMessage}`); }); } diff --git a/packages/cachekit/src/cache-core.ts b/packages/cachekit/src/cache-core.ts index 3b3ffa8..6e0c18c 100644 --- a/packages/cachekit/src/cache-core.ts +++ b/packages/cachekit/src/cache-core.ts @@ -1,13 +1,17 @@ import type { CacheOptions, SetOptions, + StampedeConfig, WrapOptions, + WrapOptionsBase, SecureCache, EncryptionConfig, InvalidationConfig, } from './types/cache.js'; -import type { Backend } from './backends/types.js'; +import type { Backend, L1Metrics, LockableBackend } from './backends/types.js'; import type { InvalidationEvent } from './l1/types.js'; +import type { MetricsCollector, MetricsConfig } from './metrics/prometheus.js'; +import { logError } from './logger.js'; import { L1Cache } from './l1/lru-cache.js'; import { ReliabilityExecutor } from './reliability/executor.js'; import { BackgroundRefreshManager, type WaitUntil } from './cache/background-refresh.js'; @@ -25,7 +29,40 @@ import { } from './serialization/interop.js'; import { createInvalidationEvent } from './invalidation/event.js'; import { BackendError, ConfigurationError } from './errors.js'; -import { DEFAULT_TTL_SECONDS } from './constants.js'; +import { + DEFAULT_TTL_SECONDS, + DEFAULT_LOCK_TIMEOUT_MS, + DEFAULT_LOCK_WAIT_MS, + DEFAULT_LOCK_POLL_MS, +} from './constants.js'; + +/** + * Sentinel for "the lock path did not resolve the miss — compute without + * it". Distinct from null: the wrapped function may legitimately resolve + * null, and conflating the two would compute twice under a held lock. + */ +const LOCK_FALLTHROUGH = Symbol('cachekit.lock-fallthrough'); + +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Metrics fallback when the runtime supplies no collector (Workers, or + * metrics disabled). Duplicates NoopMetrics from metrics/prometheus.js + * deliberately: that module's graph reaches prom-client (Node-only), so + * cache-core may only import its types — a value import would trip the + * workers bundle guard. + */ +const NOOP_METRICS: MetricsCollector = { + async recordOperation() {}, + async recordHit() {}, + async recordMiss() {}, + async recordError() {}, + async startTimer() { + return () => {}; + }, + async updateL1Stats() {}, + async updateCircuitBreakerState() {}, +}; /** * ByteStorage envelope surface (LZ4 + xxHash3-64 + msgpack envelope). @@ -81,8 +118,27 @@ export type { WaitUntil }; * CachekitIO + wasm). Everything protocol-critical stays in CacheImpl. */ export interface CacheRuntime { - /** Resolve a backend config union to a Backend instance. */ - resolveBackend(config: CacheOptions['backend']): Backend; + /** + * Resolve a backend config union to a Backend instance. `stampede` carries + * the cold-miss protection config so a runtime can select a lock-capable + * variant (Node picks cachekitioWithLocking for apiKey configs when + * distributedLock is on); `l1Telemetry` lazily reads the cache's live + * hit/miss counters so a SaaS backend can auto-wire its telemetry headers. + * Runtimes without these conveniences may ignore both — users can still + * pass a fully-configured Backend instance directly. + */ + resolveBackend( + config: CacheOptions['backend'], + stampede?: StampedeConfig, + l1Telemetry?: () => L1Metrics + ): Backend; + /** + * Create the Prometheus metrics collector. Absent on platforms without + * prom-client (Workers) — the `metrics` option degrades to a no-op there, + * mirroring how the Node collector degrades when the optional prom-client + * peer dependency is missing. + */ + createMetrics?(config: MetricsConfig | undefined): MetricsCollector; /** Create the ByteStorage envelope codec. */ createByteStorage(): ByteStorageLike; /** Create the encryption manager for this platform's bindings. */ @@ -116,12 +172,71 @@ export class CacheImpl implements SecureCache { private readonly serializer: MessagePackSerializer; private readonly defaultTtl: number; private readonly invalidationChannel: InvalidationChannelLike | null = null; + private readonly metrics: MetricsCollector; + // Live hit/miss counters. Feed both the Prometheus collector and the SaaS + // X-CacheKit-L1-* telemetry headers (auto-wired via resolveBackend's + // l1Telemetry hook below). + private readonly telemetry = { l1Hits: 0, l2Hits: 0, misses: 0 }; private readonly swrRequiresWaitUntil: boolean; private closed = false; + /** One in-flight cold-miss resolution per cache key (single-flight, LAB-519). */ + private readonly inflight = new Map>(); + private readonly stampede: Required; + private readonly lockable: LockableBackend | null; constructor(options: CacheOptions, runtime: CacheRuntime) { - // Initialize backend - this.backend = runtime.resolveBackend(options.backend); + // Initialize backend. The telemetry getter reads `this` lazily (per + // request), so constructor field order is safe. + this.backend = runtime.resolveBackend(options.backend, options.stampede, () => ({ + ...this.telemetry, + l1Enabled: this.l1 !== null, + })); + + // Initialize metrics (Prometheus via the runtime; no-op when the + // platform has no collector or metrics are off) + const metricsOption = options.metrics ?? false; + this.metrics = + metricsOption !== false && runtime.createMetrics + ? runtime.createMetrics(typeof metricsOption === 'object' ? metricsOption : undefined) + : NOOP_METRICS; + + // Stampede config + lock capability. Duck-typed like cachekit-py's + // hasattr check: user-supplied Backend instances aren't required to + // declare the LockableBackend interface, only to implement it. + this.stampede = { + distributedLock: options.stampede?.distributedLock ?? false, + lockTimeoutMs: options.stampede?.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS, + lockWaitMs: options.stampede?.lockWaitMs ?? DEFAULT_LOCK_WAIT_MS, + lockPollMs: options.stampede?.lockPollMs ?? DEFAULT_LOCK_POLL_MS, + }; + if (!Number.isFinite(this.stampede.lockTimeoutMs) || this.stampede.lockTimeoutMs <= 0) { + throw new ConfigurationError( + `stampede.lockTimeoutMs must be > 0, got ${this.stampede.lockTimeoutMs}` + ); + } + if (!Number.isFinite(this.stampede.lockPollMs) || this.stampede.lockPollMs <= 0) { + throw new ConfigurationError( + `stampede.lockPollMs must be > 0, got ${this.stampede.lockPollMs}` + ); + } + if (!Number.isFinite(this.stampede.lockWaitMs) || this.stampede.lockWaitMs < 0) { + throw new ConfigurationError( + `stampede.lockWaitMs must be >= 0, got ${this.stampede.lockWaitMs}` + ); + } + const maybeLockable = this.backend as Partial; + this.lockable = + typeof maybeLockable.acquireLock === 'function' && + typeof maybeLockable.releaseLock === 'function' + ? (this.backend as LockableBackend) + : null; + if (this.stampede.distributedLock && !this.lockable) { + throw new ConfigurationError( + 'stampede.distributedLock requires a backend with lock capability ' + + '(Redis, cachekitioWithLocking, or cachekitioFull) — the configured backend ' + + 'has no acquireLock/releaseLock' + ); + } // Initialize L1 cache if (options.l1?.enabled !== false) { @@ -186,13 +301,81 @@ export class CacheImpl implements SecureCache { // Start the channel (fire-and-forget, channel handles errors internally) channel.start().catch((err) => { - // eslint-disable-next-line no-console - console.error('[cachekit] Failed to start invalidation channel:', err); + logError('[cachekit] Failed to start invalidation channel:', err); }); return channel; } + // ── Metrics recording ───────────────────────────────────── + // MetricsCollector methods never reject (errors route to its handler), so + // fire-and-forget `void` keeps them off the hot path. + + private recordHit(layer: 'l1' | 'l2'): void { + if (layer === 'l1') this.telemetry.l1Hits++; + else this.telemetry.l2Hits++; + void this.metrics.recordHit(layer); + } + + private recordMiss(): void { + this.telemetry.misses++; + void this.metrics.recordMiss(); + } + + private recordFailure(operation: string, error: unknown): void { + void this.metrics.recordOperation(operation, 'error'); + void this.metrics.recordError(error instanceof Error ? error.constructor.name : 'Unknown'); + } + + private publishL1Stats(): void { + if (!this.l1) return; + const stats = this.l1.stats; + void this.metrics.updateL1Stats(stats.entries, stats.memoryUsed); + } + + /** + * Run an operation through the reliability stack, then publish the + * circuit-breaker state gauge (state transitions happen inside execute). + */ + private async execute(operation: () => Promise, fallback: T): Promise { + try { + return await this.reliabilityExecutor.execute(operation, fallback); + } finally { + const state = this.reliabilityExecutor.getCircuitBreakerState(); + if (state !== null) void this.metrics.updateCircuitBreakerState(state); + } + } + + /** + * Instrument one backend attempt: duration histogram + operations counter + * by status + errors counter. Wraps the operation closure (inside retry), + * so each retry attempt counts as one operation — the honest reading of + * `operations_total`. + */ + private async instrument(operation: string, fn: () => Promise): Promise { + const endTimer = await this.metrics.startTimer(operation); + try { + const result = await fn(); + void this.metrics.recordOperation(operation, 'success'); + return result; + } catch (error) { + this.recordFailure(operation, error); + throw error; + } finally { + endTimer(); + } + } + + /** + * Route a backend op through instrumentation (timer + op/error counters, + * inside retry so each attempt counts once) and the reliability stack + * (retry + circuit breaker, publishing the CB gauge). Every public op goes + * through here so none can silently drift out of the metrics set. + */ + private run(operation: string, fallback: T, fn: () => Promise): Promise { + return this.execute(() => this.instrument(operation, fn), fallback); + } + async get(key: string): Promise { return this.getEntry(key, false); } @@ -211,6 +394,7 @@ export class CacheImpl implements SecureCache { if (this.l1) { const l1Result = this.l1.get(key); if (l1Result !== null) { + this.recordHit('l1'); return l1Result as T; } } @@ -218,9 +402,12 @@ export class CacheImpl implements SecureCache { const useEnvelope = !interop && this.byteStorage !== null; // Fetch from L2 (backend) - const operation = async (): Promise => { + return this.run('get', null, async (): Promise => { const data = await this.backend.get(key); - if (data === null) return null; + if (data === null) { + this.recordMiss(); + return null; + } // Decrypt if encryption enabled let plaintext = data; @@ -244,12 +431,12 @@ export class CacheImpl implements SecureCache { if (this.l1) { const namespace = interop ? key.slice(0, key.indexOf(':')) : extractNamespace(key); this.l1.set(key, value, (ttlSeconds ?? this.defaultTtl) * 1000, namespace); + this.publishL1Stats(); } + this.recordHit('l2'); return value; - }; - - return this.reliabilityExecutor.execute(operation, null); + }); } async set(key: string, value: T, options?: SetOptions): Promise { @@ -288,7 +475,7 @@ export class CacheImpl implements SecureCache { // (existing degrade semantics unchanged). const interopSerialized = interop ? encodeInteropValue(value) : null; - const operation = async (): Promise => { + return this.run('set', undefined, async (): Promise => { // Serialize const serialized = interopSerialized ?? this.serializer.encode(value); @@ -314,28 +501,26 @@ export class CacheImpl implements SecureCache { // have). if (updateL1 && this.l1) { this.l1.set(key, value, ttl * 1000, namespace); + this.publishL1Stats(); } - }; - - await this.reliabilityExecutor.execute(operation, undefined); + }); } async delete(key: string): Promise { this.ensureNotClosed(); - const operation = async (): Promise => { + return this.run('delete', false, async (): Promise => { // Delete from backend const deleted = await this.backend.delete(key); // Invalidate L1 if (this.l1) { this.l1.invalidateByKey(key); + this.publishL1Stats(); } return deleted; - }; - - return this.reliabilityExecutor.execute(operation, false); + }); } async exists(key: string): Promise { @@ -345,11 +530,12 @@ export class CacheImpl implements SecureCache { if (this.l1) { const l1Value = this.l1.get(key); if (l1Value !== null) { + this.recordHit('l1'); return true; } } - return this.reliabilityExecutor.execute(() => this.backend.exists(key), false); + return this.run('exists', false, () => this.backend.exists(key)); } /** @@ -474,6 +660,7 @@ export class CacheImpl implements SecureCache { if (this.swrRequiresWaitUntil && !waitUntil) { const l1Value = this.l1.get(cacheKey); if (l1Value !== null) { + this.recordHit('l1'); return l1Value as TResult; } } else { @@ -500,28 +687,129 @@ export class CacheImpl implements SecureCache { waitUntil ); } + this.recordHit('l1'); return swrResult.value as TResult; } } } - // Check L2 - const cached = await this.getEntry(cacheKey, interop, options.ttl); - if (cached !== null) { - return cached; + // Cold path (L1 miss): single-flight per key per process. The flight + // covers the L2 read too, not just the compute — the L2 GET-miss is + // the billed event under metered-misses, so N concurrent cold callers + // must share one read, one compute, and one write (LAB-519). + const existing = this.inflight.get(cacheKey); + if (existing) { + return existing as Promise; + } + const flight = this.resolveMiss(cacheKey, interop, () => fn(...args), options); + this.inflight.set(cacheKey, flight); + try { + return await flight; + } finally { + this.inflight.delete(cacheKey); } + }; + } - // Compute and cache - const result = await fn(...args); - await this.setEntry( - cacheKey, - result, - { ttl: options.ttl, namespace: options.namespace }, - interop - ); + /** + * Cold-path resolution shared by every concurrent caller of one key: + * L2 read, then compute + write, optionally bracketed by a distributed + * lock when the backend supports it and stampede.distributedLock is on. + */ + private async resolveMiss( + cacheKey: string, + interop: boolean, + compute: () => Promise, + options: WrapOptionsBase + ): Promise { + const cached = await this.getEntry(cacheKey, interop, options.ttl); + if (cached !== null) { + return cached; + } - return result; - }; + if (this.lockable && this.stampede.distributedLock) { + const locked = await this.resolveUnderLock(cacheKey, interop, compute, options); + if (locked !== LOCK_FALLTHROUGH) { + return locked; + } + } + + return this.computeAndStore(cacheKey, interop, compute, options); + } + + /** + * Cross-process miss arbitration, mirroring cachekit-py's acquire_lock + * flow (wrapper.py): acquire → double-check L2 → compute → write → + * release. acquireLock never blocks on contention (LAB-240), so + * "waiting" is retrying the lock on an interval bounded by lockWaitMs — + * deliberately NOT polling get(), because on a metered-misses backend + * every poll GET against a still-cold key is itself a billed miss, + * while contested lock calls are not. + * + * Returns LOCK_FALLTHROUGH when the lock never resolved the miss + * (acquire error, or contested past the wait budget): the lease is + * best-effort stampede mitigation, never a correctness gate, so lock + * failure degrades to computing without it. + */ + private async resolveUnderLock( + cacheKey: string, + interop: boolean, + compute: () => Promise, + options: WrapOptionsBase + ): Promise { + const lockable = this.lockable!; + const { lockTimeoutMs, lockWaitMs, lockPollMs } = this.stampede; + const deadline = Date.now() + lockWaitMs; + + for (;;) { + let lockId: string | null; + try { + // Deliberately outside the reliability executor: retry would stack + // latency onto a best-effort call, and counting lock failures + // against the circuit breaker could open it for data operations. + lockId = await lockable.acquireLock(cacheKey, lockTimeoutMs); + } catch { + return LOCK_FALLTHROUGH; + } + + if (lockId !== null) { + try { + // Double-check: the holder we waited on (or a racing process) + // may have written between our miss and this grant — one GET + // that hits, instead of a duplicate compute + write. + const filled = await this.getEntry(cacheKey, interop, options.ttl); + if (filled !== null) { + return filled; + } + return await this.computeAndStore(cacheKey, interop, compute, options); + } finally { + // Best-effort: the lease auto-expires, and a failed release must + // not mask the compute result. + lockable.releaseLock(cacheKey, lockId).catch(() => {}); + } + } + + if (Date.now() + lockPollMs > deadline) { + return LOCK_FALLTHROUGH; + } + await sleep(lockPollMs); + } + } + + private async computeAndStore( + cacheKey: string, + interop: boolean, + compute: () => Promise, + options: WrapOptionsBase + ): Promise { + const result = await compute(); + await this.setEntry( + cacheKey, + result, + { ttl: options.ttl, namespace: options.namespace }, + interop + ); + return result; } with( @@ -603,6 +891,7 @@ export class CacheImpl implements SecureCache { } break; } + this.publishL1Stats(); } // Invalidate L2 for params-level (key deletion) @@ -645,6 +934,11 @@ export class CacheImpl implements SecureCache { // Stop background refresh manager (clears in-flight refreshes) attempt(() => this.backgroundRefresh.close()); + // Drop single-flight registrations (in-flight promises settle on their + // own; callers already awaiting them get the result or a closed-backend + // error) + attempt(() => this.inflight.clear()); + // Stop invalidation channel if (this.invalidationChannel) { try { diff --git a/packages/cachekit/src/cache.metrics.test.ts b/packages/cachekit/src/cache.metrics.test.ts new file mode 100644 index 0000000..5526376 --- /dev/null +++ b/packages/cachekit/src/cache.metrics.test.ts @@ -0,0 +1,246 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { Registry } from 'prom-client'; +import { createCache } from './cache.js'; +import type { SecureCache } from './types/cache.js'; +import type { Backend } from './backends/types.js'; + +/** + * LAB-517 regression tests: `metrics` must actually work, not be silently + * accepted. These drive the REAL prom-client (devDependency) through + * CacheImpl end-to-end — no mocks on the metrics path. + */ + +class InMemoryBackend implements Backend { + store = new Map(); + async get(key: string): Promise { + return this.store.get(key) ?? null; + } + async set(key: string, value: Uint8Array): Promise { + this.store.set(key, value); + } + async delete(key: string): Promise { + return this.store.delete(key); + } + async exists(key: string): Promise { + return this.store.has(key); + } + async close(): Promise {} +} + +class FailingBackend implements Backend { + async get(): Promise { + throw new Error('backend down'); + } + async set(): Promise { + throw new Error('backend down'); + } + async delete(): Promise { + throw new Error('backend down'); + } + async exists(): Promise { + throw new Error('backend down'); + } + async close(): Promise {} +} + +async function metricValue( + registry: Registry, + name: string, + labels?: Record +): Promise { + const metrics = await registry.getMetricsAsJSON(); + return metrics + .flatMap((m) => + m.values.map((v) => ({ + value: v.value, + labels: v.labels, + // prom-client emits metricName on histogram sub-series + // (_count/_sum/_bucket) at runtime but omits it from MetricValue's + // declared type — narrow only that one optional field. + seriesName: (v as { metricName?: string }).metricName ?? m.name, + })) + ) + .filter((v) => v.seriesName === name) + .filter((v) => !labels || Object.entries(labels).every(([k, val]) => v.labels[k] === val)) + .reduce((sum, v) => sum + v.value, 0); +} + +describe('Cache metrics wiring (LAB-517)', () => { + const caches: SecureCache[] = []; + + function makeCache( + registry: Registry, + overrides: Partial[0]> = {} + ): SecureCache { + const cache = createCache({ + backend: new InMemoryBackend(), + defaultTtl: 3600, + l1: { enabled: true, maxEntries: 100 }, + metrics: { registry }, + ...overrides, + }); + caches.push(cache); + return cache; + } + + afterEach(async () => { + await Promise.all(caches.splice(0).map((c) => c.close())); + }); + + it('records misses and get operations', async () => { + const registry = new Registry(); + const cache = makeCache(registry); + + expect(await cache.get('ns:missing')).toBeNull(); + + await vi.waitFor(async () => { + expect(await metricValue(registry, 'cachekit_misses_total')).toBe(1); + expect( + await metricValue(registry, 'cachekit_operations_total', { + operation: 'get', + status: 'success', + }) + ).toBe(1); + }); + }); + + it('records l1 hits after set, and l2 hits when L1 is disabled', async () => { + const registry = new Registry(); + const cache = makeCache(registry); + await cache.set('ns:key', 'value'); + expect(await cache.get('ns:key')).toBe('value'); // L1 hit + + const registry2 = new Registry(); + const noL1 = makeCache(registry2, { l1: { enabled: false } }); + await noL1.set('ns:key', 'value'); + expect(await noL1.get('ns:key')).toBe('value'); // L2 hit + + await vi.waitFor(async () => { + expect(await metricValue(registry, 'cachekit_hits_total', { layer: 'l1' })).toBe(1); + expect(await metricValue(registry2, 'cachekit_hits_total', { layer: 'l2' })).toBe(1); + }); + }); + + it('records set/delete operations and observes durations', async () => { + const registry = new Registry(); + const cache = makeCache(registry); + + await cache.set('ns:key', 'value'); + await cache.delete('ns:key'); + + await vi.waitFor(async () => { + expect( + await metricValue(registry, 'cachekit_operations_total', { + operation: 'set', + status: 'success', + }) + ).toBe(1); + expect( + await metricValue(registry, 'cachekit_operations_total', { + operation: 'delete', + status: 'success', + }) + ).toBe(1); + expect( + await metricValue(registry, 'cachekit_operation_duration_seconds_count', { + operation: 'set', + }) + ).toBe(1); + }); + }); + + it('records errors when the backend fails (degradation still swallows)', async () => { + const registry = new Registry(); + const cache = makeCache(registry, { backend: new FailingBackend(), l1: { enabled: false } }); + + // Degradation (default on) returns the fallback instead of throwing + expect(await cache.get('ns:key')).toBeNull(); + + await vi.waitFor(async () => { + expect(await metricValue(registry, 'cachekit_errors_total', { error_type: 'Error' })).toBe(1); + expect( + await metricValue(registry, 'cachekit_operations_total', { + operation: 'get', + status: 'error', + }) + ).toBe(1); + }); + }); + + it('updates L1 gauges and circuit-breaker gauge', async () => { + const registry = new Registry(); + const cache = makeCache(registry, { + reliability: { circuitBreaker: { failureThreshold: 5 } }, + }); + + await cache.set('ns:a', 'value'); + await cache.set('ns:b', 'value'); + + await vi.waitFor(async () => { + expect(await metricValue(registry, 'cachekit_l1_entries')).toBe(2); + expect(await metricValue(registry, 'cachekit_l1_memory_bytes')).toBeGreaterThan(0); + // closed = 0; the gauge exists with a recorded value + const metrics = await registry.getMetricsAsJSON(); + const cb = metrics.find((m) => m.name === 'cachekit_circuit_breaker_state'); + expect(cb).toBeDefined(); + expect(cb?.values[0]?.value).toBe(0); + }); + }); + + it('records hits and misses through wrap(), including the L1 SWR path', async () => { + const registry = new Registry(); + const cache = makeCache(registry); + + const fn = vi.fn(async (id: number) => ({ id })); + const wrapped = cache.wrap(fn, { namespace: 'users:get', ttl: 60 }); + + await wrapped(1); // miss → compute → set + await wrapped(1); // L1 hit via SWR path + + await vi.waitFor(async () => { + expect(await metricValue(registry, 'cachekit_misses_total')).toBe(1); + expect(await metricValue(registry, 'cachekit_hits_total', { layer: 'l1' })).toBe(1); + }); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('two caches sharing a registry reuse metrics instead of throwing on duplicates', async () => { + const registry = new Registry(); + const cacheA = makeCache(registry); + const cacheB = makeCache(registry); + + await cacheA.get('ns:miss-a'); + await cacheB.get('ns:miss-b'); + + await vi.waitFor(async () => { + // Both misses land on the shared counter — second init did not fail + expect(await metricValue(registry, 'cachekit_misses_total')).toBe(2); + }); + }); + + it('supports a custom prefix', async () => { + const registry = new Registry(); + const cache = makeCache(registry, { metrics: { registry, prefix: 'myapp' } }); + + await cache.get('ns:missing'); + + await vi.waitFor(async () => { + expect(await metricValue(registry, 'myapp_misses_total')).toBe(1); + }); + }); + + it('metrics disabled (default) records nothing and touches no registry', async () => { + const registry = new Registry(); + const cache = createCache({ + backend: new InMemoryBackend(), + l1: { enabled: true }, + }); + caches.push(cache); + + await cache.set('ns:key', 'value'); + await cache.get('ns:key'); + await cache.get('ns:missing'); + + expect(await registry.getMetricsAsJSON()).toHaveLength(0); + }); +}); diff --git a/packages/cachekit/src/cache.single-flight.test.ts b/packages/cachekit/src/cache.single-flight.test.ts new file mode 100644 index 0000000..7ac3959 --- /dev/null +++ b/packages/cachekit/src/cache.single-flight.test.ts @@ -0,0 +1,293 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { createCache } from './cache.js'; +import { ConfigurationError } from './errors.js'; +import type { SecureCache } from './types/cache.js'; +import type { Backend, LockableBackend } from './backends/types.js'; + +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * In-memory backend with operation counters. `gets` on a cold key is the + * billed-miss count under metered-misses — the number these tests exist to + * pin down (LAB-519). + */ +class CountingBackend implements Backend { + store = new Map(); + gets = 0; + sets = 0; + + async get(key: string): Promise { + this.gets++; + return this.store.get(key) ?? null; + } + + async set(key: string, value: Uint8Array, _ttl?: number): Promise { + this.sets++; + this.store.set(key, value); + } + + async delete(key: string): Promise { + return this.store.delete(key); + } + + async exists(key: string): Promise { + return this.store.has(key); + } + + async close(): Promise { + // Shared between cache instances in cross-process tests — keep the store. + } +} + +/** Lockable variant: single-holder lock table, compare-and-delete release. */ +class CountingLockableBackend extends CountingBackend implements LockableBackend { + acquires = 0; + releases = 0; + private readonly locks = new Map(); + + async acquireLock(key: string, _timeoutMs?: number): Promise { + this.acquires++; + if (this.locks.has(key)) return null; + const lockId = `lock-${this.acquires}`; + this.locks.set(key, lockId); + return lockId; + } + + async releaseLock(key: string, lockId: string): Promise { + this.releases++; + if (this.locks.get(key) !== lockId) return false; + this.locks.delete(key); + return true; + } + + heldLocks(): number { + return this.locks.size; + } +} + +describe('Cold-miss single-flight (LAB-519)', () => { + const caches: SecureCache[] = []; + + const make = (options: Parameters[0]): SecureCache => { + const cache = createCache(options); + caches.push(cache); + return cache; + }; + + afterEach(async () => { + await Promise.all(caches.splice(0).map((c) => c.close())); + }); + + describe('in-process', () => { + it('shares one L2 read, one compute, and one write across a concurrent cold herd', async () => { + const backend = new CountingBackend(); + const cache = make({ backend, l1: { enabled: true } }); + + let calls = 0; + const wrapped = cache.wrap( + async (id: number) => { + calls++; + await sleep(20); + return `value-${id}`; + }, + { namespace: 'sf:herd', ttl: 60 } + ); + + const results = await Promise.all(Array.from({ length: 10 }, () => wrapped(1))); + + expect(results).toEqual(Array.from({ length: 10 }, () => 'value-1')); + expect(calls).toBe(1); + expect(backend.gets).toBe(1); // 1 billed miss, not 10 + expect(backend.sets).toBe(1); + }); + + it('does not share flights across distinct keys', async () => { + const backend = new CountingBackend(); + const cache = make({ backend, l1: { enabled: true } }); + + let calls = 0; + const wrapped = cache.wrap( + async (id: number) => { + calls++; + await sleep(10); + return id; + }, + { namespace: 'sf:keys', ttl: 60 } + ); + + const [a, b] = await Promise.all([wrapped(1), wrapped(2)]); + + expect(a).toBe(1); + expect(b).toBe(2); + expect(calls).toBe(2); + }); + + it('evicts the flight after it settles so later calls read the cache', async () => { + const backend = new CountingBackend(); + const cache = make({ backend, l1: { enabled: false } }); + + let calls = 0; + const wrapped = cache.wrap( + async () => { + calls++; + return 'once'; + }, + { namespace: 'sf:settle', ttl: 60 } + ); + + await wrapped(); + const second = await wrapped(); + + expect(second).toBe('once'); + expect(calls).toBe(1); + expect(backend.gets).toBe(2); // second call is its own flight: L2 hit + }); + + it('rejects every herd member on failure, then retries fresh on the next call', async () => { + const backend = new CountingBackend(); + const cache = make({ backend, l1: { enabled: true } }); + + let calls = 0; + const wrapped = cache.wrap( + async () => { + calls++; + await sleep(10); + if (calls === 1) throw new Error('upstream down'); + return 'recovered'; + }, + { namespace: 'sf:err', ttl: 60 } + ); + + const herd = await Promise.allSettled(Array.from({ length: 5 }, () => wrapped())); + + expect(herd.every((r) => r.status === 'rejected')).toBe(true); + expect(calls).toBe(1); // one shared failure, not five + + // Rejected flight must not be cached: next call recomputes. + await expect(wrapped()).resolves.toBe('recovered'); + expect(calls).toBe(2); + }); + }); + + describe('cross-process distributed lock (opt-in)', () => { + const lockOptions = { distributedLock: true, lockWaitMs: 2000, lockPollMs: 10 }; + + it('contested process waits on the lock and picks up the winner’s write instead of recomputing', async () => { + // Two cache instances sharing one backend = two processes sharing L2. + const backend = new CountingLockableBackend(); + const cache1 = make({ backend, l1: { enabled: false }, stampede: lockOptions }); + const cache2 = make({ backend, l1: { enabled: false }, stampede: lockOptions }); + + let calls = 0; + const slow = async (id: number): Promise => { + calls++; + await sleep(100); + return `result-${id}`; + }; + const w1 = cache1.wrap(slow, { namespace: 'sf:fleet', ttl: 60 }); + const w2 = cache2.wrap(slow, { namespace: 'sf:fleet', ttl: 60 }); + + const [a, b] = await Promise.all([w1(7), w2(7)]); + + expect(a).toBe('result-7'); + expect(b).toBe('result-7'); + expect(calls).toBe(1); // loser's post-grant double-check hit the winner's write + expect(backend.sets).toBe(1); + expect(backend.heldLocks()).toBe(0); // both sides released + expect(backend.releases).toBeGreaterThanOrEqual(1); + }); + + it('computes anyway when the holder never fills the cache (stampede fallthrough)', async () => { + class NeverGrantsBackend extends CountingBackend implements LockableBackend { + async acquireLock(): Promise { + return null; // permanently contested (e.g. holder crashed, lease outlives our wait) + } + async releaseLock(): Promise { + return false; + } + } + + const backend = new NeverGrantsBackend(); + const cache = make({ + backend, + l1: { enabled: false }, + stampede: { distributedLock: true, lockWaitMs: 100, lockPollMs: 20 }, + }); + + let calls = 0; + const wrapped = cache.wrap( + async () => { + calls++; + return 'computed-anyway'; + }, + { namespace: 'sf:fallthrough', ttl: 60 } + ); + + await expect(wrapped()).resolves.toBe('computed-anyway'); + expect(calls).toBe(1); + }); + + it('degrades to computing without the lock when acquireLock throws', async () => { + class BrokenLockBackend extends CountingBackend implements LockableBackend { + async acquireLock(): Promise { + throw new Error('lock endpoint unreachable'); + } + async releaseLock(): Promise { + return false; + } + } + + const backend = new BrokenLockBackend(); + const cache = make({ backend, l1: { enabled: false }, stampede: lockOptions }); + + let calls = 0; + const wrapped = cache.wrap( + async () => { + calls++; + return 'still-works'; + }, + { namespace: 'sf:broken-lock', ttl: 60 } + ); + + await expect(wrapped()).resolves.toBe('still-works'); + expect(calls).toBe(1); + expect(backend.sets).toBe(1); + }); + + it('releases the lock even when the compute throws', async () => { + const backend = new CountingLockableBackend(); + const cache = make({ backend, l1: { enabled: false }, stampede: lockOptions }); + + const wrapped = cache.wrap( + async () => { + throw new Error('compute failed'); + }, + { namespace: 'sf:release-on-error', ttl: 60 } + ); + + await expect(wrapped()).rejects.toThrow('compute failed'); + expect(backend.heldLocks()).toBe(0); + }); + + it('rejects distributedLock on a backend without lock capability', () => { + expect(() => + createCache({ + backend: new CountingBackend(), + stampede: { distributedLock: true }, + }) + ).toThrow(ConfigurationError); + }); + + it('rejects invalid stampede timing config', () => { + expect(() => + createCache({ backend: new CountingBackend(), stampede: { lockPollMs: 0 } }) + ).toThrow(ConfigurationError); + expect(() => + createCache({ backend: new CountingBackend(), stampede: { lockTimeoutMs: -1 } }) + ).toThrow(ConfigurationError); + expect(() => + createCache({ backend: new CountingBackend(), stampede: { lockWaitMs: Infinity } }) + ).toThrow(ConfigurationError); + }); + }); +}); diff --git a/packages/cachekit/src/cache.ts b/packages/cachekit/src/cache.ts index 136a5c7..075f3b2 100644 --- a/packages/cachekit/src/cache.ts +++ b/packages/cachekit/src/cache.ts @@ -1,7 +1,13 @@ -import type { CacheOptions, SecureCache, InvalidationConfig } from './types/cache.js'; -import type { RedisBackendConfig, CachekitIOBackendConfig } from './backends/types.js'; +import type { + CacheOptions, + SecureCache, + InvalidationConfig, + StampedeConfig, +} from './types/cache.js'; +import type { L1Metrics, RedisBackendConfig, CachekitIOBackendConfig } from './backends/types.js'; import { redis } from './backends/redis.js'; -import { cachekitio } from './backends/cachekitio-factory.js'; +import { cachekitio, cachekitioWithLocking } from './backends/cachekitio-factory.js'; +import { createMetrics } from './metrics/prometheus.js'; import { EncryptionManager } from './encryption/manager.js'; import { ByteStorage } from '@cachekit-io/cachekit-core-ts'; import { RedisInvalidationChannel } from './invalidation/redis-channel.js'; @@ -14,15 +20,32 @@ import { CacheImpl, type CacheRuntime } from './cache-core.js'; * everything protocol-critical is shared in cache-core.ts. */ const nodeRuntime: CacheRuntime = { - resolveBackend(config: CacheOptions['backend']) { + resolveBackend( + config: CacheOptions['backend'], + stampede?: StampedeConfig, + l1Telemetry?: () => L1Metrics + ) { if ('get' in config) { return config; } if ('apiKey' in config) { - return cachekitio(config as CachekitIOBackendConfig); + const ioConfig = config as CachekitIOBackendConfig; + // Auto-wire the SaaS telemetry headers from the cache's live hit/miss + // counters; an explicit user-supplied provider still wins. + const withTelemetry: CachekitIOBackendConfig = { + ...ioConfig, + metricsProvider: ioConfig.metricsProvider ?? l1Telemetry, + }; + // The plain SaaS factory has no lock capability; the distributedLock + // opt-in selects the lockable wrapper so config-based users aren't + // forced to construct the backend by hand. + return stampede?.distributedLock + ? cachekitioWithLocking(withTelemetry) + : cachekitio(withTelemetry); } return redis(config as RedisBackendConfig); }, + createMetrics: (config) => createMetrics(true, config), createByteStorage: () => new ByteStorage(), createEncryption: (config) => new EncryptionManager(config.masterKey, config.tenantId), createInvalidationChannel: (config: InvalidationConfig) => diff --git a/packages/cachekit/src/cache/background-refresh.ts b/packages/cachekit/src/cache/background-refresh.ts index a0757c0..db14c84 100644 --- a/packages/cachekit/src/cache/background-refresh.ts +++ b/packages/cachekit/src/cache/background-refresh.ts @@ -1,4 +1,5 @@ import type { L1Cache } from '../l1/lru-cache.js'; +import { logError } from '../logger.js'; /** * Registers a background promise with the platform so it survives past the @@ -85,8 +86,7 @@ export class BackgroundRefreshManager { } } catch (error) { // Log error for observability - // eslint-disable-next-line no-console - console.error( + logError( '[cachekit] Background refresh failed:', error instanceof Error ? error.message : 'Unknown error' ); @@ -110,8 +110,7 @@ export class BackgroundRefreshManager { try { waitUntil?.(refresh); } catch (error) { - // eslint-disable-next-line no-console - console.error( + logError( '[cachekit] Failed to register background refresh with waitUntil:', error instanceof Error ? error.message : 'Unknown error' ); diff --git a/packages/cachekit/src/constants.ts b/packages/cachekit/src/constants.ts index 612e6b1..9e73358 100644 --- a/packages/cachekit/src/constants.ts +++ b/packages/cachekit/src/constants.ts @@ -126,6 +126,19 @@ export const MIN_MASTER_KEY_BYTES = 32; /** Minimum master key length in hex characters */ export const MIN_MASTER_KEY_HEX_LENGTH = 64; +// ============================================================================ +// Stampede / Single-Flight Constants +// ============================================================================ + +/** Default distributed lock lease in milliseconds (matches cachekit-py's lock_timeout=30s) */ +export const DEFAULT_LOCK_TIMEOUT_MS = 30000; + +/** Default wait for a contested lock holder to fill the cache (matches cachekit-py's blocking_timeout=5s) */ +export const DEFAULT_LOCK_WAIT_MS = 5000; + +/** Default lock retry interval while contested, in milliseconds */ +export const DEFAULT_LOCK_POLL_MS = 100; + // ============================================================================ // SWR (Stale-While-Revalidate) Constants // ============================================================================ diff --git a/packages/cachekit/src/exports-common.ts b/packages/cachekit/src/exports-common.ts index 23a684b..bc70819 100644 --- a/packages/cachekit/src/exports-common.ts +++ b/packages/cachekit/src/exports-common.ts @@ -16,6 +16,7 @@ export type { WrapOptions, EncryptionConfig, ReliabilityConfig, + StampedeConfig, } from './types/cache.js'; export type { @@ -42,6 +43,13 @@ export type { IOOptions, } from './intents-core.js'; +// ============ Observability ============ +// The pluggable error logger is platform-neutral and is the sink for every +// fire-and-forget failure in the shared core (SWR refresh, invalidation +// channel, metrics init) — Workers apps need setLogger just like Node apps. +export { setLogger } from './logger.js'; +export type { CachekitLogger } from './logger.js'; + // ============ Error Classes ============ export { CachekitError, diff --git a/packages/cachekit/src/index.test.ts b/packages/cachekit/src/index.test.ts index 1e33c29..aa206ed 100644 --- a/packages/cachekit/src/index.test.ts +++ b/packages/cachekit/src/index.test.ts @@ -26,4 +26,12 @@ describe('index exports', () => { expect(cachekit.NonceExhaustedError).toBe(NonceExhaustedError); expect(cachekit.SerializationError).toBe(SerializationError); }); + + // LAB-517: the metrics module and logger hook are public API + it('should export the observability surface', () => { + expect(cachekit.CacheMetrics).toBeTypeOf('function'); + expect(cachekit.NoopMetrics).toBeTypeOf('function'); + expect(cachekit.createMetrics).toBeTypeOf('function'); + expect(cachekit.setLogger).toBeTypeOf('function'); + }); }); diff --git a/packages/cachekit/src/index.ts b/packages/cachekit/src/index.ts index 8b0b5b9..85fd6ef 100644 --- a/packages/cachekit/src/index.ts +++ b/packages/cachekit/src/index.ts @@ -17,6 +17,10 @@ export type { // import { file } from '@cachekit-io/cachekit/backends/file'; // import { memcached } from '@cachekit-io/cachekit/backends/memcached'; export type { InvalidationConfig } from './types/cache.js'; +// Observability: the Prometheus collector reaches the optional prom-client +// peer dependency, so it stays off the shared (Workers-safe) surface. +export { CacheMetrics, NoopMetrics, createMetrics } from './metrics/prometheus.js'; +export type { MetricsCollector, MetricsConfig } from './metrics/prometheus.js'; export { EncryptionManager } from './encryption/manager.js'; export { ByteStorage } from '@cachekit-io/cachekit-core-ts'; export { RedisInvalidationChannel } from './invalidation/redis-channel.js'; diff --git a/packages/cachekit/src/intents-core.ts b/packages/cachekit/src/intents-core.ts index 0fb8b8e..ec6d2fa 100644 --- a/packages/cachekit/src/intents-core.ts +++ b/packages/cachekit/src/intents-core.ts @@ -14,6 +14,7 @@ import type { InvalidationConfig, } from './types/cache.js'; import type { L1Config } from './l1/types.js'; +import type { MetricsConfig } from './metrics/prometheus.js'; import type { Backend } from './backends/types.js'; import type { SerializerConfig } from './serialization/serializer.js'; import { ConfigurationError } from './errors.js'; @@ -73,8 +74,13 @@ export type MinimalOptions = BaseIntentOptions & IntentBackendOptions; */ export type ProductionOptions = BaseIntentOptions & IntentBackendOptions & { - /** Enable Prometheus metrics (default: true) */ - metrics?: boolean; + /** + * Enable Prometheus metrics (default: true), or pass a MetricsConfig to + * customize prefix/labels/registry. Requires the optional `prom-client` + * peer dependency — reports once through the library logger and degrades + * to no-ops when it is missing. + */ + metrics?: boolean | MetricsConfig; /** Override default reliability settings */ reliability?: Partial; }; @@ -94,8 +100,13 @@ export type SecureOptions = BaseIntentOptions & masterKey?: string; /** Tenant ID for key derivation isolation */ tenantId?: string; - /** Enable Prometheus metrics (default: true) */ - metrics?: boolean; + /** + * Enable Prometheus metrics (default: true), or pass a MetricsConfig to + * customize prefix/labels/registry. Requires the optional `prom-client` + * peer dependency — reports once through the library logger and degrades + * to no-ops when it is missing. + */ + metrics?: boolean | MetricsConfig; /** Override default reliability settings */ reliability?: Partial; }; @@ -118,8 +129,13 @@ export interface IOOptions extends BaseIntentOptions { timeout?: number; /** Optional encryption config for zero-knowledge mode */ encryption?: EncryptionConfig; - /** Enable Prometheus metrics (default: true) */ - metrics?: boolean; + /** + * Enable Prometheus metrics (default: true), or pass a MetricsConfig to + * customize prefix/labels/registry. Requires the optional `prom-client` + * peer dependency — reports once through the library logger and degrades + * to no-ops when it is missing. + */ + metrics?: boolean | MetricsConfig; /** Override default reliability settings */ reliability?: Partial; } diff --git a/packages/cachekit/src/invalidation/redis-channel.ts b/packages/cachekit/src/invalidation/redis-channel.ts index 9a1b2a5..f5081b5 100644 --- a/packages/cachekit/src/invalidation/redis-channel.ts +++ b/packages/cachekit/src/invalidation/redis-channel.ts @@ -1,5 +1,6 @@ import type { Redis } from 'ioredis'; import type { InvalidationEvent, InvalidationCallback } from '../l1/types.js'; +import { logError } from '../logger.js'; import { serializeEvent, deserializeEvent } from './event.js'; const DEFAULT_CHANNEL = 'cachekit:invalidate'; @@ -56,8 +57,7 @@ export class RedisInvalidationChannel { // Fire-and-forget - don't await, don't throw this.redis.publish(this.channelName, Buffer.from(data)).catch((err) => { - // eslint-disable-next-line no-console -- Library intentionally uses console for error visibility - console.error('[cachekit] Failed to publish invalidation:', err.message); + logError('[cachekit] Failed to publish invalidation:', err.message); }); } @@ -95,13 +95,11 @@ export class RedisInvalidationChannel { try { callback(event); } catch (err) { - // eslint-disable-next-line no-console -- Library intentionally uses console for error visibility - console.error('[cachekit] Invalidation callback error:', err); + logError('[cachekit] Invalidation callback error:', err); } } } catch (err) { - // eslint-disable-next-line no-console -- Library intentionally uses console for error visibility - console.error('[cachekit] Failed to deserialize invalidation event:', err); + logError('[cachekit] Failed to deserialize invalidation event:', err); } }); diff --git a/packages/cachekit/src/logger.test.ts b/packages/cachekit/src/logger.test.ts new file mode 100644 index 0000000..79ffcd7 --- /dev/null +++ b/packages/cachekit/src/logger.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { setLogger, logError } from './logger.js'; +import { BackgroundRefreshManager } from './cache/background-refresh.js'; + +describe('pluggable logger (LAB-517)', () => { + afterEach(() => { + setLogger(null); + vi.restoreAllMocks(); + }); + + it('defaults to console.error', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + logError('[cachekit] something failed:', new Error('boom')); + expect(spy).toHaveBeenCalledWith('[cachekit] something failed:', expect.any(Error)); + }); + + it('routes through a custom logger and silences console', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const custom = vi.fn(); + setLogger(custom); + + logError('[cachekit] something failed:', 'detail'); + + expect(custom).toHaveBeenCalledWith('[cachekit] something failed:', 'detail'); + expect(spy).not.toHaveBeenCalled(); + }); + + it('setLogger(null) restores the console.error default', () => { + const custom = vi.fn(); + setLogger(custom); + setLogger(null); + + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + logError('[cachekit] back to default'); + expect(custom).not.toHaveBeenCalled(); + expect(spy).toHaveBeenCalledWith('[cachekit] back to default'); + }); + + it('background refresh failures reach the custom logger', async () => { + const custom = vi.fn(); + setLogger(custom); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const manager = new BackgroundRefreshManager(); + manager.scheduleRefresh( + 'ns:key', + async () => { + throw new Error('refresh exploded'); + }, + { ttl: 60, namespace: 'ns' }, + 0, + null, + async () => {} + ); + + await vi.waitFor(() => { + expect(custom).toHaveBeenCalledWith( + '[cachekit] Background refresh failed:', + 'refresh exploded' + ); + }); + expect(consoleSpy).not.toHaveBeenCalled(); + manager.close(); + }); +}); diff --git a/packages/cachekit/src/logger.ts b/packages/cachekit/src/logger.ts new file mode 100644 index 0000000..3862f62 --- /dev/null +++ b/packages/cachekit/src/logger.ts @@ -0,0 +1,36 @@ +/** + * Pluggable error logger for the library's internal error reporting. + * + * CacheKit never fails silently: background/fire-and-forget failures + * (invalidation channel, SWR refresh, Redis connection events, metrics + * initialization) are reported through this hook. The default sink is + * `console.error`; applications can route these into their own logging + * pipeline with {@link setLogger}. + * + * @example + * ```typescript + * import { setLogger } from '@cachekit-io/cachekit'; + * + * setLogger((message, error) => myLogger.warn({ err: error }, message)); + * setLogger(null); // restore the console.error default + * ``` + */ +export type CachekitLogger = (message: string, error?: unknown) => void; + +const defaultLogger: CachekitLogger = (message, error) => { + const args = error === undefined ? [message] : [message, error]; + // eslint-disable-next-line no-console -- default sink; replaceable via setLogger + console.error(...args); +}; + +let activeLogger: CachekitLogger = defaultLogger; + +/** Replace the library-wide error logger. Pass `null` to restore the default. */ +export function setLogger(logger: CachekitLogger | null): void { + activeLogger = logger ?? defaultLogger; +} + +/** Internal: report a library error through the active logger. */ +export function logError(message: string, error?: unknown): void { + activeLogger(message, error); +} diff --git a/packages/cachekit/src/metrics/prometheus.test.ts b/packages/cachekit/src/metrics/prometheus.test.ts index dec98a7..72c43df 100644 --- a/packages/cachekit/src/metrics/prometheus.test.ts +++ b/packages/cachekit/src/metrics/prometheus.test.ts @@ -3,6 +3,10 @@ import { CacheMetrics, NoopMetrics, createMetrics } from './prometheus'; // Mock prom-client (not available in test environment) vi.mock('prom-client', () => ({ + register: { + registerMetric() {}, + getSingleMetric: () => undefined, + }, Counter: class MockCounter { inc() {} }, @@ -167,3 +171,49 @@ describe('CacheMetrics init failure handling', () => { timer(); // should not throw }); }); + +describe('CacheMetrics guards the onError handler', () => { + // The cache layer calls every collector method fire-and-forget + // (`void this.metrics.*()`) on the invariant that they never reject. A + // user-supplied onError that throws must not break that invariant. + + it('a throwing onError during a metric operation never rejects', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const throwingCounter = { + inc: () => { + throw new Error('inc failed'); + }, + }; + const metrics = new CacheMetrics({ + registry: { registerMetric() {}, getSingleMetric: () => throwingCounter }, + onError: () => { + throw new Error('handler bug'); + }, + }); + + await expect(metrics.recordOperation('get', 'success')).resolves.toBeUndefined(); + expect(consoleSpy).toHaveBeenCalledWith( + '[cachekit] metrics onError handler threw:', + expect.any(Error) + ); + consoleSpy.mockRestore(); + }); + + it('a throwing onError during initialization failure never rejects', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const metrics = new CacheMetrics({ + registry: { + registerMetric() {}, + getSingleMetric: () => { + throw new Error('registry down'); + }, + }, + onError: () => { + throw new Error('handler bug'); + }, + }); + + await expect(metrics.recordOperation('get', 'success')).resolves.toBeUndefined(); + consoleSpy.mockRestore(); + }); +}); diff --git a/packages/cachekit/src/metrics/prometheus.ts b/packages/cachekit/src/metrics/prometheus.ts index 8b8e693..47c84b0 100644 --- a/packages/cachekit/src/metrics/prometheus.ts +++ b/packages/cachekit/src/metrics/prometheus.ts @@ -3,6 +3,7 @@ * * Uses prom-client types when available, otherwise provides compatible interface. */ +import { logError } from '../logger.js'; // Types for prom-client (peer dependency) interface Counter { @@ -22,6 +23,7 @@ interface Gauge { interface Registry { registerMetric(metric: unknown): void; + getSingleMetric?(name: string): unknown; } // Generic constructor type for prom-client metric classes @@ -68,17 +70,18 @@ export interface MetricsCollector { * - cachekit_l1_memory_bytes (gauge): Current L1 memory usage * - cachekit_circuit_breaker_state (gauge): Circuit breaker state (0=closed, 1=open, 0.5=half-open) * + * Requires the optional `prom-client` peer dependency; when it is missing, + * initialization reports once through the library logger and metrics degrade + * to no-ops. + * * @example * ```typescript - * import { CacheMetrics } from '@cachekit-io/cachekit'; + * import { createCache } from '@cachekit-io/cachekit'; * import promClient from 'prom-client'; * - * const metrics = new CacheMetrics({ prefix: 'myapp_cache' }); - * - * // Use with cache * const cache = createCache({ * backend: { url: 'redis://localhost' }, - * metrics: true, + * metrics: { prefix: 'myapp_cache' }, // or `metrics: true` for defaults * }); * * // Expose metrics endpoint @@ -91,6 +94,7 @@ export interface MetricsCollector { export class CacheMetrics implements MetricsCollector { private readonly prefix: string; private readonly defaultLabels: Record; + private readonly registry: Registry | undefined; private readonly errorHandler: ((error: Error) => void) | undefined; // Metric instances (lazy-loaded when prom-client available) @@ -112,6 +116,7 @@ export class CacheMetrics implements MetricsCollector { constructor(config: MetricsConfig = {}) { this.prefix = config.prefix ?? 'cachekit'; this.defaultLabels = config.defaultLabels ?? {}; + this.registry = config.registry; this.errorHandler = config.onError; } @@ -140,36 +145,47 @@ export class CacheMetrics implements MetricsCollector { this.HistogramClass = promClient.Histogram as unknown as MetricConstructor; this.GaugeClass = promClient.Gauge as unknown as MetricConstructor; + // Target registry: custom (config.registry) or prom-client's default. + // Reuse an already-registered metric instead of constructing a second + // one — prom-client throws on duplicate names, which would kill metrics + // for every cache instance after the first sharing a prefix+registry. + const registry = this.registry ?? (promClient.register as unknown as Registry); + const getOrCreate = (Ctor: MetricConstructor, cfg: Record): M => { + const existing = registry.getSingleMetric?.(cfg.name as string); + if (existing) return existing as M; + return new Ctor({ ...cfg, registers: [registry] }); + }; + // Operations counter - this.operationsCounter = new this.CounterClass({ + this.operationsCounter = getOrCreate(this.CounterClass, { name: `${this.prefix}_operations_total`, help: 'Total cache operations', labelNames: ['operation', 'status'], }); // Hits counter - this.hitsCounter = new this.CounterClass({ + this.hitsCounter = getOrCreate(this.CounterClass, { name: `${this.prefix}_hits_total`, help: 'Cache hits', labelNames: ['layer'], }); // Misses counter - this.missesCounter = new this.CounterClass({ + this.missesCounter = getOrCreate(this.CounterClass, { name: `${this.prefix}_misses_total`, help: 'Cache misses', labelNames: [], }); // Errors counter - this.errorsCounter = new this.CounterClass({ + this.errorsCounter = getOrCreate(this.CounterClass, { name: `${this.prefix}_errors_total`, help: 'Cache errors', labelNames: ['error_type'], }); // Duration histogram - this.durationHistogram = new this.HistogramClass({ + this.durationHistogram = getOrCreate(this.HistogramClass, { name: `${this.prefix}_operation_duration_seconds`, help: 'Operation duration in seconds', labelNames: ['operation'], @@ -177,21 +193,21 @@ export class CacheMetrics implements MetricsCollector { }); // L1 entries gauge - this.l1EntriesGauge = new this.GaugeClass({ + this.l1EntriesGauge = getOrCreate(this.GaugeClass, { name: `${this.prefix}_l1_entries`, help: 'Current L1 cache entries', labelNames: [], }); // L1 memory gauge - this.l1MemoryGauge = new this.GaugeClass({ + this.l1MemoryGauge = getOrCreate(this.GaugeClass, { name: `${this.prefix}_l1_memory_bytes`, help: 'Current L1 memory usage in bytes', labelNames: [], }); // Circuit breaker gauge - this.circuitBreakerGauge = new this.GaugeClass({ + this.circuitBreakerGauge = getOrCreate(this.GaugeClass, { name: `${this.prefix}_circuit_breaker_state`, help: 'Circuit breaker state (0=closed, 0.5=half-open, 1=open)', labelNames: [], @@ -202,17 +218,35 @@ export class CacheMetrics implements MetricsCollector { // m5 Fix: Log error instead of silently swallowing const err = error instanceof Error ? error : new Error(String(error)); - if (this.errorHandler) { - this.errorHandler(err); - } else { - // eslint-disable-next-line no-console - console.error('[cachekit] Failed to initialize metrics:', err.message); + if (!this.invokeErrorHandler(err)) { + logError( + '[cachekit] metrics are enabled but failed to initialize — install the optional ' + + '`prom-client` peer dependency or set `metrics: false`:', + err.message + ); } return false; } } + /** + * Invoke the user's onError handler guarded, returning whether one was + * registered. A throwing handler must never escape: the cache layer calls + * every collector method fire-and-forget (`void this.metrics.*()`) on the + * invariant that they never reject — an unguarded handler would turn its + * own bug into unhandled rejections. Metrics stay best-effort. + */ + private invokeErrorHandler(err: Error): boolean { + if (!this.errorHandler) return false; + try { + this.errorHandler(err); + } catch (handlerError) { + logError('[cachekit] metrics onError handler threw:', handlerError); + } + return true; + } + /** * Handle errors from async metric operations. * m5 Fix: Proper error handling instead of silent failures. @@ -220,11 +254,8 @@ export class CacheMetrics implements MetricsCollector { private handleError(error: unknown, context: string): void { const err = error instanceof Error ? error : new Error(String(error)); - if (this.errorHandler) { - this.errorHandler(err); - } else { - // eslint-disable-next-line no-console - console.error(`[cachekit] Metrics error (${context}):`, err.message); + if (!this.invokeErrorHandler(err)) { + logError(`[cachekit] Metrics error (${context}):`, err.message); } } diff --git a/packages/cachekit/src/types/cache.ts b/packages/cachekit/src/types/cache.ts index 9850cee..125344a 100644 --- a/packages/cachekit/src/types/cache.ts +++ b/packages/cachekit/src/types/cache.ts @@ -1,5 +1,6 @@ import type { Backend, RedisBackendConfig, CachekitIOBackendConfig } from '../backends/types.js'; import type { L1Config } from '../l1/types.js'; +import type { MetricsConfig } from '../metrics/prometheus.js'; import type { CircuitBreakerConfig } from '../reliability/circuit-breaker.js'; import type { RetryConfig } from '../reliability/retry.js'; import type { SerializerConfig } from '../serialization/serializer.js'; @@ -100,6 +101,61 @@ export interface EncryptionConfig { tenantId?: string; } +/** + * Cold-miss stampede protection. + * + * In-process single-flight is always on: concurrent wrap() calls for the + * same cache key share one in-flight promise, so a herd of N cold callers + * costs one L2 read, one compute, and one L2 write instead of N of each — + * under metered-misses billing that is 1 billed miss per cold key instead + * of N. + * + * This config only controls the cross-process extension of that posture. + * + * Deliberately absent — general admission control (a global concurrent-miss + * cap beyond L1's maxConcurrentRefreshes): on Node's single-threaded event + * loop concurrent misses don't compete for threads, single-flight already + * collapses the per-key herd (the amplification vector metered-misses + * punishes), and distinct-key miss floods are bounded by backend timeouts + * plus the circuit breaker. A global semaphore would add queueing latency + * and a tuning knob without a failure mode it prevents; revisit only with + * evidence of backend connection exhaustion. (LAB-519) + */ +export interface StampedeConfig { + /** + * Hold a backend distributed lock around cold-miss compute, mirroring + * cachekit-py's acquire_lock flow: acquire → double-check L2 → compute → + * write → release. When contested, re-try the lock on an interval up to + * `lockWaitMs` (acquireLock never blocks — LAB-240), then compute anyway: + * the lease is best-effort stampede mitigation, never a correctness gate. + * + * Contested waiters deliberately retry the LOCK rather than polling + * get(): on a metered-misses backend every poll GET against a still-cold + * key would itself be a billed miss. + * + * Requires a LockableBackend (Redis, cachekitioWithLocking, + * cachekitioFull, or a CachekitIO config — the SaaS lock endpoint is + * selected automatically). createCache() throws ConfigurationError when + * the backend has no lock capability — an explicit opt-in that silently + * does nothing would be a lie. Default: false. + */ + distributedLock?: boolean; + /** + * Lock lease in milliseconds before auto-release. Size at or above the + * expected recompute time (default 30000, matching cachekit-py's + * lock_timeout). + */ + lockTimeoutMs?: number; + /** + * Contested: max milliseconds to wait for the lock holder to fill the + * cache before computing anyway (default 5000, matching cachekit-py's + * blocking_timeout). + */ + lockWaitMs?: number; + /** Contested: lock retry interval in milliseconds (default 100). */ + lockPollMs?: number; +} + /** * Reliability configuration for cache. */ @@ -131,14 +187,23 @@ export interface CacheOptions { /** Reliability configuration (circuit breaker, retry) */ reliability?: ReliabilityConfig; + /** Cold-miss stampede protection (cross-process lock opt-in) */ + stampede?: StampedeConfig; + /** Serializer configuration */ serializer?: Partial; /** Enable ByteStorage wire format (LZ4 compression + xxHash3-64 integrity). Default: true */ compression?: boolean; - /** Enable Prometheus metrics */ - metrics?: boolean; + /** + * Enable Prometheus metrics (`true`), or enable with configuration + * (prefix, default labels, custom registry). Requires the optional + * `prom-client` peer dependency — when it is missing, metrics report the + * failure once through the library logger and degrade to no-ops. + * Default: false. + */ + metrics?: boolean | MetricsConfig; /** Cross-instance invalidation via Redis Pub/Sub */ invalidation?: InvalidationConfig;