feat(shadow): record validated value age on shadow match and mismatch - #136
Merged
Conversation
Shadow validation now measures how stale a cached value was when a
verdict was delivered. Every Redis frame already carried an 8-byte
createdAtMs header (Redis server time for tracked writes, the writer's
client clock for untracked ones), but both decoders discarded it. The
decoders and DialCacheRedisClient.read() now return a DecodedRedisFrame
({ payload, createdAtMs }), the shadow flight retains the frame, and a
match or mismatch verdict records now - createdAtMs in seconds, clamped
at zero, through the new optional observeShadowValueAge adapter hook.
The Prometheus adapter exposes dialcache_shadow_value_age_histogram
with 1s..7d buckets and the Datadog adapter emits
dialcache.shadow.value_age, both labeled by outcome. Outcomes that
deliver no verdict on a retained value (superseded, filled, errors,
timeout) record no age, and the hook does not gate shadow eligibility;
only shadowValidation does.
BREAKING CHANGE: decodeRedisFrame, decodeTrackedRedisFrame, and
DialCacheRedisClient.read() return DecodedRedisFrame | null instead of
the bare payload. Custom Redis clients must return the decoded frame;
the bundled node-redis and Valkey GLIDE adapters inherit the change
unchanged. Pre-1.0 policy releases this as a minor version.
Review-loop findings on cf087d0, all four addressed: - Skip the value-age observation when the computed age is non-finite. An out-of-contract custom client stamping NaN (or omitting createdAtMs in plain JS) previously flowed through Math.max(NaN, 0) into the metrics backend, permanently poisoning prom-client histogram sums with no diagnostic surface; the write side already RangeErrors the same field. - Correct the two stale "untracked reads never consult it" contract docs: the stamp is never consulted for serving or miss decisions, but it now surfaces on the decoded frame and feeds the shadow value-age observation, so untracked writers must stamp real client time. Also tighten DecodedRedisFrame payload wording (compression envelope) and the verdict-time phrasing in metrics docs, Prometheus help, and README. - Pin mismatch-age provenance: the confirmation read now republishes identical bytes with a fresh stamp while the test still expects the original frame's 90s age, so regressing to the confirmation frame's stamp fails instead of passing silently. - Assert the Prometheus shadow value-age histogram sum (42) and its exact label set, closing the one unpinned numeric pass-through.
lan17
force-pushed
the
claude/dialcache-shadow-miss-staleness-df6603
branch
from
August 14, 2026 20:38
53da96d to
44801f7
Compare
evzheka
reviewed
Aug 16, 2026
| const TIMER_BUCKETS = [0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]; | ||
| const SIZE_BUCKETS = [100, 1_000, 10_000, 100_000, 1_000_000, 10_000_000]; | ||
| const RATIO_BUCKETS = [0.05, 0.1, 0.2, 0.3, 0.5, 0.7, 0.9, 1]; | ||
| // Value ages span seconds to the 365-day TTL ceiling: 1s..15m, then 1h, 3h, 12h, 1d, 3d, 7d. |
There was a problem hiding this comment.
Should this include buckets beyond 7 days? 604_800 is only 7 days, while DialCache supports much longer cache TTLs. Everything else will be inf.
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 free
to 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.
What
When shadow validation delivers a
matchormismatchverdict, DialCache now records the age of the validated cached value — the observing process's epoch clock minus the served frame'screatedAtMs, in seconds, clamped at zero. A confirmedmismatchage answers the question this feature exists for: how long had the stale value been readable when validation caught it? Thematchseries is the baseline that makes the mismatch histogram interpretable.observeShadowValueAge(labels, seconds)with the existing shadow labels (cacheNamespace,useCase,keyType,outcome). It does not gate shadow eligibility; onlyshadowValidationdoes.dialcache_shadow_value_age_histogramwith buckets spanning 1s to 7d.dialcache.shadow.value_age(distribution or histogram per the configured observation type).superseded,filled, every error,timeout) record no age.How (breaking)
Every Redis frame already carried an 8-byte
createdAtMsheader — Redis server time for tracked writes (stamp script), the writer's informational client clock for untracked ones — but both decoders stripped it before it could cross the client boundary.BREAKING CHANGE:
decodeRedisFrame,decodeTrackedRedisFrame, andDialCacheRedisClient.read()now returnDecodedRedisFrame | null({ payload, createdAtMs }) instead of the bare payload. The bundled node-redis and Valkey GLIDE adapters pass decoder results through and needed no code changes; custom clients must return the decoded frame. Per the pre-1.0 release policy this ships as a minor version.Design notes:
decodeTrackedRedisFrame;createdAtMsis consumed only for observability.C1) equality check remains byte-equality on payloads — identical bytes rewritten by a concurrent writer still confirm the mismatch verdict, exactly as before.Date.now()), since frame stamps are epoch-based. It mixes clocks (server vs. client), so negative skew clamps to zero and the metric is documented as coarse operational evidence.flight.cachedFrame = null) is unchanged, and the packaged GC test still proves a timed-out flight releases its payload.Validation
pnpm typecheck✓pnpm test— 516/516 (new coverage: match/mismatch age with pinned clocks, future-stamp clamp to zero, no age onsuperseded/filled, both metric adapters, decoder timestamp round-trips)pnpm build✓pnpm test:package✓ (packaged ESM/CJS round-trip assertions updated to the frame shape)pnpm test:integration— 139/139 against real Redis + Redis Cluster via testcontainers, including an end-to-end assertion that real shadow runs emit exactly two age observations (match, mismatch) and none for supersededMigration notes for custom Redis clients
Nothing stored in Redis changes: the frame wire format, stamps, and fencing are byte-identical, and mixed 0.19/0.20 fleets interoperate in both directions. The break is confined to the in-process contract.
createNodeRedisDialCacheClient,createValkeyGlideDialCacheClient): no action beyond the version bump.DialCacheRedisClientimplementations / direct decoder callers:read()and thedialcache/redis-protocoldecoders now returnDecodedRedisFrame | null({ payload, createdAtMs }). TypeScript surfaces this as a compile error. Two plain-JS failure modes to know:serialization_loaderror metrics (loud, correctness-safe).{ payload }withoutcreatedAtMs→ serving and shadow validation work normally; only the value-age observation is affected, and DialCache skips recording non-finite ages rather than forwarding them to the metrics backend.Date.now()), not a constant — the stamp now feedsobserveShadowValueAgeon shadow verdicts.Review
Ran a six-lane adversarial review (correctness, tests, simplicity, architecture, contracts, reliability + two-stage holistic audit) against cf087d0; four low-severity findings, all addressed in the follow-up commit: stale "untracked reads never consult it" contract docs, a
Number.isFiniteguard so an out-of-contract client stamp cannot poison backend histogram sums, a divergent-stamp confirmation test pinning mismatch-age provenance to the original frame, and a Prometheus histogram sum assertion.🤖 Generated with Claude Code