diff --git a/README.md b/README.md index d757740..7d6b4c1 100644 --- a/README.md +++ b/README.md @@ -205,25 +205,25 @@ Instance-wide behavior is set through the `DialCache` constructor: | `shadowMaxInFlight` | `1` | Maximum scheduled or active shadow jobs per `DialCache` instance, including uncancellable underlying work. Positive safe integer; excess work is dropped without queuing. | | `cacheConfigProvider` | none | Resolves runtime config per enabled invocation as a sparse overlay on the function's `defaultConfig`; `null` applies no overrides. | | `metrics` | disabled | A `DialCacheMetricsAdapter` (see [Metrics](#metrics)). | -| `logger` | `console` | Receives operational cache failures (`debug`, `warn`, `error`). Synchronous throws and rejections from returned promises or thenables are isolated without being awaited. | +| `logger` | `console` | Receives operational cache failures and opted-in confirmed shadow mismatch warnings (`debug`, `warn`, `error`). Synchronous throws and rejections from returned promises or thenables are isolated without being awaited. | -Per-invocation cache policy is a `DialCacheKeyConfig`: per-layer `ttlSec` and `ramp` maps keyed by `CacheLayer.LOCAL` (process-local) and `CacheLayer.REMOTE` (Redis), a `requestLocal` boolean, an optional `remoteReadTimeoutMs`, and the scalar `shadowRamp` percentage. +Per-invocation cache policy is a `DialCacheKeyConfig`: per-layer `ttlSec` and `ramp` maps keyed by `CacheLayer.LOCAL` (process-local) and `CacheLayer.REMOTE` (Redis), a `requestLocal` boolean, an optional `remoteReadTimeoutMs`, and an optional `shadow` group. `ShadowConfig` contains the independent shadow `ramp` percentage plus the default-off `logMismatches` control. Every cached definition or `getOrLoad()` invocation can provide an optional per-use-case `defaultConfig`. It is the baseline policy, and the `cacheConfigProvider` result is a sparse field-level overlay on that baseline. For cache enablement fields, precedence is runtime config, then `defaultConfig`, then DialCache's disabled baseline. For the remote-read deadline, precedence is runtime `remoteReadTimeoutMs`, `defaultConfig.remoteReadTimeoutMs`, `redis.readTimeoutMs`, then the 50 ms library default. -The disabled baseline sets `requestLocal` to false, leaves the process-local and Redis TTLs unset, and sets shadow work to 0%. A shared layer with no effective TTL is disabled by policy. When a shared layer has an effective TTL but no effective ramp, its ramp defaults to 100%. Shadow work remains disabled unless `shadowRamp` is explicitly greater than zero. +The disabled baseline sets `requestLocal` to false, leaves the process-local and Redis TTLs unset, and sets `shadow.ramp` to 0% with mismatch logging false. A shared layer with no effective TTL is disabled by policy. When a shared layer has an effective TTL but no effective ramp, its ramp defaults to 100%. Shadow work remains disabled unless `shadow.ramp` is explicitly greater than zero. `DialCacheKeyConfig` preserves an omitted `requestLocal` as `undefined` so the overlay can distinguish omission from an explicit `false`; the effective value still defaults to false after resolution. -A provider result of `null` (or defensive `undefined`) applies no overrides. An empty `DialCacheKeyConfig` and omitted runtime fields also inherit the baseline. Use explicit values to override inherited policy: `requestLocal: false` disables request-local caching and a layer ramp of `0` disables that shared layer. `DialCacheKeyConfig.disabled()` is the complete new-cache-invocation kill switch in one call: request-local and shadow work off, with both shared layers ramped to 0. It does not cancel already-admitted work, and explicit maintenance operations such as `invalidateRemote()` remain available. To stop new cache-invocation Redis reads and fills while preserving other runtime settings, explicitly set both `ramp.remote` and `shadowRamp` to `0`; the remote ramp alone stops serving but does not override an inherited nonzero shadow ramp. +A provider result of `null` (or defensive `undefined`) applies no overrides. An empty `DialCacheKeyConfig` and omitted runtime fields also inherit the baseline. Top-level fields, cache-layer leaves, and leaves inside `shadow` merge independently; an explicit `false` logging flag overrides an inherited `true`. Use explicit values to override inherited policy: `requestLocal: false` disables request-local caching and a layer ramp of `0` disables that shared layer. `DialCacheKeyConfig.disabled()` is the complete new-cache-invocation kill switch in one call: request-local and shadow work off, shadow logging off, and both shared layers ramped to 0. It does not cancel already-admitted work, and explicit maintenance operations such as `invalidateRemote()` remain available. To stop new cache-invocation Redis reads and fills while preserving other runtime settings, explicitly set both `ramp.remote` and `shadow.ramp` to `0`; the remote ramp alone stops serving but does not override an inherited nonzero shadow ramp. -DialCache validates `defaultConfig` when `cached()` registers a definition and whenever `getOrLoad()` is invoked: TTLs must be positive safe integers no greater than 31,536,000 seconds (a fixed 365-day duration), remote-read deadlines must be positive safe integers within their documented limit, layer and shadow ramps must be finite percentages from 0 to 100, layer maps must be objects, and `requestLocal` must be a boolean when present. Invalid defaults are rejected immediately. +DialCache validates `defaultConfig` when `cached()` registers a definition and whenever `getOrLoad()` is invoked: TTLs must be positive safe integers no greater than 31,536,000 seconds (a fixed 365-day duration), remote-read deadlines must be positive safe integers within their documented limit, layer and shadow ramps must be finite percentages from 0 to 100, layer maps and `shadow` must be objects, and `requestLocal` and `shadow.logMismatches` must be booleans when present. Invalid defaults are rejected immediately. Each registration or one-shot invocation captures an immutable internal snapshot of `defaultConfig`; mutating the supplied config or its maps later does not change that operation's baseline. Runtime policy changes belong in the provider's returned overlay. -Runtime TTL and ramp leaves are used as supplied instead of falling back to valid default leaves. A TTL outside the same 1-to-31,536,000-second range disables that layer with `invalid_ttl`; a nonnumeric, non-finite, or out-of-range ramp disables it with `invalid_ramp`. Valid ramps include both `0` and `100`. Other layers can still run, and invalid leaves also record a `config_resolution` error so provider garbage is alertable separately from intentional ramp-downs. A malformed runtime config object, layer-map shape, `requestLocal` value, or explicit `remoteReadTimeoutMs` fails config resolution for the invocation, records `config_error`, and executes the fallback uncached without attempting Redis. +Runtime TTL and ramp leaves are used as supplied instead of falling back to valid default leaves. A TTL outside the same 1-to-31,536,000-second range disables that layer with `invalid_ttl`; a nonnumeric, non-finite, or out-of-range ramp disables it with `invalid_ramp`. Valid ramps include both `0` and `100`. Other layers can still run, and invalid leaves also record a `config_resolution` error so provider garbage is alertable separately from intentional ramp-downs. A malformed runtime config object, layer-map shape, `requestLocal` value, explicit `remoteReadTimeoutMs`, or removed top-level `shadowRamp` fails config resolution for the invocation, records `config_error`, and executes the fallback uncached without attempting Redis. The public `DialCacheKeyConfig` constructor and static defaults likewise reject `shadowRamp` immediately; migrate it to `shadow.ramp`. -An invalid runtime `shadowRamp` does not affect the cache result or disable an otherwise valid Redis policy. If normal traversal reaches an otherwise shadow-eligible Redis path, DialCache skips shadow work and records a `config_resolution` error. +An invalid runtime `shadow.ramp` does not affect the cache result or disable an otherwise valid Redis policy. If normal traversal reaches an otherwise shadow-eligible Redis path, DialCache skips shadow work and records a `config_resolution` error. An invalid runtime `shadow.logMismatches` likewise preserves the cache result, Redis policy, shadow result, and shadow metric while suppressing the warning. DialCache validates this diagnostic leaf only after the metrics hook, exact-key cohort, and capacity gates admit shadow work, then records one remote `config_resolution` error for that admitted resolution. `cacheConfigProvider` is called for every enabled cache invocation before DialCache performs any cache lookup. Keep it cheap, cache any remote/config-store reads inside the provider, and avoid work that would erase the benefit of a cache hit. @@ -237,7 +237,11 @@ const dialcache = new DialCache({ // Sparse override: inherit both TTLs and the local ramp from defaultConfig. ramp: { [CacheLayer.REMOTE]: 25 }, // Independently sample tracked Redis keys for detached validation/fill. - shadowRamp: 5, + shadow: { + ramp: 5, + // Emit one warning with a bounded key and native-JSON value strings. + logMismatches: true, + }, // Can be changed by the provider at runtime for this use case. remoteReadTimeoutMs: 35, }); @@ -259,9 +263,9 @@ const getUser = dialcache.cached((userId: string) => db.fetchUser(userId), { `ramp` values are percentages from 0 to 100. `0` disables the layer, `100` enables it, and intermediate values are deterministically sampled by cache key and layer, so the same key is consistently sampled in or out of a partial rollout across calls and instances. The assignment algorithm is owned by DialCache and remains stable across releases. Applications that need an externally coordinated cohort can use `cacheConfigProvider` to return a sparse per-key ramp override of `0` or `100`. DialCache fetches and resolves one config snapshot per enabled invocation. Provider errors do not activate defaults: they fail open, record `config_error`, and execute the fallback function uncached. -`shadowRamp` uses the same inclusive 0–100 percentage domain but is independent of cache-layer serving ramps. Omission and `0` disable shadow work; `100` selects every eligible tracked Redis key; intermediate values assign each exact cache key to a stable shadow cohort across calls and instances. A valid remote policy can therefore use `ramp.remote: 0` with a nonzero `shadowRamp` to exercise and populate Redis without serving from it. A nonzero value explicitly authorizes detached tracked writes after clean shadow-only misses. It does not create another `CacheLayer`, activate Redis without a valid remote TTL, or make a request-local/process-local hit continue to Redis. +`shadow.ramp` uses the same inclusive 0–100 percentage domain but is independent of cache-layer serving ramps. Omission and `0` disable shadow work; `100` selects every eligible tracked Redis key; intermediate values assign each exact cache key to a stable shadow cohort across calls and instances. A valid remote policy can therefore use `ramp.remote: 0` with a nonzero `shadow.ramp` to exercise and populate Redis without serving from it. A nonzero value explicitly authorizes detached tracked writes after clean shadow-only misses. It does not create another `CacheLayer`, activate Redis without a valid remote TTL, or make a request-local/process-local hit continue to Redis. -Remote serving and shadow sampling use independent deterministic cohorts. Equal partial percentages do not imply the same keys, so a partial shadow cohort does not guarantee that every key admitted by a later partial serving ramp was warmed or validated. Use `shadowRamp: 100` when every otherwise eligible invocation must exercise the non-serving Redis path before a serving-ramp increase. +Remote serving and shadow sampling use independent deterministic cohorts. Equal partial percentages do not imply the same keys, so a partial shadow cohort does not guarantee that every key admitted by a later partial serving ramp was warmed or validated. Use `shadow: { ramp: 100 }` when every otherwise eligible invocation must exercise the non-serving Redis path before a serving-ramp increase. ## Cache layers @@ -455,7 +459,7 @@ This guard is deliberately conservative and is not a proof of runtime data. Type #### Shadow validation -Shadow mode runs a sampled, detached Redis path for tracked keys without allowing that path to serve the caller. A Redis hit is compared with the source of truth (SoT); a clean Redis miss can be filled from the caller-accepted SoT value. Redis serving and shadow execution are independent, and shadowing is opt-in per use case through `shadowRamp`: +Shadow mode runs a sampled, detached Redis path for tracked keys without allowing that path to serve the caller. A Redis hit is compared with the source of truth (SoT); a clean Redis miss can be filled from the caller-accepted SoT value. Redis serving and shadow execution are independent, and shadowing is opt-in per use case through `shadow.ramp`: ```ts import { CacheLayer, DialCache, DialCacheKeyConfig } from "dialcache"; @@ -473,7 +477,7 @@ const getUser = dialcache.cached( (userId: string) => db.fetchUser(userId), { keyType: "user_id", - useCase: "GetSensitiveUser", + useCase: "GetUser", cacheKey: (userId) => userId, trackForInvalidation: true, // Optional: override strict deep equality with use-case semantics. @@ -483,13 +487,18 @@ const getUser = dialcache.cached( ttlSec: { [CacheLayer.REMOTE]: 300 }, // Exercise and populate Redis without serving it to callers. ramp: { [CacheLayer.REMOTE]: 0 }, - shadowRamp: 5, + shadow: { + ramp: 5, + // Default-off warning with a bounded key and native-JSON value strings. + // Enable only after approving the logger and data-handling policy. + logMismatches: true, + }, }), }, ); ``` -Shadow work is eligible only when the operation sets `trackForInvalidation: true`, a valid remote TTL/policy exists, its effective `shadowRamp` selects the exact cache key, a configured metrics adapter implements `shadowValidation`, and capacity is available. The bundled Prometheus and Datadog adapters implement that hook. There are two paths: +Shadow work is eligible only when the operation sets `trackForInvalidation: true`, a valid remote TTL/policy exists, its effective `shadow.ramp` selects the exact cache key, a configured metrics adapter implements `shadowValidation`, and capacity is available. Logging is supplemental to that metric; enabling `logMismatches` does not activate shadow work without the metrics hook. The bundled Prometheus and Datadog adapters implement that hook. There are two paths: - When remote serving is enabled and produces a tracked Redis hit, DialCache retains the exact serialized payload that supplied the caller as `C0`. - When the remote policy is valid but disabled specifically by `ramped_down`, DialCache starts a detached tracked Redis read for `C0`. Its result can be validated or used to decide whether a clean miss may be filled, but can never supply the caller or populate an in-memory layer. @@ -534,6 +543,12 @@ The effective serializer's `load` method therefore runs a second time for a samp Shadow metrics use the bounded outcomes `match`, `mismatch`, `superseded`, `filled`, `fill_blocked`, `fill_error`, `redis_error`, `source_error`, `deserialization_error`, `comparison_error`, `confirmation_error`, `timeout`, and `dropped`. `redis_error` applies to the initial shadow-only `C0` read; `confirmation_error` applies to `C1`. A clean `C0` miss is an ordinary `miss{layer="remote_shadow"}` and terminates with a fill, source, or timeout outcome rather than a second shadow outcome for the miss itself. Labels never contain cache ids, values, payloads, Redis keys, or raw exception text. +Confirmed-mismatch logging is separately opt-in through `shadow.logMismatches`; it never replaces the required `shadowValidation` metric or emits for a mismatch candidate that becomes `superseded`. The single warning contains `cacheNamespace`, `useCase`, `keyType`, `outcome: "mismatch"`, `cacheKey`, `cachedValueJson`, and `sourceValueJson`. `cacheKey` is the logical DialCache URN capped at 2 KiB, not the physical Redis storage key. DialCache independently applies native `JSON.stringify` to the deserialized cached snapshot and raw source value supplied to the comparator, then caps each resulting string at 8 KiB. A byte-clipped field ends in `...[truncated]`, counted inside its cap. If native JSON throws or returns `undefined`, the corresponding JSON field is `null`; the other side is still attempted. DialCache does not compute a textual diff or call the configured serializer again for logging. + +Mismatch logging is intentionally default-off. Logical URNs can contain ids and arguments, while cached and source values may contain secrets or personal data; truncation is not redaction. DialCache creates the JSON strings only after terminal mismatch confirmation and never passes the raw compared-value references to the logger. Native JSON semantics apply: getters and `toJSON` methods may run, unsupported values may be omitted or normalized, and cycles or `bigint` can make a field unavailable. Stringification is synchronous, and the 8 KiB caps apply only after `JSON.stringify` returns; they do not bound input traversal, hook execution, event-loop time, or the intermediate JSON string. Enable mismatch logging only for trusted, reasonably bounded values and with an approved logger, redaction, transport, access, and retention policy. + +The byte caps apply before logger framing or escaping, so they do not guarantee a final transport event below a sink-specific limit; the metadata fields are not size-clamped. A detail-construction failure degrades to the metadata-only warning. Logger throws and rejected promises or thenables remain isolated from cache and shadow correctness. + Detached Redis reads, serializer loads/dumps, payload sizes, and read/write errors use the existing layer label with `layer="remote_shadow"`. This distinguishes non-serving Redis cost from caller-path `layer="remote"` telemetry without adding a metric or label. The established `observeGet{layer="remote"}` boundary includes caller-path deserialization, while `observeGet{layer="remote_shadow"}` ends when the deadline-bounded Redis read result settles; detached serializer work and any later raw-client settlement are outside that timer. The request-path read that supplied a served `C0` keeps `layer="remote"`, and a ramped-down caller keeps `disabled{layer="remote", reason="ramped_down"}`. No `disabled{layer="remote_shadow"}` event is emitted for ineligible or dropped work; `dropped` remains the terminal shadow outcome. Confirmation reads use the same `remote_shadow` value, with `superseded` or `confirmation_error` describing their role. The command amplification is bounded: a selected served hit adds one SoT read and adds `C1` only for a semantic mismatch candidate; a selected ramped-down hit adds detached `C0`, reuses the caller's existing SoT read, and likewise adds `C1` only for a candidate; a selected ramped-down miss adds detached `C0` and at most one tracked fill. `superseded` means only that the original observation could not be confirmed. `mismatch` means the exact `C0` payload survived a tracked Redis read after the SoT disagreement; it is not a cross-system atomic snapshot or a guarantee that the mismatch persists. @@ -542,7 +557,7 @@ The initial `C0` read and later fill are not atomic. The fill is a normal tracke A served-hit sample invokes the wrapped function or inline loader as an additional source read, so that loader must be safe to call for observation. A ramped-down sample reuses the caller's ordinary invocation and does not add another SoT call. -For valid policies, shadow-specific source calls, cache-path Redis traffic, returned values, and metrics are unchanged when `shadowRamp` is omitted or `0`. Independently of shadow execution, this release rejects malformed runtime ramps instead of clamping them, isolates rejected observer promises and thenables, and makes `DialCacheKeyConfig.disabled()` explicitly set `shadowRamp: 0`. The opt-in feature adds the per-key `shadowRamp` and `shadowComparator` settings, the per-instance `shadowMaxInFlight` limit, and the `shadowValidation` metrics hook/instrument. The clean-miss bootstrap adds no additional ramp knob, Redis protocol operation, metric instrument, or label key; enabling shadowing authorizes the tracked Redis write described above. Exported unions widen: `MetricLayer` gains `remote_shadow`, and `ShadowValidationOutcome` gains `superseded`, `filled`, `fill_blocked`, `fill_error`, `redis_error`, and `confirmation_error`. TypeScript consumers with exhaustive switches or `Record` values must add those cases, and dashboards restricted to `layer="remote"` intentionally exclude detached traffic. +For valid policies, shadow-specific source calls, cache-path Redis traffic, returned values, and metrics are unchanged when `shadow` is omitted or `shadow.ramp` is `0`. Shadow policy is grouped under `DialCacheKeyConfig.shadow`; consumers of the former flat ramp field must migrate to `shadow: { ramp }`. The public constructor, static defaults, and runtime provider results reject the removed field. `DialCacheKeyConfig.disabled()` explicitly disables the shadow ramp and mismatch logging. `shadowComparator` remains a typed `cached()` / `getOrLoad()` option because it defines stable use-case equality, while `shadowMaxInFlight` remains a per-instance concurrency limit. The clean-miss bootstrap adds no additional ramp knob, Redis protocol operation, metric instrument, or label key; enabling shadowing authorizes the tracked Redis write described above. Exported unions include `remote_shadow` in `MetricLayer` and `superseded`, `filled`, `fill_blocked`, `fill_error`, `redis_error`, and `confirmation_error` in `ShadowValidationOutcome`. TypeScript consumers with exhaustive switches or `Record` values must include those cases, and dashboards restricted to `layer="remote"` intentionally exclude detached traffic. ## Cached-value ownership @@ -819,7 +834,7 @@ These values are defined by the backend-neutral core and are identical for every ### Custom adapters -For other telemetry backends, implement `DialCacheMetricsAdapter` and pass the adapter through `new DialCache({ metrics })`. Every backend-neutral label object exposes the logical namespace as camel-case `cacheNamespace`; adapters should map it to their backend's `cache_namespace` label/tag. This field is present even when no key or cache layer was reached. Implement the optional `shadowValidation` method to enable shadow work as well as record its outcomes; omitting it leaves all shadow work disabled even when `shadowRamp` is nonzero. Every metrics callback is fire-and-forget: DialCache isolates synchronous throws and consumes rejections from returned promises or thenables, but never awaits or drains observer work. Omit `metrics` to disable metrics. +For other telemetry backends, implement `DialCacheMetricsAdapter` and pass the adapter through `new DialCache({ metrics })`. Every backend-neutral label object exposes the logical namespace as camel-case `cacheNamespace`; adapters should map it to their backend's `cache_namespace` label/tag. This field is present even when no key or cache layer was reached. Implement the optional `shadowValidation` method to enable shadow work as well as record its outcomes; omitting it leaves all shadow work disabled even when `shadow.ramp` is nonzero or mismatch logging is enabled. Every metrics callback is fire-and-forget: DialCache isolates synchronous throws and consumes rejections from returned promises or thenables, but never awaits or drains observer work. Omit `metrics` to disable metrics. ## Maintainers diff --git a/scripts/benchmark-request-local.mjs b/scripts/benchmark-request-local.mjs index d5ef82e..8a0c3b9 100644 --- a/scripts/benchmark-request-local.mjs +++ b/scripts/benchmark-request-local.mjs @@ -35,7 +35,7 @@ const results = [ scenario: "tracked Redis hits, shadow ramped out", useCase: "BenchmarkTrackedRedisShadowRampedOut", // This exact key's stable shadow sample is about 90.11. - shadowRamp: 50, + shadowPercentage: 50, }), await benchmarkDarkShadowDetachment(), await benchmarkDarkShadowFillDetachment(), @@ -314,7 +314,7 @@ async function benchmarkRedisReadDeadlineCoalescing(fanout) { } } -async function benchmarkSequentialTrackedRedisHits(iterations, { scenario, useCase, shadowRamp }) { +async function benchmarkSequentialTrackedRedisHits(iterations, { scenario, useCase, shadowPercentage }) { let redisReadCalls = 0; let redisWriteCalls = 0; let redisInvalidationCalls = 0; @@ -351,7 +351,7 @@ async function benchmarkSequentialTrackedRedisHits(iterations, { scenario, useCa defaultConfig: new DialCacheKeyConfig({ ttlSec: { [CacheLayer.REMOTE]: 60 }, ramp: { [CacheLayer.REMOTE]: 100 }, - ...(shadowRamp === undefined ? {} : { shadowRamp }), + ...(shadowPercentage === undefined ? {} : { shadow: { ramp: shadowPercentage } }), }), }, ); @@ -429,7 +429,7 @@ async function benchmarkDarkShadowDetachment() { defaultConfig: new DialCacheKeyConfig({ ttlSec: { [CacheLayer.REMOTE]: 60 }, ramp: { [CacheLayer.REMOTE]: 0 }, - shadowRamp: 100, + shadow: { ramp: 100 }, }), }, ); @@ -514,7 +514,7 @@ async function benchmarkDarkShadowFillDetachment() { defaultConfig: new DialCacheKeyConfig({ ttlSec: { [CacheLayer.REMOTE]: 60 }, ramp: { [CacheLayer.REMOTE]: 0 }, - shadowRamp: 100, + shadow: { ramp: 100 }, }), }, ); diff --git a/scripts/test-package.mjs b/scripts/test-package.mjs index 1b40c53..b2a9c58 100644 --- a/scripts/test-package.mjs +++ b/scripts/test-package.mjs @@ -42,6 +42,7 @@ const rootConsumer = `import { type RedisWriteRequest, type Serializer, type ShadowComparator, + type ShadowConfig, type ShadowValidationMetricLabels, type ShadowValidationOutcome, } from "dialcache"; @@ -115,7 +116,11 @@ const shadowCacheConfig: DialCacheConfig = { shadowMaxInFlight: 2, }; const shadowCache = new DialCache(shadowCacheConfig); -const shadowKeyConfig = new DialCacheKeyConfig({ shadowRamp: 50 }); +const shadowConfig: ShadowConfig = { + ramp: 50, + logMismatches: true, +}; +const shadowKeyConfig = new DialCacheKeyConfig({ shadow: shadowConfig }); const dogStatsDClient: DatadogDogStatsDClient = { increment: () => undefined, histogram: () => undefined, @@ -631,7 +636,13 @@ if ("MissingKeyConfigError" in root) { throw new Error("The removed MissingKeyConfigError class must not be exported from the root ESM entry"); } const esmDisabledOverlay = root.DialCacheKeyConfig.disabled(); -if (esmDisabledOverlay.requestLocal !== false || esmDisabledOverlay.shadowRamp !== 0 || esmDisabledOverlay.ramp[root.CacheLayer.LOCAL] !== 0 || esmDisabledOverlay.ramp[root.CacheLayer.REMOTE] !== 0) { +if ( + esmDisabledOverlay.requestLocal !== false + || esmDisabledOverlay.shadow?.ramp !== 0 + || esmDisabledOverlay.shadow.logMismatches !== false + || esmDisabledOverlay.ramp[root.CacheLayer.LOCAL] !== 0 + || esmDisabledOverlay.ramp[root.CacheLayer.REMOTE] !== 0 +) { throw new Error("The packed ESM runtime did not build the disabled() kill-switch overlay"); } let calls = 0; @@ -774,7 +785,7 @@ const load = cache.cached(async () => await new Promise(() => undefined), { defaultConfig: new root.DialCacheKeyConfig({ ttlSec: { [root.CacheLayer.REMOTE]: 60 }, ramp: { [root.CacheLayer.REMOTE]: 100 }, - shadowRamp: 100, + shadow: { ramp: 100 }, }), }); let value = await cache.enable(() => load()); @@ -863,7 +874,13 @@ if ("MissingKeyConfigError" in root) { throw new Error("The removed MissingKeyConfigError class must not be exported from the root CommonJS entry"); } const cjsDisabledOverlay = root.DialCacheKeyConfig.disabled(); -if (cjsDisabledOverlay.requestLocal !== false || cjsDisabledOverlay.shadowRamp !== 0 || cjsDisabledOverlay.ramp[root.CacheLayer.LOCAL] !== 0 || cjsDisabledOverlay.ramp[root.CacheLayer.REMOTE] !== 0) { +if ( + cjsDisabledOverlay.requestLocal !== false + || cjsDisabledOverlay.shadow?.ramp !== 0 + || cjsDisabledOverlay.shadow.logMismatches !== false + || cjsDisabledOverlay.ramp[root.CacheLayer.LOCAL] !== 0 + || cjsDisabledOverlay.ramp[root.CacheLayer.REMOTE] !== 0 +) { throw new Error("The packed CommonJS runtime did not build the disabled() kill-switch overlay"); } void (async () => { diff --git a/src/config.ts b/src/config.ts index 2e72cb4..b40c24f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -11,17 +11,23 @@ export enum CacheLayer { export type Awaitable = T | Promise; export type LayerConfig = Partial>; +/** Per-use-case runtime policy for detached Redis shadow work. */ +export interface ShadowConfig { + /** Independent stable cohort percentage. Omitted and zero disable shadow work. */ + readonly ramp?: number; + /** + * Emit one warning with the logical key and bounded native-JSON strings for + * the compared values for each confirmed mismatch. Defaults to false. + */ + readonly logMismatches?: boolean; +} + export class DialCacheKeyConfig { /** Per-layer TTLs in seconds, from 1 through 31,536,000 (365 days). */ readonly ttlSec: LayerConfig; readonly ramp: LayerConfig; - /** - * Percentage of tracked Redis keys that asynchronously exercise Redis - * without changing what serves the caller. Hits validate against the source - * of truth and ramped-down clean misses populate Redis. This shadow cohort is - * independent of the Redis serving ramp. Omitted and zero disable it. - */ - readonly shadowRamp?: number; + /** Per-use-case runtime policy for detached Redis shadow work. */ + readonly shadow?: ShadowConfig; /** * Memoize successful values for the lifetime of the outermost enabled scope. * Request-local caching is disabled by default and has no TTL or ramp. @@ -36,17 +42,21 @@ export class DialCacheKeyConfig { constructor(config: { ttlSec?: LayerConfig; ramp?: LayerConfig; - shadowRamp?: number; + shadow?: ShadowConfig; requestLocal?: boolean; remoteReadTimeoutMs?: number; }) { if (config === null || typeof config !== "object" || Array.isArray(config)) { throw new TypeError("DialCache key config must be an object"); } + if (Object.hasOwn(config, "shadowRamp")) { + throw new TypeError('DialCacheKeyConfig.shadowRamp was replaced by "shadow.ramp"'); + } this.ttlSec = cloneLayerConfig(config.ttlSec, "ttlSec"); this.ramp = cloneLayerConfig(config.ramp, "ramp"); - if (config.shadowRamp !== undefined) { - this.shadowRamp = config.shadowRamp; + const shadow = cloneShadowConfig(config.shadow); + if (shadow !== undefined) { + this.shadow = shadow; } if (config.requestLocal !== undefined && typeof config.requestLocal !== "boolean") { throw new TypeError("DialCache requestLocal config must be a boolean"); @@ -83,7 +93,10 @@ export class DialCacheKeyConfig { static disabled(): DialCacheKeyConfig { return new DialCacheKeyConfig({ requestLocal: false, - shadowRamp: 0, + shadow: { + ramp: 0, + logMismatches: false, + }, ramp: { [CacheLayer.LOCAL]: 0, [CacheLayer.REMOTE]: 0, @@ -102,6 +115,16 @@ function cloneLayerConfig(config: LayerConfig | undefined, name: "ttlSec" | "ram return { ...config }; } +function cloneShadowConfig(config: ShadowConfig | undefined): ShadowConfig | undefined { + if (config === undefined) { + return undefined; + } + if (config === null || typeof config !== "object" || Array.isArray(config)) { + throw new TypeError("DialCache shadow config must be an object"); + } + return { ...config }; +} + /** * Resolves runtime cache policy. Async implementations must settle within a * finite application-defined deadline; DialCache does not add one. diff --git a/src/dialcache.ts b/src/dialcache.ts index 42c64d5..90f3eca 100644 --- a/src/dialcache.ts +++ b/src/dialcache.ts @@ -41,6 +41,7 @@ import { type LayerConfigResolution, type ResolvedLayerConfig, } from "./internal/runtime-config.js"; +import { shadowMismatchLogDetails } from "./internal/shadow-log-json.js"; type CacheKeyArgs = Record; type Id = string | number | bigint; @@ -222,6 +223,11 @@ interface ShadowValidationPlan { readonly didCallerFallbackTimeout: () => boolean; } +interface ShadowMismatchDetails { + readonly cachedValue: unknown; + readonly sourceValue: unknown; +} + type ShadowValidationStart = | { readonly kind: "retained"; readonly payload: RedisCachePayload } | { @@ -707,7 +713,7 @@ export class DialCache { fallback: () => Promise, shadowValidation: ShadowValidationPlan, ): Promise { - const shadowStartedAtMs = keyConfig?.shadowRamp === undefined || keyConfig.shadowRamp === 0 + const shadowStartedAtMs = keyConfig?.shadow?.ramp === undefined || keyConfig.shadow.ramp === 0 ? null : performance.now(); const valuePromise = this.callFallback(fallbackLabels, fallback); @@ -793,18 +799,36 @@ export class DialCache { return; } - const shadowRamp = keyConfig?.shadowRamp; - if (shadowRamp === undefined || shadowRamp === 0) { + const shadowConfig: unknown = keyConfig?.shadow; + if ( + shadowConfig === null + || typeof shadowConfig !== "object" + || Array.isArray(shadowConfig) + ) { + if (shadowConfig !== undefined) { + this.recordError(key, CacheLayer.REMOTE, "config_resolution"); + } + return; + } + + const resolvedShadowConfig = shadowConfig as Record; + const shadowPercentage: unknown = resolvedShadowConfig.ramp; + if (shadowPercentage === undefined || shadowPercentage === 0) { return; } - if (typeof shadowRamp !== "number" || !Number.isFinite(shadowRamp) || shadowRamp < 0 || shadowRamp > 100) { + if ( + typeof shadowPercentage !== "number" + || !Number.isFinite(shadowPercentage) + || shadowPercentage < 0 + || shadowPercentage > 100 + ) { this.recordError(key, CacheLayer.REMOTE, "config_resolution"); return; } if (this.metrics?.shadowValidation === undefined) { return; } - if (shadowRamp < 100 && deterministicShadowRampSample(key) >= shadowRamp) { + if (shadowPercentage < 100 && deterministicShadowRampSample(key) >= shadowPercentage) { return; } @@ -812,6 +836,7 @@ export class DialCache { this.recordShadowValidation(key, "dropped"); return; } + const logMismatches = this.resolveShadowLogging(key, resolvedShadowConfig); const flight: ShadowFlight = { cachedPayload: start.kind === "retained" ? start.payload : null, @@ -831,9 +856,25 @@ export class DialCache { runStart, validation, readTimeoutMs, + logMismatches, ); } + private resolveShadowLogging( + key: DialCacheKey, + shadowConfig: Record, + ): boolean { + const configuredLogMismatches = shadowConfig.logMismatches; + if (configuredLogMismatches === undefined) { + return false; + } + if (typeof configuredLogMismatches !== "boolean") { + this.recordError(key, CacheLayer.REMOTE, "config_resolution"); + return false; + } + return configuredLogMismatches; + } + private deferShadowValidation( redisCache: RedisCache, key: DialCacheKey, @@ -841,6 +882,7 @@ export class DialCache { start: ShadowValidationRunStart, validation: ShadowValidationPlan, readTimeoutMs: number, + logMismatches: boolean, ): void { setImmediate(() => { this.runShadowValidation( @@ -850,6 +892,7 @@ export class DialCache { start, validation, readTimeoutMs, + logMismatches, ); }).unref(); } @@ -861,6 +904,7 @@ export class DialCache { start: ShadowValidationRunStart, plan: ShadowValidationPlan, readTimeoutMs: number, + logMismatches: boolean, ): void { const pendingRedisReads = new Set>(); let operationFinished = false; @@ -910,6 +954,7 @@ export class DialCache { }; const elapsedBeforeStartMs = Math.max(performance.now() - deadlineStartedAtMs, 0); const remainingTimeoutMs = Math.max(plan.timeoutMs - elapsedBeforeStartMs, 0); + let mismatchDetails: ShadowMismatchDetails | undefined; const validation = withMonotonicDeadline({ timeoutMs: remainingTimeoutMs, @@ -1040,9 +1085,13 @@ export class DialCache { if (originalPayload === null) { return "timeout"; } - return confirmationPayload === null || !redisPayloadsEqual(originalPayload, confirmationPayload) - ? "superseded" - : "mismatch"; + if (confirmationPayload === null || !redisPayloadsEqual(originalPayload, confirmationPayload)) { + return "superseded"; + } + if (logMismatches) { + mismatchDetails = { cachedValue, sourceValue }; + } + return "mismatch"; } finally { finishOperation(); } @@ -1050,18 +1099,53 @@ export class DialCache { }); void validation.then( - (outcome) => this.recordShadowValidation(key, outcome), + (outcome) => this.recordShadowValidation(key, outcome, logMismatches, mismatchDetails), () => this.recordShadowValidation(key, "timeout"), ); } - private recordShadowValidation(key: DialCacheKey, outcome: ShadowValidationOutcome): void { - this.metrics?.shadowValidation?.({ + private recordShadowValidation( + key: DialCacheKey, + outcome: ShadowValidationOutcome, + logMismatches = false, + mismatchDetails?: ShadowMismatchDetails, + ): void { + const labels = { cacheNamespace: key.namespace, useCase: key.useCase, keyType: key.keyType, outcome, - }); + } as const; + this.metrics?.shadowValidation?.(labels); + if (outcome !== "mismatch" || !logMismatches) { + return; + } + + const warning = { + cacheNamespace: key.namespace, + useCase: key.useCase, + keyType: key.keyType, + outcome: "mismatch", + } as const; + if (mismatchDetails !== undefined) { + try { + this.logger.warn( + "DialCache shadow validation mismatch", + { + ...warning, + ...shadowMismatchLogDetails( + key.urn, + mismatchDetails.cachedValue, + mismatchDetails.sourceValue, + ), + }, + ); + return; + } catch { + // JSON detail construction is best-effort; preserve the metadata warning. + } + } + this.logger.warn("DialCache shadow validation mismatch", warning); } private async resolveLocalLayerConfig( @@ -1292,9 +1376,12 @@ function snapshotDefaultConfig(config: DialCacheKeyConfig | null | undefined): D if (typeof config !== "object" || Array.isArray(config)) { throw new TypeError("DialCache defaultConfig must be an object"); } + if (Object.hasOwn(config, "shadowRamp")) { + throw new TypeError('DialCacheKeyConfig.shadowRamp was replaced by "shadow.ramp"'); + } const ttlSecConfig = config.ttlSec; const rampConfig = config.ramp; - const shadowRamp = config.shadowRamp; + const shadowConfig = config.shadow; const requestLocal = config.requestLocal; const remoteReadTimeoutMs = config.remoteReadTimeoutMs; if (requestLocal !== undefined && typeof requestLocal !== "boolean") { @@ -1303,13 +1390,14 @@ function snapshotDefaultConfig(config: DialCacheKeyConfig | null | undefined): D assertDefaultLayerMap(ttlSecConfig, "ttlSec"); assertDefaultLayerMap(rampConfig, "ramp"); + assertDefaultShadowConfig(shadowConfig); const snapshot = new DialCacheKeyConfig({ ttlSec: ttlSecConfig, ramp: rampConfig, ...(requestLocal === undefined ? {} : { requestLocal }), ...(remoteReadTimeoutMs === undefined ? {} : { remoteReadTimeoutMs }), - ...(shadowRamp === undefined ? {} : { shadowRamp }), + ...(shadowConfig === undefined ? {} : { shadow: shadowConfig }), }); for (const layer of [CacheLayer.LOCAL, CacheLayer.REMOTE]) { @@ -1336,17 +1424,25 @@ function snapshotDefaultConfig(config: DialCacheKeyConfig | null | undefined): D } } - if (snapshot.shadowRamp !== undefined) { - if (typeof snapshot.shadowRamp !== "number") { - throw new TypeError("DialCache defaultConfig shadowRamp must be a number"); + if (snapshot.shadow !== undefined) { + if (snapshot.shadow.ramp !== undefined) { + if (typeof snapshot.shadow.ramp !== "number") { + throw new TypeError("DialCache defaultConfig shadow.ramp must be a number"); + } + if (!Number.isFinite(snapshot.shadow.ramp) || snapshot.shadow.ramp < 0 || snapshot.shadow.ramp > 100) { + throw new RangeError("DialCache defaultConfig shadow.ramp must be between 0 and 100"); + } } - if (!Number.isFinite(snapshot.shadowRamp) || snapshot.shadowRamp < 0 || snapshot.shadowRamp > 100) { - throw new RangeError("DialCache defaultConfig shadowRamp must be between 0 and 100"); + if (snapshot.shadow.logMismatches !== undefined && typeof snapshot.shadow.logMismatches !== "boolean") { + throw new TypeError("DialCache defaultConfig shadow.logMismatches must be a boolean"); } } Object.freeze(snapshot.ttlSec); Object.freeze(snapshot.ramp); + if (snapshot.shadow !== undefined) { + Object.freeze(snapshot.shadow); + } return Object.freeze(snapshot); } @@ -1356,6 +1452,15 @@ function assertDefaultLayerMap(config: unknown, name: "ttlSec" | "ramp"): void { } } +function assertDefaultShadowConfig(config: unknown): asserts config is Record | undefined { + if ( + config !== undefined + && (config === null || typeof config !== "object" || Array.isArray(config)) + ) { + throw new TypeError("DialCache defaultConfig shadow must be an object"); + } +} + function resolveFallbackTimeoutMs(value: number | null | undefined): number | null { if (value === null) { return null; diff --git a/src/index.ts b/src/index.ts index c3faad0..bfa5a63 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ export { CacheLayer, DialCacheKeyConfig } from "./config.js"; -export type { CacheConfigProvider, DialCacheConfig, LayerConfig, Logger } from "./config.js"; +export type { CacheConfigProvider, DialCacheConfig, LayerConfig, Logger, ShadowConfig } from "./config.js"; export { DialCacheContext } from "./context.js"; export type { CacheMetricLabels, diff --git a/src/internal/runtime-config.ts b/src/internal/runtime-config.ts index e657328..fec7c4e 100644 --- a/src/internal/runtime-config.ts +++ b/src/internal/runtime-config.ts @@ -3,6 +3,7 @@ import { DialCacheKeyConfig, type CacheConfigProvider, type LayerConfig, + type ShadowConfig, } from "../config.js"; import type { DialCacheKey } from "../key.js"; import type { DisabledReason } from "../metrics.js"; @@ -107,16 +108,14 @@ function mergeKeyConfig( const remoteReadTimeoutMs = overlay?.remoteReadTimeoutMs !== undefined ? overlay.remoteReadTimeoutMs : defaultConfig?.remoteReadTimeoutMs; - const shadowRamp = overlay?.shadowRamp !== undefined - ? overlay.shadowRamp - : defaultConfig?.shadowRamp; + const shadow = mergeShadowConfig(defaultConfig?.shadow, overlay?.shadow); return new DialCacheKeyConfig({ ttlSec: mergeLayerConfig(defaultConfig?.ttlSec, overlay?.ttlSec, "ttlSec"), ramp: mergeLayerConfig(defaultConfig?.ramp, overlay?.ramp, "ramp"), requestLocal, ...(remoteReadTimeoutMs === undefined ? {} : { remoteReadTimeoutMs }), - ...(shadowRamp === undefined ? {} : { shadowRamp }), + ...(shadow === undefined ? {} : { shadow }), }); } @@ -124,6 +123,9 @@ function assertKeyConfig(config: DialCacheKeyConfig | null | undefined): void { if (config !== null && config !== undefined && (typeof config !== "object" || Array.isArray(config))) { throw new TypeError("DialCache key config must be an object"); } + if (config !== null && config !== undefined && Object.hasOwn(config, "shadowRamp")) { + throw new TypeError('DialCacheKeyConfig.shadowRamp was replaced by "shadow.ramp"'); + } } function mergeLayerConfig( @@ -150,3 +152,31 @@ function assertLayerConfig(config: LayerConfig | undefined, name: "ttlSec" | "ra throw new TypeError(`DialCache ${name} config must be a layer map`); } } + +function mergeShadowConfig( + defaults: ShadowConfig | undefined, + overlay: ShadowConfig | undefined, +): ShadowConfig | undefined { + assertShadowConfig(defaults); + assertShadowConfig(overlay); + + if (defaults === undefined && overlay === undefined) { + return undefined; + } + + const ramp = overlay?.ramp !== undefined ? overlay.ramp : defaults?.ramp; + const logMismatches = overlay?.logMismatches !== undefined + ? overlay.logMismatches + : defaults?.logMismatches; + + return { + ...(ramp === undefined ? {} : { ramp }), + ...(logMismatches === undefined ? {} : { logMismatches }), + }; +} + +function assertShadowConfig(config: ShadowConfig | undefined): void { + if (config !== undefined && (config === null || typeof config !== "object" || Array.isArray(config))) { + throw new TypeError("DialCache shadow config must be an object"); + } +} diff --git a/src/internal/shadow-log-json.ts b/src/internal/shadow-log-json.ts new file mode 100644 index 0000000..a43b2db --- /dev/null +++ b/src/internal/shadow-log-json.ts @@ -0,0 +1,51 @@ +export const SHADOW_LOG_KEY_MAX_BYTES = 2 * 1024; +export const SHADOW_LOG_VALUE_MAX_BYTES = 8 * 1024; +export const SHADOW_LOG_TRUNCATION_MARKER = "...[truncated]"; + +const UTF8_ENCODER = new TextEncoder(); +const UTF8_DECODER = new TextDecoder("utf-8", { fatal: true }); +const TRUNCATION_MARKER_BYTES = UTF8_ENCODER.encode(SHADOW_LOG_TRUNCATION_MARKER); + +export interface ShadowMismatchLogDetails { + readonly cacheKey: string; + readonly cachedValueJson: string | null; + readonly sourceValueJson: string | null; +} + +export function previewShadowLogKey(value: string): string { + return clampUtf8(value, SHADOW_LOG_KEY_MAX_BYTES); +} + +export function previewShadowLogJson(value: unknown): string | null { + try { + const json = JSON.stringify(value); + return json === undefined ? null : clampUtf8(json, SHADOW_LOG_VALUE_MAX_BYTES); + } catch { + return null; + } +} + +export function shadowMismatchLogDetails( + cacheKey: string, + cachedValue: unknown, + sourceValue: unknown, +): ShadowMismatchLogDetails { + return { + cacheKey: previewShadowLogKey(cacheKey), + cachedValueJson: previewShadowLogJson(cachedValue), + sourceValueJson: previewShadowLogJson(sourceValue), + }; +} + +function clampUtf8(value: string, maxBytes: number): string { + const bytes = new Uint8Array(maxBytes); + const encoded = UTF8_ENCODER.encodeInto(value, bytes); + if (encoded.read === value.length) { + return value; + } + + const content = bytes.subarray(0, maxBytes - TRUNCATION_MARKER_BYTES.byteLength); + const { written } = UTF8_ENCODER.encodeInto(value, content); + bytes.set(TRUNCATION_MARKER_BYTES, written); + return UTF8_DECODER.decode(bytes.subarray(0, written + TRUNCATION_MARKER_BYTES.byteLength)); +} diff --git a/test/dialcache-config-ramp.test.ts b/test/dialcache-config-ramp.test.ts index 91c2d7a..0a1d9c3 100644 --- a/test/dialcache-config-ramp.test.ts +++ b/test/dialcache-config-ramp.test.ts @@ -38,16 +38,45 @@ describe("DialCache runtime config and ramp controls", () => { expect(new DialCacheKeyConfig({ requestLocal: false }).requestLocal).toBe(false); }); - it("preserves shadowRamp omission and explicit kill-switch values", () => { - expect(new DialCacheKeyConfig({}).shadowRamp).toBeUndefined(); - expect(new DialCacheKeyConfig({ shadowRamp: 0 }).shadowRamp).toBe(0); + it("preserves shadow omission and explicit kill-switch values", () => { + expect(new DialCacheKeyConfig({}).shadow).toBeUndefined(); + expect(new DialCacheKeyConfig({ shadow: {} }).shadow).toEqual({}); + expect(new DialCacheKeyConfig({ + shadow: { + ramp: 0, + logMismatches: false, + }, + }).shadow).toEqual({ + ramp: 0, + logMismatches: false, + }); + }); + + it("clones the supplied shadow policy", () => { + const suppliedShadow = { + ramp: 25, + logMismatches: true, + }; + const config = new DialCacheKeyConfig({ shadow: suppliedShadow }); + + suppliedShadow.ramp = 0; + suppliedShadow.logMismatches = false; + + expect(config.shadow).not.toBe(suppliedShadow); + expect(config.shadow).toEqual({ + ramp: 25, + logMismatches: true, + }); }); it("captures an immutable default policy snapshot when the use case is registered", async () => { const suppliedDefault = new DialCacheKeyConfig({ ttlSec: { [CacheLayer.LOCAL]: 60 }, ramp: { [CacheLayer.LOCAL]: 100 }, - shadowRamp: 25, + shadow: { + ramp: 25, + logMismatches: true, + }, }); const observedDefaults: Array = []; const dialcache = new DialCache({ @@ -67,7 +96,12 @@ describe("DialCache runtime config and ramp controls", () => { suppliedDefault.ttlSec[CacheLayer.LOCAL] = 0; suppliedDefault.ramp[CacheLayer.LOCAL] = 0; - (suppliedDefault as { shadowRamp?: number }).shadowRamp = 0; + const mutableShadow = suppliedDefault.shadow as { + ramp?: number; + logMismatches?: boolean; + }; + mutableShadow.ramp = 0; + mutableShadow.logMismatches = false; const first = await dialcache.enable(async () => await getUser("123")); const second = await dialcache.enable(async () => await getUser("123")); @@ -78,10 +112,14 @@ describe("DialCache runtime config and ramp controls", () => { expect(observedDefaults[1]).toBe(observedDefaults[0]); expect(observedDefaults[0]?.ttlSec[CacheLayer.LOCAL]).toBe(60); expect(observedDefaults[0]?.ramp[CacheLayer.LOCAL]).toBe(100); - expect(observedDefaults[0]?.shadowRamp).toBe(25); + expect(observedDefaults[0]?.shadow).toEqual({ + ramp: 25, + logMismatches: true, + }); expect(Object.isFrozen(observedDefaults[0])).toBe(true); expect(Object.isFrozen(observedDefaults[0]?.ttlSec)).toBe(true); expect(Object.isFrozen(observedDefaults[0]?.ramp)).toBe(true); + expect(Object.isFrozen(observedDefaults[0]?.shadow)).toBe(true); }); it("enables request-local caching without TTL or ramp policy", async () => { @@ -273,6 +311,21 @@ describe("DialCache runtime config and ramp controls", () => { () => new DialCacheKeyConfig({ requestLocal: null as unknown as boolean }), "DialCache requestLocal config must be a boolean", ], + [ + "a null shadow config", + () => new DialCacheKeyConfig({ shadow: null as never }), + "DialCache shadow config must be an object", + ], + [ + "an array shadow config", + () => new DialCacheKeyConfig({ shadow: [] as never }), + "DialCache shadow config must be an object", + ], + [ + "the removed shadowRamp field", + () => new DialCacheKeyConfig({ shadowRamp: 100 } as never), + 'DialCacheKeyConfig.shadowRamp was replaced by "shadow.ramp"', + ], ])("rejects $0 in the public config constructor", (_name, construct, message) => { expect(construct).toThrow(message); }); @@ -297,11 +350,21 @@ describe("DialCache runtime config and ramp controls", () => { ["negative ramp", new DialCacheKeyConfig({ ramp: { [CacheLayer.LOCAL]: -1 } }), RangeError, "between 0 and 100"], ["over-100 ramp", new DialCacheKeyConfig({ ramp: { [CacheLayer.LOCAL]: 101 } }), RangeError, "between 0 and 100"], ["non-finite ramp", new DialCacheKeyConfig({ ramp: { [CacheLayer.LOCAL]: Number.POSITIVE_INFINITY } }), RangeError, "between 0 and 100"], - ["negative shadow ramp", new DialCacheKeyConfig({ shadowRamp: -1 }), RangeError, "between 0 and 100"], - ["over-100 shadow ramp", new DialCacheKeyConfig({ shadowRamp: 101 }), RangeError, "between 0 and 100"], + [ + "negative shadow ramp", + new DialCacheKeyConfig({ shadow: { ramp: -1 } }), + RangeError, + "between 0 and 100", + ], + [ + "over-100 shadow ramp", + new DialCacheKeyConfig({ shadow: { ramp: 101 } }), + RangeError, + "between 0 and 100", + ], [ "non-finite shadow ramp", - new DialCacheKeyConfig({ shadowRamp: Number.POSITIVE_INFINITY }), + new DialCacheKeyConfig({ shadow: { ramp: Number.POSITIVE_INFINITY } }), RangeError, "between 0 and 100", ], @@ -319,10 +382,16 @@ describe("DialCache runtime config and ramp controls", () => { ], [ "wrong-type shadow ramp", - new DialCacheKeyConfig({ shadowRamp: null as unknown as number }), + new DialCacheKeyConfig({ shadow: { ramp: null as unknown as number } }), TypeError, "must be a number", ], + [ + "wrong-type shadow mismatch logging flag", + new DialCacheKeyConfig({ shadow: { logMismatches: null as unknown as boolean } }), + TypeError, + "must be a boolean", + ], ["primitive config", 42 as unknown as DialCacheKeyConfig, TypeError, "must be an object"], ["array config", [] as unknown as DialCacheKeyConfig, TypeError, "must be an object"], [ @@ -331,6 +400,18 @@ describe("DialCache runtime config and ramp controls", () => { TypeError, "must be a layer map", ], + [ + "array shadow config", + { ttlSec: {}, ramp: {}, shadow: [] } as unknown as DialCacheKeyConfig, + TypeError, + "shadow must be an object", + ], + [ + "removed shadowRamp", + { ttlSec: {}, ramp: {}, shadowRamp: 100 } as unknown as DialCacheKeyConfig, + TypeError, + 'shadowRamp was replaced by "shadow.ramp"', + ], ])("rejects a malformed static defaultConfig with $0 at registration", (_name, defaultConfig, ErrorType, message) => { const dialcache = new DialCache(); @@ -361,6 +442,8 @@ describe("DialCache runtime config and ramp controls", () => { ["a primitive", 42], ["an array", []], ["a null layer map", { ttlSec: null, ramp: {} }], + ["an array shadow config", { ttlSec: {}, ramp: {}, shadow: [] }], + ["the removed shadowRamp field", { ttlSec: {}, ramp: {}, shadowRamp: 100 }], ["a null requestLocal value", { ttlSec: {}, ramp: {}, requestLocal: null }], ] as const)("fails open instead of inheriting defaults when the provider returns %s", async (_name, runtimeConfig) => { const cacheConfigProvider = vi.fn(async () => runtimeConfig as unknown as DialCacheKeyConfig); @@ -384,7 +467,10 @@ describe("DialCache runtime config and ramp controls", () => { it("returns the explicit kill-switch overlay from DialCacheKeyConfig.disabled()", () => { expect(DialCacheKeyConfig.disabled()).toEqual(new DialCacheKeyConfig({ requestLocal: false, - shadowRamp: 0, + shadow: { + ramp: 0, + logMismatches: false, + }, ramp: { [CacheLayer.LOCAL]: 0, [CacheLayer.REMOTE]: 0 }, })); }); diff --git a/test/dialcache-observability-internals.test.ts b/test/dialcache-observability-internals.test.ts index 8cb43ae..96915e4 100644 --- a/test/dialcache-observability-internals.test.ts +++ b/test/dialcache-observability-internals.test.ts @@ -15,7 +15,10 @@ describe("DialCache observability internal compatibility paths", () => { requestLocal: true, ttlSec: { [CacheLayer.LOCAL]: 60, [CacheLayer.REMOTE]: 120 }, ramp: { [CacheLayer.LOCAL]: 25, [CacheLayer.REMOTE]: 50 }, - shadowRamp: 20, + shadow: { + ramp: 20, + logMismatches: true, + }, }); const cases = [ { @@ -23,25 +26,46 @@ describe("DialCache observability internal compatibility paths", () => { requestLocal: false, ttlSec: { [CacheLayer.LOCAL]: 30 }, ramp: { [CacheLayer.REMOTE]: 75 }, - shadowRamp: 80, + shadow: { ramp: 80 }, }), expected: new DialCacheKeyConfig({ requestLocal: false, ttlSec: { [CacheLayer.LOCAL]: 30, [CacheLayer.REMOTE]: 120 }, ramp: { [CacheLayer.LOCAL]: 25, [CacheLayer.REMOTE]: 75 }, - shadowRamp: 80, + shadow: { + ramp: 80, + logMismatches: true, + }, }), }, { runtime: new DialCacheKeyConfig({ ttlSec: { [CacheLayer.REMOTE]: 90 }, ramp: { [CacheLayer.LOCAL]: 10 }, + shadow: { + logMismatches: false, + }, }), expected: new DialCacheKeyConfig({ requestLocal: true, ttlSec: { [CacheLayer.LOCAL]: 60, [CacheLayer.REMOTE]: 90 }, ramp: { [CacheLayer.LOCAL]: 10, [CacheLayer.REMOTE]: 50 }, - shadowRamp: 20, + shadow: { + ramp: 20, + logMismatches: false, + }, + }), + }, + { + runtime: new DialCacheKeyConfig({ shadow: {} }), + expected: new DialCacheKeyConfig({ + requestLocal: true, + ttlSec: { [CacheLayer.LOCAL]: 60, [CacheLayer.REMOTE]: 120 }, + ramp: { [CacheLayer.LOCAL]: 25, [CacheLayer.REMOTE]: 50 }, + shadow: { + ramp: 20, + logMismatches: true, + }, }), }, ]; diff --git a/test/dialcache-shadow-confirmation.test.ts b/test/dialcache-shadow-confirmation.test.ts index 9cf64b5..95e7e45 100644 --- a/test/dialcache-shadow-confirmation.test.ts +++ b/test/dialcache-shadow-confirmation.test.ts @@ -29,6 +29,11 @@ import { deterministicRampSample, deterministicShadowRampSample, } from "../src/internal/ramp.js"; +import { + SHADOW_LOG_KEY_MAX_BYTES, + SHADOW_LOG_TRUNCATION_MARKER, + SHADOW_LOG_VALUE_MAX_BYTES, +} from "../src/internal/shadow-log-json.js"; import { REMOTE_SHADOW_CACHE_LAYER } from "../src/metrics.js"; interface Deferred { @@ -138,11 +143,17 @@ class RecordingMetrics implements DialCacheMetricsAdapter { } } -function remoteConfig(remoteRamp: number, shadowRamp = 100): DialCacheKeyConfig { +function remoteConfig( + remoteRamp: number, + shadowPercentage = 100, + logging: { + readonly logMismatches?: boolean; + } = {}, +): DialCacheKeyConfig { return new DialCacheKeyConfig({ ttlSec: { [CacheLayer.REMOTE]: 60 }, ramp: { [CacheLayer.REMOTE]: remoteRamp }, - shadowRamp, + shadow: { ramp: shadowPercentage, ...logging }, }); } @@ -156,7 +167,7 @@ function localAndRemoteConfig(): DialCacheKeyConfig { [CacheLayer.LOCAL]: 100, [CacheLayer.REMOTE]: 0, }, - shadowRamp: 100, + shadow: { ramp: 100 }, }); } @@ -254,6 +265,240 @@ describe("DialCache Redis shadow confirmation", () => { expect(redis.invalidate).not.toHaveBeenCalled(); }); + it("does not log confirmed mismatches when logging is omitted", async () => { + const payload = JSON.stringify({ id: "private-id", version: 1 }); + const redis = new ScriptedRedis([() => payload, () => payload]); + const metrics = new RecordingMetrics(); + const warn = vi.fn(); + const dialcache = createCache(redis, metrics, { + logger: { + debug: () => undefined, + error: () => undefined, + warn, + }, + }); + const getUser = dialcache.cached(async () => ({ id: "private-id", version: 2 }), { + ...trackedOptions( + "ShadowMismatchLoggingOff", + remoteConfig(100), + ), + cacheKey: () => ({ + id: "private-id", + args: { tenant: "private-tenant" }, + }), + }); + + await dialcache.enable(async () => await getUser()); + await waitForShadowEvents(metrics, 1); + + expect(metrics.shadowEvents.map(({ outcome }) => outcome)).toEqual(["mismatch"]); + expect(warn).not.toHaveBeenCalled(); + }); + + it("logs one bounded JSON warning for a served-hit confirmed mismatch", async () => { + const payload = JSON.stringify({ id: "private-id", version: 1 }); + const redis = new ScriptedRedis([() => payload, () => payload]); + const metrics = new RecordingMetrics(); + vi.spyOn(metrics, "shadowValidation").mockImplementation((labels) => { + metrics.shadowEvents.push({ ...labels }); + Object.assign(labels as unknown as Record, { + cacheNamespace: "mutated-by-metrics", + cacheKey: "injected-by-metrics", + }); + }); + const warn = vi.fn(() => Promise.reject(new Error("logger unavailable"))); + const dialcache = createCache(redis, metrics, { + namespace: "private-namespace", + logger: { + debug: () => undefined, + error: () => undefined, + warn, + }, + }); + const getUser = dialcache.cached(async () => ({ id: "private-id", version: 2 }), { + ...trackedOptions( + "ShadowMismatchJson", + remoteConfig(100, 100, { logMismatches: true }), + ), + cacheKey: () => ({ + id: "private-id", + args: { tenant: "private-tenant" }, + }), + }); + + await dialcache.enable(async () => await getUser()); + await waitForShadowEvents(metrics, 1); + + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith( + "DialCache shadow validation mismatch", + { + cacheNamespace: "private-namespace", + useCase: "ShadowMismatchJson", + keyType: "user_id", + outcome: "mismatch", + cacheKey: "{private-namespace:user_id:private-id}?tenant=private-tenant#ShadowMismatchJson", + cachedValueJson: '{"id":"private-id","version":1}', + sourceValueJson: '{"id":"private-id","version":2}', + }, + ); + await nextImmediate(); + }); + + it("logs the logical key and compared values for a ramped-down confirmed mismatch", async () => { + const cachedValue = { id: "123", version: 1 }; + const sourceValue = { id: "123", version: 2 }; + const payload = JSON.stringify(cachedValue); + const serializer: Serializer = { + dump: vi.fn((value) => JSON.stringify(value)), + load: vi.fn((value) => JSON.parse( + Buffer.isBuffer(value) ? value.toString("utf8") : value, + ) as typeof cachedValue), + }; + const redis = new ScriptedRedis([() => payload, () => payload]); + const metrics = new RecordingMetrics(); + const warn = vi.fn(); + const dialcache = createCache(redis, metrics, { + logger: { + debug: () => undefined, + error: () => undefined, + warn, + }, + }); + const getUser = dialcache.cached(async () => sourceValue, { + ...trackedOptions( + "ShadowMismatchRampedDownJson", + remoteConfig(0, 100, { + logMismatches: true, + }), + ), + cacheKey: () => ({ + id: "123", + args: { locale: "en-US" }, + }), + serializer, + }); + + await expect(dialcache.enable(async () => await getUser())).resolves.toBe(sourceValue); + await waitForShadowEvents(metrics, 1); + + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith( + "DialCache shadow validation mismatch", + { + cacheNamespace: "urn", + useCase: "ShadowMismatchRampedDownJson", + keyType: "user_id", + outcome: "mismatch", + cacheKey: "{urn:user_id:123}?locale=en-US#ShadowMismatchRampedDownJson", + cachedValueJson: '{"id":"123","version":1}', + sourceValueJson: '{"id":"123","version":2}', + }, + ); + expect(serializer.dump).not.toHaveBeenCalled(); + }); + + it("falls back to a metadata warning when JSON detail construction throws", async () => { + const cachedValue = { id: "123", version: 1 }; + const sourceValue = { id: "123", version: 2 }; + const payload = JSON.stringify(cachedValue); + const redis = new ScriptedRedis([() => payload, () => payload]); + const metrics = new RecordingMetrics(); + const warn = vi.fn(); + const dialcache = createCache(redis, metrics, { + logger: { + debug: () => undefined, + error: () => undefined, + warn, + }, + }); + const sourceStarted = deferred(); + const sourceGate = deferred(); + const getUser = dialcache.cached(async () => { + sourceStarted.resolve(); + return await sourceGate.promise; + }, { + ...trackedOptions( + "ShadowMismatchJsonFailure", + remoteConfig(100, 100, { + logMismatches: true, + }), + ), + cacheKey: () => "123", + }); + + await dialcache.enable(async () => await getUser()); + await sourceStarted.promise; + const encodeInto = vi.spyOn(TextEncoder.prototype, "encodeInto").mockImplementationOnce(() => { + throw new Error("preview unavailable"); + }); + try { + sourceGate.resolve(sourceValue); + await waitForShadowEvents(metrics, 1); + + expect(metrics.shadowEvents.map(({ outcome }) => outcome)).toEqual(["mismatch"]); + expect(warn).toHaveBeenCalledOnce(); + expect(warn).toHaveBeenCalledWith( + "DialCache shadow validation mismatch", + { + cacheNamespace: "urn", + useCase: "ShadowMismatchJsonFailure", + keyType: "user_id", + outcome: "mismatch", + }, + ); + } finally { + sourceGate.resolve(sourceValue); + encodeInto.mockRestore(); + } + }); + + it("clamps the logical key and both JSON strings before handing details to the logger", async () => { + const id = "k".repeat(SHADOW_LOG_KEY_MAX_BYTES + 100); + const cachedValue = { id, content: "🙂".repeat(SHADOW_LOG_VALUE_MAX_BYTES) }; + const sourceValue = { id, content: "s".repeat(SHADOW_LOG_VALUE_MAX_BYTES + 1) }; + const payload = JSON.stringify(cachedValue); + const redis = new ScriptedRedis([() => payload, () => payload]); + const metrics = new RecordingMetrics(); + const warn = vi.fn(); + const dialcache = createCache(redis, metrics, { + logger: { + debug: () => undefined, + error: () => undefined, + warn, + }, + }); + const getUser = dialcache.cached(async () => sourceValue, { + ...trackedOptions( + "ShadowMismatchClampedJson", + remoteConfig(100, 100, { + logMismatches: true, + }), + ), + cacheKey: () => id, + }); + + await dialcache.enable(async () => await getUser()); + await waitForShadowEvents(metrics, 1); + + expect(metrics.shadowEvents.map(({ outcome }) => outcome)).toEqual(["mismatch"]); + expect(warn).toHaveBeenCalledTimes(1); + const warning = warn.mock.calls[0]?.[1] as Record; + expect(warning).not.toHaveProperty("cachedValue"); + expect(warning).not.toHaveProperty("sourceValue"); + for (const [field, maxBytes] of [ + ["cacheKey", SHADOW_LOG_KEY_MAX_BYTES], + ["cachedValueJson", SHADOW_LOG_VALUE_MAX_BYTES], + ["sourceValueJson", SHADOW_LOG_VALUE_MAX_BYTES], + ] as const) { + const preview = warning[field]; + expect(typeof preview).toBe("string"); + expect(Buffer.byteLength(preview as string)).toBeLessThanOrEqual(maxBytes); + expect((preview as string).endsWith(SHADOW_LOG_TRUNCATION_MARKER)).toBe(true); + expect(preview).not.toContain("\uFFFD"); + } + }); + it.each([ { name: "missing", confirmation: null }, { @@ -284,6 +529,36 @@ describe("DialCache Redis shadow confirmation", () => { expectTrackedReads(redis, 2); }); + it("does not log a mismatch candidate when C1 is superseded", async () => { + const original = JSON.stringify({ id: "123", version: 1 }); + const confirmation = JSON.stringify({ id: "123", version: 3 }); + const redis = new ScriptedRedis([() => original, () => confirmation]); + const metrics = new RecordingMetrics(); + const warn = vi.fn(); + const dialcache = createCache(redis, metrics, { + logger: { + debug: () => undefined, + error: () => undefined, + warn, + }, + }); + const getUser = dialcache.cached(async () => ({ id: "123", version: 2 }), { + ...trackedOptions( + "ShadowMismatchSuperseded", + remoteConfig(100, 100, { + logMismatches: true, + }), + ), + cacheKey: () => "123", + }); + + await dialcache.enable(async () => await getUser()); + await waitForShadowEvents(metrics, 1); + + expect(metrics.shadowEvents.map(({ outcome }) => outcome)).toEqual(["superseded"]); + expect(warn).not.toHaveBeenCalled(); + }); + it.each([ { name: "string to string", @@ -931,7 +1206,7 @@ describe("DialCache Redis shadow confirmation", () => { const cacheConfigProvider = vi.fn(async () => new DialCacheKeyConfig({ ttlSec: { [CacheLayer.REMOTE]: runtimeTtlSec }, ramp: { [CacheLayer.REMOTE]: 0 }, - shadowRamp: 100, + shadow: { ramp: 100 }, remoteReadTimeoutMs: 321, })); const dialcache = createCache(redis, metrics, { cacheConfigProvider }); @@ -1000,7 +1275,7 @@ describe("DialCache Redis shadow confirmation", () => { name: "missing remote policy", tracked: true, metrics: new RecordingMetrics(), - config: new DialCacheKeyConfig({ shadowRamp: 100 }), + config: new DialCacheKeyConfig({ shadow: { ramp: 100 } }), }, { name: "untracked", @@ -1077,7 +1352,7 @@ describe("DialCache Redis shadow confirmation", () => { { name: "invalid shadow ramp", provider: async () => new DialCacheKeyConfig({ - shadowRamp: Number.NaN, + shadow: { ramp: Number.NaN }, }), }, { @@ -1111,6 +1386,46 @@ describe("DialCache Redis shadow confirmation", () => { } }); + it("fails closed for invalid runtime mismatch logging without suppressing the metric", async () => { + const useCase = "ShadowInvalidLogging"; + const cachedValue = { id: "123", version: 1 }; + const sourceValue = { id: "123", version: 2 }; + const payload = JSON.stringify(cachedValue); + const redis = new ScriptedRedis([() => payload, () => payload]); + const metrics = new RecordingMetrics(); + const warn = vi.fn(); + const dialcache = createCache(redis, metrics, { + cacheConfigProvider: async () => new DialCacheKeyConfig({ + shadow: { logMismatches: "yes" as never }, + }), + logger: { + debug: () => undefined, + error: () => undefined, + warn, + }, + }); + const getUser = dialcache.cached(async () => sourceValue, { + ...trackedOptions( + useCase, + remoteConfig(100, 100, { + logMismatches: true, + }), + ), + cacheKey: () => "123", + }); + + await dialcache.enable(async () => await getUser()); + await waitForShadowEvents(metrics, 1); + + expect(metrics.shadowEvents.map(({ outcome }) => outcome)).toEqual(["mismatch"]); + expect(metrics.ordinaryEvents.filter(({ name: metricName, labels }) => + metricName === "error" + && labels.layer === CacheLayer.REMOTE + && labels.error === "config_resolution" + )).toHaveLength(1); + expect(warn).not.toHaveBeenCalled(); + }); + it("treats DialCacheKeyConfig.disabled() as a complete shadow kill switch", async () => { const redis = new ScriptedRedis([]); const metrics = new RecordingMetrics(); @@ -1244,7 +1559,7 @@ describe("DialCache Redis shadow confirmation", () => { ttlSec: { [CacheLayer.REMOTE]: 60 }, ramp: { [CacheLayer.REMOTE]: 0 }, requestLocal: true, - shadowRamp: 100, + shadow: { ramp: 100 }, })), cacheKey: () => "123", }); diff --git a/test/dialcache-shadow-validation.test.ts b/test/dialcache-shadow-validation.test.ts index d80b122..34698d2 100644 --- a/test/dialcache-shadow-validation.test.ts +++ b/test/dialcache-shadow-validation.test.ts @@ -60,16 +60,16 @@ function deferred(): Deferred { return { promise, resolve, reject }; } -function remoteOnly(shadowRamp?: number, requestLocal = false): DialCacheKeyConfig { +function remoteOnly(shadowPercentage?: number, requestLocal = false): DialCacheKeyConfig { return new DialCacheKeyConfig({ ttlSec: { [CacheLayer.REMOTE]: 60 }, ramp: { [CacheLayer.REMOTE]: 100 }, - ...(shadowRamp === undefined ? {} : { shadowRamp }), + ...(shadowPercentage === undefined ? {} : { shadow: { ramp: shadowPercentage } }), ...(requestLocal ? { requestLocal: true } : {}), }); } -function localAndRemote(shadowRamp: number): DialCacheKeyConfig { +function localAndRemote(shadowPercentage: number): DialCacheKeyConfig { return new DialCacheKeyConfig({ ttlSec: { [CacheLayer.LOCAL]: 60, @@ -79,7 +79,7 @@ function localAndRemote(shadowRamp: number): DialCacheKeyConfig { [CacheLayer.LOCAL]: 100, [CacheLayer.REMOTE]: 100, }, - shadowRamp, + shadow: { ramp: shadowPercentage }, }); } @@ -134,12 +134,12 @@ function createShadowCache( }); } -function trackedRemoteDefaults(useCase: string, shadowRamp = 100) { +function trackedRemoteDefaults(useCase: string, shadowPercentage = 100) { return { keyType: "user_id", useCase, trackForInvalidation: true, - defaultConfig: remoteOnly(shadowRamp), + defaultConfig: remoteOnly(shadowPercentage), } as const; } @@ -368,9 +368,31 @@ describe("DialCache Redis shadow validation", () => { const useCase = "ShadowNoMetricHook"; seedRedis(redis, { id: "123", useCase, payload: JSON.stringify({ id: "123" }) }); const source = vi.fn(async () => ({ id: "123" })); - const dialcache = createShadowCache(redis, metricsWithoutShadow()); + const error = vi.fn(); + const warn = vi.fn(); + const dialcache = createShadowCache(redis, { + ...metricsWithoutShadow(), + error, + }, { + cacheConfigProvider: async () => new DialCacheKeyConfig({ + shadow: { logMismatches: "yes" as never }, + }), + logger: { + debug: () => undefined, + error: () => undefined, + warn, + }, + }); const getUser = dialcache.cached(source, { ...trackedRemoteDefaults(useCase), + defaultConfig: new DialCacheKeyConfig({ + ttlSec: { [CacheLayer.REMOTE]: 60 }, + ramp: { [CacheLayer.REMOTE]: 100 }, + shadow: { + ramp: 100, + logMismatches: true, + }, + }), cacheKey: () => "123", }); @@ -378,18 +400,68 @@ describe("DialCache Redis shadow validation", () => { await nextImmediate(); expect(source).not.toHaveBeenCalled(); + expect(warn).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); + }); + + it("does not validate shadow logging for a key outside the shadow cohort", async () => { + const redis = new FakeRedis(); + const metrics = new RecordingMetrics(); + const useCase = "ShadowExcludedInvalidLogging"; + const excludedId = Array.from({ length: 1_000 }, (_, index) => `excluded-${index}`) + .find((id) => deterministicShadowRampSample(new DialCacheKey({ + keyType: "user_id", + id, + useCase, + trackForInvalidation: true, + })) >= 50); + if (excludedId === undefined) { + throw new Error("Could not find a key outside the partial shadow cohort"); + } + seedRedis(redis, { + id: excludedId, + useCase, + payload: JSON.stringify({ id: excludedId }), + }); + const source = vi.fn(async (id: string) => ({ id })); + const dialcache = createShadowCache(redis, metrics, { + cacheConfigProvider: async () => new DialCacheKeyConfig({ + shadow: { logMismatches: "yes" as never }, + }), + }); + const getUser = dialcache.cached(source, { + keyType: "user_id", + useCase, + trackForInvalidation: true, + defaultConfig: new DialCacheKeyConfig({ + ttlSec: { [CacheLayer.REMOTE]: 60 }, + ramp: { [CacheLayer.REMOTE]: 100 }, + shadow: { ramp: 50 }, + }), + cacheKey: (id: string) => id, + }); + + expect(await dialcache.enable(async () => await getUser(excludedId))).toEqual({ id: excludedId }); + await nextImmediate(); + + expect(source).not.toHaveBeenCalled(); + expect(metrics.shadowEvents).toHaveLength(0); + expect(metrics.errorEvents.filter((labels) => + labels.layer === CacheLayer.REMOTE + && labels.error === "config_resolution" + )).toHaveLength(0); }); it.each([ - { name: "omitted", shadowRamp: undefined, recordsError: false }, - { name: "zero", shadowRamp: 0, recordsError: false }, - { name: "a string", shadowRamp: "100", recordsError: true }, - { name: "NaN", shadowRamp: Number.NaN, recordsError: true }, - { name: "negative", shadowRamp: -1, recordsError: true }, - { name: "above one hundred", shadowRamp: 101, recordsError: true }, - ])("treats runtime shadowRamp $name as a no-op without disturbing a valid Redis hit", async ({ + { name: "omitted", shadowPercentage: undefined, recordsError: false }, + { name: "zero", shadowPercentage: 0, recordsError: false }, + { name: "a string", shadowPercentage: "100", recordsError: true }, + { name: "NaN", shadowPercentage: Number.NaN, recordsError: true }, + { name: "negative", shadowPercentage: -1, recordsError: true }, + { name: "above one hundred", shadowPercentage: 101, recordsError: true }, + ])("treats runtime shadow.ramp $name as a no-op without disturbing a valid Redis hit", async ({ name, - shadowRamp, + shadowPercentage, recordsError, }) => { const redis = new FakeRedis(); @@ -398,7 +470,7 @@ describe("DialCache Redis shadow validation", () => { const cachedValue = { id: "123", source: "cache" }; seedRedis(redis, { id: "123", useCase, payload: JSON.stringify(cachedValue) }); const runtimeConfig = new DialCacheKeyConfig({ - ...(shadowRamp === undefined ? {} : { shadowRamp: shadowRamp as number }), + ...(shadowPercentage === undefined ? {} : { shadow: { ramp: shadowPercentage as number } }), }); const source = vi.fn(async () => ({ id: "123", source: "truth" })); const dialcache = createShadowCache(redis, metrics, { @@ -440,7 +512,7 @@ describe("DialCache Redis shadow validation", () => { runtimeShadowRamp: 0, expectedValidations: 0, }, - ])("runtime shadowRamp $name", async ({ + ])("runtime shadow.ramp $name", async ({ staticShadowRamp, runtimeShadowRamp, expectedValidations, @@ -452,7 +524,7 @@ describe("DialCache Redis shadow validation", () => { seedRedis(redis, { id: "123", useCase, payload: JSON.stringify(cachedValue) }); const source = vi.fn(async () => cachedValue); const dialcache = createShadowCache(redis, metrics, { - cacheConfigProvider: async () => new DialCacheKeyConfig({ shadowRamp: runtimeShadowRamp }), + cacheConfigProvider: async () => new DialCacheKeyConfig({ shadow: { ramp: runtimeShadowRamp } }), }); const getUser = dialcache.cached(source, { ...trackedRemoteDefaults(useCase), @@ -875,7 +947,14 @@ describe("DialCache Redis shadow validation", () => { seedRedis(redis, { id: "b", useCase, payload: JSON.stringify({ id: "b" }) }); const firstGate = deferred<{ readonly id: string }>(); const sourceIds: string[] = []; - const dialcache = createShadowCache(redis, metrics, { shadowMaxInFlight: 1 }); + const dialcache = createShadowCache(redis, metrics, { + shadowMaxInFlight: 1, + cacheConfigProvider: async (key) => key.id === "b" + ? new DialCacheKeyConfig({ + shadow: { logMismatches: "yes" as never }, + }) + : null, + }); const getUser = dialcache.cached(async (id: string) => { sourceIds.push(id); return id === "a" ? await firstGate.promise : { id }; @@ -890,6 +969,10 @@ describe("DialCache Redis shadow validation", () => { expect(metrics.shadowEvents.map(({ outcome }) => outcome)).toEqual(["dropped"]); await nextImmediate(); expect(sourceIds).toEqual(["a"]); + expect(metrics.errorEvents.filter((labels) => + labels.layer === CacheLayer.REMOTE + && labels.error === "config_resolution" + )).toHaveLength(0); firstGate.resolve({ id: "a" }); await waitForShadowEvents(metrics, 2); expect(metrics.shadowEvents.map(({ outcome }) => outcome)).toEqual(["dropped", "match"]); diff --git a/test/redis-real.integration.test.ts b/test/redis-real.integration.test.ts index 75e0f7b..4ca5df9 100644 --- a/test/redis-real.integration.test.ts +++ b/test/redis-real.integration.test.ts @@ -345,7 +345,7 @@ describe.each(engines)("DialCache Lua protocol on $name", ({ image }) => { const shadowRemoteOnly = new DialCacheKeyConfig({ ttlSec: { [CacheLayer.REMOTE]: 60 }, ramp: { [CacheLayer.REMOTE]: 100 }, - shadowRamp: 100, + shadow: { ramp: 100 }, }); const dialcache = new DialCache({ namespace, @@ -508,7 +508,7 @@ describe.each(engines)("DialCache Lua protocol on $name", ({ image }) => { defaultConfig: new DialCacheKeyConfig({ ttlSec: { [CacheLayer.REMOTE]: 60 }, ramp: { [CacheLayer.REMOTE]: 0 }, - shadowRamp: 100, + shadow: { ramp: 100 }, }), }); @@ -623,7 +623,7 @@ describe.each(engines)("DialCache Lua protocol on $name", ({ image }) => { cacheConfigProvider: async () => new DialCacheKeyConfig({ ttlSec: { [CacheLayer.REMOTE]: 60 }, ramp: { [CacheLayer.REMOTE]: serveFromRedis ? 100 : 0 }, - shadowRamp: serveFromRedis ? 0 : 100, + shadow: { ramp: serveFromRedis ? 0 : 100 }, }), }); const source = vi.fn(async () => sourceValue); @@ -746,7 +746,7 @@ describe.each(engines)("DialCache Lua protocol on $name", ({ image }) => { defaultConfig: new DialCacheKeyConfig({ ttlSec: { [CacheLayer.REMOTE]: 60 }, ramp: { [CacheLayer.REMOTE]: 0 }, - shadowRamp: 100, + shadow: { ramp: 100 }, }), }); diff --git a/test/shadow-log-json.test.ts b/test/shadow-log-json.test.ts new file mode 100644 index 0000000..22b1707 --- /dev/null +++ b/test/shadow-log-json.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; + +import { + SHADOW_LOG_KEY_MAX_BYTES, + SHADOW_LOG_TRUNCATION_MARKER, + SHADOW_LOG_VALUE_MAX_BYTES, + previewShadowLogJson, + previewShadowLogKey, + shadowMismatchLogDetails, +} from "../src/internal/shadow-log-json.js"; + +describe("shadow mismatch log JSON", () => { + it("uses native JSON for the values supplied to the comparator", () => { + expect(shadowMismatchLogDetails( + "urn:user_id:123#GetUser", + { id: "123", updatedAt: new Date("2026-07-31T00:00:00.000Z") }, + { id: "123", updatedAt: new Date("2026-08-01T00:00:00.000Z") }, + )).toEqual({ + cacheKey: "urn:user_id:123#GetUser", + cachedValueJson: '{"id":"123","updatedAt":"2026-07-31T00:00:00.000Z"}', + sourceValueJson: '{"id":"123","updatedAt":"2026-08-01T00:00:00.000Z"}', + }); + }); + + it("returns null independently for values that native JSON cannot serialize", () => { + const circular: { self?: unknown } = {}; + circular.self = circular; + + expect(shadowMismatchLogDetails("key", circular, { version: 2 })).toEqual({ + cacheKey: "key", + cachedValueJson: null, + sourceValueJson: '{"version":2}', + }); + expect(previewShadowLogJson(1n)).toBeNull(); + expect(previewShadowLogJson(undefined)).toBeNull(); + }); + + it("catches user JSON hook failures", () => { + const value = { + toJSON(): never { + throw new Error("not serializable"); + }, + }; + + expect(previewShadowLogJson(value)).toBeNull(); + }); + + it("byte-clamps keys and JSON without splitting UTF-8 sequences", () => { + const key = `${"k".repeat(SHADOW_LOG_KEY_MAX_BYTES)}🙂`; + const value = { text: "🙂".repeat(SHADOW_LOG_VALUE_MAX_BYTES) }; + const keyPreview = previewShadowLogKey(key); + const valuePreview = previewShadowLogJson(value); + + expect(valuePreview).not.toBeNull(); + expect(Buffer.byteLength(keyPreview)).toBeLessThanOrEqual(SHADOW_LOG_KEY_MAX_BYTES); + expect(Buffer.byteLength(valuePreview!)).toBeLessThanOrEqual(SHADOW_LOG_VALUE_MAX_BYTES); + expect(keyPreview.endsWith(SHADOW_LOG_TRUNCATION_MARKER)).toBe(true); + expect(valuePreview!.endsWith(SHADOW_LOG_TRUNCATION_MARKER)).toBe(true); + expect(keyPreview).not.toContain("\uFFFD"); + expect(valuePreview).not.toContain("\uFFFD"); + }); +});