feat: add sampled Redis shadow validation - #105
Merged
Conversation
This was referenced Jul 29, 2026
lan17
marked this pull request as ready for review
July 29, 2026 16:55
Uh oh!
There was an error while loading. Please reload this page.
This was referenced Jul 30, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds opt-in semantic shadow validation for sensitive, invalidation-tracked Redis cache hits. A selected hit returns its cached value normally, then detached best-effort work re-reads the source of truth (SoT), independently deserializes the retained Redis payload, and compares the two application values.
Closes#104.
Architecture
flowchart LR A[Tracked Redis hit] --> B[Return cached value] A --> C{shadowRamp selects key?} C -- no --> X[No shadow work] C -- yes --> D{Metric hook and capacity?} D -- no --> X D -- yes --> E[Reserve exact-key flight] E --> F[Unref setImmediate] F --> G[Read SoT with DialCache disabled] G --> H[serializer.load retained payload] H --> I[Default or custom value comparator] I --> J[Bounded outcome metric]The hit path performs eligibility checks and slot reservation only. It does no SoT read, extra deserialization, deep comparison, payload-size-linear copy, repair, or cache mutation. All comparison work begins from an unreferenced immediate and is not awaited by the caller.
Public API and configuration
DialCacheKeyConfig.shadowRamp?: number.0disables validation.100selects every otherwise eligible exact key.:shadowdiscriminator.config_resolutionerror without changing the hit.DialCacheConfig.shadowMaxInFlight?: number, a positive safe integer defaulting to1per instance.dropped.ShadowComparator<T>and optionalshadowComparatoroncached()/getOrLoad()options.DialCacheMetricsAdapterwith an optionalshadowValidationhook, preserving existing custom adapters.Comparison and data boundaries
trackForInvalidation: true; request-local/process-local hits, misses, read errors, and initial deserialize failures do not validate.string | Bufferpayload internally alongside the decoded value.load()again on the retained payload to create an independent cached snapshot.T, never a Redis payload.util.isDeepStrictEqual; an optional per-operation comparator defines use-case-specific equality.comparison_error.Detachment, liveness, and safety
setImmediateand runs underdialcache.disable(...)so it cannot recursively satisfy itself from the same cache.fallbackTimeoutMsis reused;fallbackTimeoutMs: nullkeeps normal fallbacks unbounded but gives shadow work the internal 60-second default.cached()argument references or agetOrLoad()closure; docs require relevant inputs/captures to be immutable or snapshotted.Observability
Bounded outcomes:
match,mismatch,source_error,deserialization_error,comparison_error,timeout, anddropped.Labels/tags are limited to cache namespace, use case, key type, and outcome. IDs, cached/SoT values, payloads, Redis keys, and raw exception messages are excluded.
Built-in adapters add:
dialcache_shadow_validation_counterdialcache.shadow.countAn adapter without the optional hook disables shadow execution so DialCache never performs an unobservable SoT read. Runtime thenable rejections from the hook are consumed without being awaited.
Compatibility and operations
shadowMaxInFlightbounds one instance, not the fleet. Roll outshadowRampgradually and monitormatch,mismatch,timeout,source_error,comparison_error, anddropped.Change footprint
The PR is test-led rather than carrying duplicate first-pass production code:
+1,992 / -38against currentmain+1,364 / -2across 7 files (68% of additions)+453 / -25across 13 files (23% of additions)+92+83 / -11The dedicated shadow suite contains 36 scenarios covering the detached lifecycle and failure matrix. Shared setup is factored through test-only helpers; no obsolete serialized-payload comparison implementation remains.
Validation
Run on Node.js 22.22.0 at
4b8f7e4, including currentmain@500d5e7:corepack pnpm checkcorepack pnpm test:integrationDIALCACHE_BENCH_ITERATIONS=20000 DIALCACHE_BENCH_FANOUT=10000 corepack pnpm benchmark:request-localcorepack pnpm audit --prod: no known production-dependency vulnerabilities4b8f7e4git diff --checkFocused shadow tests cover detachment, semantic default/custom equality, caller-mutation isolation, serializer ownership/repeatability, runtime ramp overlays, comparator type/runtime failures, monotonic deadlines, timeout slot retention, exact-key/global drops, coalescing, disabled contexts, metric failure isolation, and unreferenced handles.
Review history
mainwith no remaining findings.