You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
P2 — Medium — opt-in correctness observability for sensitive tracked Redis caches.
Problem
A successful Redis hit is trusted after the configured serializer loads it. DialCache reports hits, misses, failures, and latency, but operators cannot sample successful hits and ask whether the value served from Redis still agrees with the source of truth (SoT).
For sensitive use cases, this leaves coherence confidence dependent on indirect signals or application-specific probes. The desired mode is observational: validate a controlled sample of successful tracked Redis hits without delaying the caller or changing cache state.
Current baseline at main@e06d833ba245706a499d66056fdad15dc1210b68:
The semantic Redis client returns serializer payloads as string | Buffer:
Shadow validation is opt-in through DialCacheKeyConfig.shadowRamp?: number on the existing per-use-case runtime configuration path.
Omitted or 0 disables validation. 100 selects all otherwise eligible hits. Partial rollout uses a stable deterministic cohort of exact cache keys.
Validation applies only when trackForInvalidation: true and only after an actual successful Redis hit.
Request-local/process-local hits, Redis misses/errors/timeouts, initial serializer-load failures, disabled calls, untracked values, and ramped-out operations do not spawn shadow work.
A metrics adapter must implement the optional shadowValidation hook; DialCache does not perform an unobservable SoT read.
Coalesced followers share the Redis-hit leader and do not fan out validations.
Request-path isolation
The caller receives the successfully loaded cached value normally.
Eligibility checks and bounded slot reservation occur on the hit path, then an unreferenced setImmediate starts all extra work.
The SoT read, detached cached-payload deserialization, comparison, and outcome metric are not awaited by the caller and cannot turn a successful hit into a rejection.
Shadow execution runs the underlying loader with DialCache disabled so it cannot recursively satisfy itself from the same cache.
Shadow work is best effort and is not guaranteed to finish during process shutdown.
Coherence definition
A match means application-level equality between an independent decoded snapshot of the Redis value and the raw current value returned by SoT.
Retain the semantic string | Buffer payload from the successful Redis read internally.
Return the already-decoded cached value to the caller.
In detached work, read the current value from SoT.
Call load(retainedPayload) again on the same effective serializer to create an independent cached snapshot.
Compare that cached value with the raw SoT value.
The default comparator is Node's util.isDeepStrictEqual. This avoids representation-only mismatches such as plain-object property insertion order while retaining strict value, array-order, prototype, constructor, Buffer, Map, and Set semantics.
cached() and getOrLoad() accept an optional typed per-operation comparator:
shadowComparator is use-case equality policy, not serializer behavior or runtime rollout configuration. It must synchronously return a boolean and must be deterministic, side-effect-free, non-mutating, and bounded. A throw or non-boolean return reports comparison_error, not mismatch. If untyped JavaScript accidentally returns a Promise, DialCache consumes its settlement without treating it as a valid result or allowing it to escape the concurrency cap.
The comparator never receives the raw Redis payload or the object already returned to the caller. Re-loading the retained payload prevents caller mutation after the hit from contaminating validation. The raw SoT value is intentionally not dump/load normalized: lossy serialization remains observable as a mismatch unless the use case supplies a comparator that defines the normalization as equivalent.
Capacity and liveness
DialCacheConfig.shadowMaxInFlight?: number is a positive safe integer and defaults to 1 per DialCache instance.
There is no queue. Exact-key duplicates and work above the per-instance cap emit dropped.
A single monotonic deadline covers the SoT read, detached serializer.load, and comparison.
A finite fallbackTimeoutMs is reused as the shadow budget. With fallbackTimeoutMs: null, normal fallbacks are unbounded but shadow work uses the internal 60-second default.
Timeout releases DialCache's retained payload reference and prevents later phases from starting. JavaScript cannot cancel already-running source/serializer/comparator work, so its slot remains occupied until that work settles.
Scheduler and deadline handles are unreferenced. A synchronous source, serializer, or comparator can still occupy the Node event loop after the caller has returned and must therefore remain bounded.
Observability and side effects
Bounded outcomes:
match
mismatch
source_error
deserialization_error
comparison_error
timeout
dropped
Metrics may include the existing namespace, use case, key type, and outcome, but never cache IDs, cached/SoT values, serialized payloads, Redis keys, or raw exception messages.
Validation is observational only. No outcome writes Redis, refreshes TTL, advances a watermark, invalidates, repairs, evicts local state, or changes the value returned to the caller.
Serializer and client contracts
The effective serializer's load() may run twice for a sampled hit and must be repeatable, non-mutating, and return independently usable values.
Serializer.load() receives borrowed immutable input and must not mutate a Buffer.
A custom DialCacheRedisClient must return an operation-owned payload whose contents remain stable after read() settles; DialCache does not add a payload-size-linear defensive copy on the request path.
The wrapped function or inline loader must be safe to invoke as an additional side-effect-free SoT read.
Detached cached() arguments and getOrLoad() captures must remain immutable or be snapshotted so the later SoT read still corresponds to the exact key built on the request path.
Architecture boundary
Keep DialCacheRedisClient, Redis Lua, frame format, value keys, watermarks, and bundled adapter protocol unchanged.
Carry the semantic payload only through an internal remote-hit result alongside the decoded value.
Keep serializer selection and detached deserialization behind the existing Redis/serializer boundary; do not expose payloads publicly.
Model validation as a post-successful-Redis-hit observational branch, not another cache layer.
Preserve request/process single-flight behavior.
Acceptance criteria
Shadow absent or 0 leaves behavior unchanged and performs no SoT read, extra deserialization, or comparison.
A selected tracked Redis hit returns before shadow work starts.
Untracked hits and every non-Redis-hit path skip shadow work.
Coalesced followers produce at most one validation for the Redis-hit leader.
The default comparison treats equivalent JSON objects with different property insertion order as a match.
A typed custom comparator works for both cached() and getOrLoad().
Caller mutation of the returned cached object cannot affect the detached comparison.
SoT failure, second-load failure, comparator throw/non-boolean/accidental Promise, telemetry failure, and timeout cannot affect the original request or escape as unhandled rejections.
In-flight work is bounded per instance; excess and exact-key duplicate work does not queue.
Timed-out underlying work retains its slot until settlement while DialCache releases the retained Redis payload.
Metrics are bounded and exclude sensitive values and identifiers.
Shadow validation never writes, repairs, invalidates, refreshes TTL, or mutates local cache state.
Existing custom Redis clients and metrics adapters remain source-compatible when shadow validation is disabled.
Unit, package-consumer, Redis/Valkey integration, and normal-path benchmark coverage is included.
Out of scope
Local or request-local hit validation.
Automatic repair, refresh, invalidation, or cache mutation based on comparison results.
Redis protocol/frame/key changes or another Redis round trip.
Worker threads or a guaranteed background-job delivery system.
Priority
P2 — Medium — opt-in correctness observability for sensitive tracked Redis caches.
Problem
A successful Redis hit is trusted after the configured serializer loads it. DialCache reports hits, misses, failures, and latency, but operators cannot sample successful hits and ask whether the value served from Redis still agrees with the source of truth (SoT).
For sensitive use cases, this leaves coherence confidence dependent on indirect signals or application-specific probes. The desired mode is observational: validate a controlled sample of successful tracked Redis hits without delaying the caller or changing cache state.
Current baseline at
main@e06d833ba245706a499d66056fdad15dc1210b68:string | Buffer:DialCache/src/redis-client.ts
Lines 37 to 38 in e06d833
RedisCacheloads that payload and previously discarded the encoded representation on a hit:DialCache/src/internal/redis-cache.ts
Lines 90 to 128 in e06d833
DialCache/src/dialcache.ts
Lines 523 to 536 in e06d833
DialCache/src/internal/ramp.ts
Lines 4 to 20 in e06d833
Confirmed design
Eligibility and rollout
DialCacheKeyConfig.shadowRamp?: numberon the existing per-use-case runtime configuration path.0disables validation.100selects all otherwise eligible hits. Partial rollout uses a stable deterministic cohort of exact cache keys.trackForInvalidation: trueand only after an actual successful Redis hit.shadowValidationhook; DialCache does not perform an unobservable SoT read.Request-path isolation
setImmediatestarts all extra work.Coherence definition
A match means application-level equality between an independent decoded snapshot of the Redis value and the raw current value returned by SoT.
string | Bufferpayload from the successful Redis read internally.load(retainedPayload)again on the same effective serializer to create an independent cached snapshot.The default comparator is Node's
util.isDeepStrictEqual. This avoids representation-only mismatches such as plain-object property insertion order while retaining strict value, array-order, prototype, constructor, Buffer, Map, and Set semantics.cached()andgetOrLoad()accept an optional typed per-operation comparator:shadowComparatoris use-case equality policy, not serializer behavior or runtime rollout configuration. It must synchronously return a boolean and must be deterministic, side-effect-free, non-mutating, and bounded. A throw or non-boolean return reportscomparison_error, notmismatch. If untyped JavaScript accidentally returns a Promise, DialCache consumes its settlement without treating it as a valid result or allowing it to escape the concurrency cap.The comparator never receives the raw Redis payload or the object already returned to the caller. Re-loading the retained payload prevents caller mutation after the hit from contaminating validation. The raw SoT value is intentionally not dump/load normalized: lossy serialization remains observable as a mismatch unless the use case supplies a comparator that defines the normalization as equivalent.
Capacity and liveness
DialCacheConfig.shadowMaxInFlight?: numberis a positive safe integer and defaults to1per DialCache instance.dropped.serializer.load, and comparison.fallbackTimeoutMsis reused as the shadow budget. WithfallbackTimeoutMs: null, normal fallbacks are unbounded but shadow work uses the internal 60-second default.Observability and side effects
Bounded outcomes:
matchmismatchsource_errordeserialization_errorcomparison_errortimeoutdroppedMetrics may include the existing namespace, use case, key type, and outcome, but never cache IDs, cached/SoT values, serialized payloads, Redis keys, or raw exception messages.
Validation is observational only. No outcome writes Redis, refreshes TTL, advances a watermark, invalidates, repairs, evicts local state, or changes the value returned to the caller.
Serializer and client contracts
load()may run twice for a sampled hit and must be repeatable, non-mutating, and return independently usable values.Serializer.load()receives borrowed immutable input and must not mutate a Buffer.DialCacheRedisClientmust return an operation-owned payload whose contents remain stable afterread()settles; DialCache does not add a payload-size-linear defensive copy on the request path.cached()arguments andgetOrLoad()captures must remain immutable or be snapshotted so the later SoT read still corresponds to the exact key built on the request path.Architecture boundary
DialCacheRedisClient, Redis Lua, frame format, value keys, watermarks, and bundled adapter protocol unchanged.Acceptance criteria
0leaves behavior unchanged and performs no SoT read, extra deserialization, or comparison.cached()andgetOrLoad().Out of scope
Implementation
Draft PR: #105
Related issues