feat(metrics)!: classify cache misses by bounded reason - #130

Closed
lan17 wants to merge 5 commits into
mainfrom
claude/cache-miss-watermark-instrumentation-7fee74
Closed

feat(metrics)!: classify cache misses by bounded reason#130
lan17 wants to merge 5 commits into
mainfrom
claude/cache-miss-watermark-instrumentation-7fee74

Conversation

@lan17

@lan17lan17 commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Summary

Cache misses were indistinguishable in metrics: the watermark fence decision in decodeTrackedRedisFrame collapsed "key absent", "frame unsupported", "watermark missing/malformed", and "fenced by watermark" into one null. Operators could not separate invalidation churn from cold keys.

The miss metric now carries a bounded reason label (mirroring the existing disabled{reason} precedent):

  • not_found — key absent/expired (all layers; local layers always use this)
  • frame_unsupported — short frame or unsupported frame version
  • watermark_unreadable — tracked read with missing/malformed/non-finite watermark
  • watermark_invalidated — tracked frame fenced (createdAt <= watermark)
  • deserialization_failed — payload read but serializer.load threw

sum by (use_case) (rate(dialcache_miss_counter{reason="watermark_invalidated"}[5m])) now measures invalidation churn directly, including the repeated stale-frame transfer cost during future-buffer windows. frame_unsupported/watermark_unreadable should be ~zero in steady state and make good anomaly alerts.

Design

  • Decoders and DialCacheRedisClient.read() return a discriminated RedisReadOutcome ({status:"hit",payload} | {status:"miss",reason}). The decoder remains the single source of truth for classification; bundled adapters needed zero code changes.
  • A runtime outcome guard in RedisCache rejects malformed client results (e.g. a stale client still returning payload | null) into the existing fail-open cache_read error path, so the label vocabulary cannot go unbounded.
  • Shadow reads emit reasons on remote_shadow; shadow fill behavior is unchanged — a fenced dark read stays fill-eligible because the tracked write script re-fences server-side (pinned by a new test).
  • Internal CacheGetResult types unchanged: misses are recorded where discovered, no caller re-records.

Breaking changes

  • DialCacheRedisClient.read() and exported decodeRedisFrame/decodeTrackedRedisFrame return RedisReadOutcome instead of RedisCachePayload | null. Custom clients delegating to the exported decoders need zero logic changes; plain-JS stragglers fail loudly open (typed error → cache_read → fallback), never mislabel.
  • dialcache_miss_counter gains a reason label — an old-schema collector in the same Prometheus registry now fails adapter construction (validateExistingCollectors). sum without (reason) totals are unchanged. Datadog gains an additive tag (wire-compatible).
  • Custom metrics adapters typed miss(labels: CacheMetricLabels) still compile; widen to MissMetricLabels to consume the reason.

Verification

  • pnpm check green: typecheck, 447 unit tests (coverage 97.5% lines / 94.7% branches, gates pass), build, packed-tarball consumer test (incl. exhaustive MissReason record and ESM/CJS decoder checks asserting watermark_invalidated)
  • pnpm test:integration green: 113 tests against real Redis 6.2, Valkey 8, and cluster
  • New tests: decoder reason boundaries, e2e fenced-vs-cold miss labels after invalidateRemote, malformed-outcome guard (5 shapes), fenced shadow-read fill parity, Prometheus/Datadog exhaustive-enum guards

lan17 added 5 commits August 7, 2026 14:24
Add a reason label to the miss metric so watermark-fenced misses are
separable from cold keys. Decoders and DialCacheRedisClient.read() now
return a discriminated RedisReadOutcome instead of payload-or-null,
with four bounded read-miss reasons (not_found, frame_unsupported,
watermark_unreadable, watermark_invalidated) plus a metrics-level
deserialization_failed for serializer.load failures. Local layers
always emit not_found. A runtime outcome guard routes malformed client
results through the existing fail-open cache_read error path so the
label vocabulary stays bounded.
BREAKING CHANGE: DialCacheRedisClient.read() and the exported
decodeRedisFrame/decodeTrackedRedisFrame return RedisReadOutcome
instead of RedisCachePayload | null, and dialcache_miss_counter gains
a reason label, so an old-schema collector in the same Prometheus
registry now fails adapter construction.
- Migrate all four benchmark fake clients to RedisReadOutcome shapes;
the legacy payload/null returns tripped the new outcome guard, failing
three scenarios on their own asserts and hanging the dark-fill one
- Single-source the guard's reason set via satisfies
Record<RedisReadMissReason, true> so a future reason cannot drift out
of it, and pin that a client-supplied deserialization_failed is
rejected as malformed
- Pin frame-before-watermark precedence with competing-bad-state
decoder assertions and a real-Redis mirror
- Delegate FakeRedis read classification to the exported decoders on a
copied frame instead of hand-mirroring the ladder
- Fix the stale shadow-spec sentence still defining a clean miss as a
null read; scope deserialization_failed to the remote layer; note
that only the first fenced read per invalidation window labels
watermark_invalidated; qualify not_found's wrong-type doc as
tracked-MGET-only
Collapse the decoder singletons, the guard's exhaustive flags object, and
its derived Set into a single REDIS_READ_MISS_OUTCOMES table: one frozen
outcome per bounded reason, returned by the decoders and now also the
normal form the core collapses every client miss onto. The read guard
(renamed normalizeRedisReadOutcome) reads each client-owned property
exactly once, recaptures hit payloads, and returns canonical singletons
for misses, so accessor-backed or otherwise unstable client objects can
never flip answers between validation and metric emission. Rejections
now carry a bounded shape fingerprint naming the malformed class -
legacy payload-or-null, non-object value, unknown status, unbounded or
core-owned reason, payload-less hit - so a stale-client migration storm
names its own cause.
Also: unify the two identical started-read interfaces as StartedRead<T>;
pin the accessor-flip behavior in both directions plus the toString
prototype-chain forgery; add the missing negative type pin that
deserialization_failed is not assignable to RedisReadMissReason.
The decoders and the read normalizer alias one frozen outcome per miss
reason, so a mutable entry would let a single consumer corrupt every
later miss and its bounded metric label process-wide; pin identity,
frozenness, and mutation rejection. Also narrow not_found's doc to the
tracked MGET value member - a wrong-type watermark member reads as nil
and classifies watermark_unreadable, as the README and integration
tests already state.
…ication
Conflicts were the adjacent bounded-vocabulary blocks in the adapter
tests (MISS_REASONS vs COMPRESSION_OUTCOMES / ERROR_KINDS with the new
compression kind) - resolved by keeping both. Compression operates on
serializer output inside the frame payload, one layer below the read
outcome classification, so the two features compose without semantic
changes; a rejected compression envelope surfaces as
deserialization_failed alongside its fallback_raw/read_over_limit
compression outcome, now cross-referenced in the miss-reasons table.
@lan17

Copy link
Copy Markdown
OwnerAuthor

Closing this stale, conflicting implementation in favor of the protocol-current reconciliation spec in #145. A fresh implementation PR will follow from current main; this branch should not be rebased mechanically.

@lan17lan17 closed this Aug 29, 2026
lan17 added a commit that referenced this pull request Sep 3, 2026
## Summary
Implements #145 (replaces the
stale attempt in #130).
Every DialCache miss event now says **why** it missed. Until now, a miss
storm during a future-buffer invalidation window was indistinguishable
from a cold cache or an eviction problem: the miss counter only told you
*that* a layer missed, never whether the value was truly absent or a
stored frame was being rejected by an invalidation watermark. This PR
adds one required, bounded `reason` to the existing miss event — no new
metric instrument, no new Redis command, no extra round trip, and no
change to serving, refill, invalidation, stale-recovery, or shadow
behavior.
## What changes in your metrics
`miss` events now carry `reason` alongside the existing `cacheNamespace`
/ `useCase` / `keyType` / `layer` labels, on every layer
(`request_local`, `local`, `remote`, `remote_shadow`):
| `reason` | Fires when | Typical cause |
| --- | --- | --- |
| `value_absent` | The layer had no retrievable value at all: never
populated, physically expired or evicted, Redis `nil`, or a
tracked-`MGET` wrong-type member (which Redis reports as `nil`).
Request-local and process-local misses are always this. | Cold keys,
physical TTL expiry, eviction pressure. |
| `expired` | A complete, supported frame with a valid, non-future
timestamp was present, but its logical age against the reader's clock
reached the effective remote TTL `F`. Covers `age >= M` misses and `F <=
age < M` frames retained as stale-on-error candidates, on both caller
and shadow reads. | Ordinary TTL churn on hot keys; the routine
steady-state miss reason whenever `staleOnErrorMaxAgeSec` is configured.
|
| `watermark_fenced` | A complete, supported tracked frame with a
positive safe-integer timestamp was rejected because its `createdAtMs`
was at or below a valid observed invalidation watermark. Decided before
deserialization. | Reads inside an active `futureBufferMs` invalidation
window; writer/invalidator clock skew. |
| `unclassified` | The miss is real but attributable to none of the
above: unrecognized custom-adapter results (including `null`),
short/unsupported frames, zero-stamped tracked frames, malformed
watermark metadata paired with a present frame, future or invalid
timestamps, and deserialization failures. | Un-migrated custom adapters;
protocol-edge states. |
The operational win: during an invalidation window you can now separate
fence churn from genuine absence at a glance; routine TTL churn
(including the stale-on-error `F..M` window, where every read of a
retained frame is a miss) is `expired` rather than noise in the other
buckets; and because expiry no longer lands there, a sudden
`unclassified` plateau genuinely points at protocol-edge states (skewed
clocks, corrupt frames, deserialization failures, legacy adapters)
instead of hiding inside the aggregate.
**Deviation from #145:** the issue specified a three-value union and
named `unclassified` as the safe default for stale-on-error retained
frames "unless a separately justified reason is added". Review found
that with `staleOnErrorMaxAgeSec` configured every read of a retained
`F..M` frame is a miss, so `unclassified` would have been the routine
steady-state reason for exactly the use cases that enable
stale-on-error, and the plateau signal above would not hold. `expired`
is that separately justified reason; the union is four values and the
cardinality note below is 4x accordingly.
**Prometheus** — `dialcache_miss_counter` keeps its name and gains
`reason` as a fifth label:
```promql
# Fence churn by use case during an invalidation window
sum by (use_case) (rate(dialcache_miss_counter{layer="remote", reason="watermark_fenced"}[5m]))
# Pre-existing total-miss and hit-ratio queries: aggregate reason away
sum by (cache_namespace, use_case, key_type, layer) (rate(dialcache_miss_counter[5m]))
```
**Datadog** — `dialcache.miss.count` keeps its name and gains the same
`reason` tag, e.g. `sum:dialcache.miss.count{reason:watermark_fenced} by
{use_case}`. Expect up to 4× the miss-series cardinality (four bounded
reasons; only `remote` and `remote_shadow` tuples can carry every
reason, since request-local and local misses are always `value_absent`).
## Migration
- **Prometheus registries:** the label set changed, so registering
against a registry that already holds a 4-label `dialcache_miss_counter`
(e.g. a not-yet-upgraded sidecar library) fails loudly at construction —
upgrade producers together per registry. Nothing silently mislabels.
- **Mixed-fleet rollouts:** old processes emit reason-less series while
new ones carry `reason`. Total-miss and miss/request-ratio queries must
`sum by (...)` the shared labels (example above); reason-aware
dashboards should group by `reason` explicitly.
- **Custom metrics adapters:** `DialCacheMetricsAdapter.miss` now
receives `MissMetricLabels` (extends the unchanged `CacheMetricLabels`
with required `reason: CacheMissReason`). Adapters whose `miss`
parameter is typed as the broader `CacheMetricLabels` compile unchanged
and may ignore the field; exact label snapshots, exhaustive `Record`s
over reasons, and adapters that reject or forward unknown fields must
add it.
- **Custom Redis adapters:** `RedisReadResult` is now `DecodedRedisFrame
| RedisReadMiss { kind: "miss", reason, observedWatermarkMs? }`. `null`,
`RedisWatermarkMiss`, `decodeRedisFrame`, and `decodeTrackedRedisFrame`
are removed; use `decodeRedisReadResult` (untracked) /
`decodeTrackedRedisReadResult` (tracked) and the exported
`isRedisReadMiss` guard. An un-migrated adapter still fails open at
runtime (`null` or any unrecognized result is an `unclassified` miss
with a normal refill) but loses reason precision and fenced-refill
suppression until it returns the typed miss.
## How classification works
Bundled decoders return one miss shape, `RedisReadMiss { kind: "miss",
reason, observedWatermarkMs? }`, attaching `observedWatermarkMs` only
when the same atomic tracked snapshot carried a valid numeric watermark.
Cause and fence are deliberately independent: Redis `nil` is decisive
evidence of absence, so an absent value reports `value_absent` while
still carrying the observed watermark that lets PR 143's two-sample
admission skip a known-fenced refill — only a complete supported frame
actually rejected by the watermark reports `watermark_fenced`.
Core treats the adapter boundary as untrusted and normalizes every
result once, at one choke point: `kind: "miss"` is the only
discriminator; invalid or missing reasons become `unclassified`, a
`watermark_fenced` claim without a valid tracked fence is demoted to
`unclassified`, invalid fences (`NaN`, negatives, non-integers,
untracked keys) are dropped, and `null`, `undefined`, or any other
unrecognized value is an `unclassified` miss with a normal refill.
Objects without `kind: "miss"` are frame candidates and go through the
existing timestamp and serializer validation.
Decoded hits keep the existing `{ payload, createdAtMs }` shape.
Core-side rejections never acquire a refill fence: logical-age
rejections emit `expired`, future timestamps and deserialization
failures emit `unclassified`; conditional refill suppression remains
exactly PR 143's adapter-level watermark-miss path, byte-compatible with
`main` (differential-verified across the decode input matrix).
## Breaking change
`DialCacheMetricsAdapter.miss` now receives `MissMetricLabels`, and
first-party miss metrics require a `reason` label/tag. `RedisReadResult`
is now `DecodedRedisFrame | RedisReadMiss`; `null`,
`RedisWatermarkMiss`, `decodeRedisFrame`, and `decodeTrackedRedisFrame`
are removed, and bundled Redis adapters return `RedisReadMiss` for every
semantic miss.
Under the pre-1.0 release policy, this is a minor release.
## Validation
- Node.js 22.22.0: `corepack pnpm check`
- typecheck
- 656 unit tests with coverage thresholds
- ESM/CJS build and declaration generation
- packed-package type and runtime checks
- Node.js 22.22.0: `corepack pnpm test:integration`
- 143 passed
- 2 expected local GLIDE Cluster skips because announced container IPs
are not host-routable
- Review follow-up (`fix(metrics): preserve existing refill and decoder
behavior`) removed the fence-carry scope creep and restored
decoder/refill parity with `main`, differential-verified across the
decode input matrix
BREAKING CHANGE: DialCacheMetricsAdapter.miss now receives
MissMetricLabels and first-party miss metrics require a reason
label/tag; RedisReadResult is now DecodedRedisFrame | RedisReadMiss ({
kind: "miss", reason, observedWatermarkMs? }); null, RedisWatermarkMiss,
decodeRedisFrame, and decodeTrackedRedisFrame are removed, and bundled
Redis adapters return RedisReadMiss for every semantic miss.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@lan17
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

feat(metrics)!: classify cache misses by bounded reason - #130

Closed
lan17 wants to merge 5 commits into
mainfrom
claude/cache-miss-watermark-instrumentation-7fee74
Closed

feat(metrics)!: classify cache misses by bounded reason#130
lan17 wants to merge 5 commits into
mainfrom
claude/cache-miss-watermark-instrumentation-7fee74

Conversation

@lan17

@lan17lan17 commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Summary

Cache misses were indistinguishable in metrics: the watermark fence decision in decodeTrackedRedisFrame collapsed "key absent", "frame unsupported", "watermark missing/malformed", and "fenced by watermark" into one null. Operators could not separate invalidation churn from cold keys.

The miss metric now carries a bounded reason label (mirroring the existing disabled{reason} precedent):

  • not_found — key absent/expired (all layers; local layers always use this)
  • frame_unsupported — short frame or unsupported frame version
  • watermark_unreadable — tracked read with missing/malformed/non-finite watermark
  • watermark_invalidated — tracked frame fenced (createdAt <= watermark)
  • deserialization_failed — payload read but serializer.load threw

sum by (use_case) (rate(dialcache_miss_counter{reason="watermark_invalidated"}[5m])) now measures invalidation churn directly, including the repeated stale-frame transfer cost during future-buffer windows. frame_unsupported/watermark_unreadable should be ~zero in steady state and make good anomaly alerts.

Design

  • Decoders and DialCacheRedisClient.read() return a discriminated RedisReadOutcome ({status:"hit",payload} | {status:"miss",reason}). The decoder remains the single source of truth for classification; bundled adapters needed zero code changes.
  • A runtime outcome guard in RedisCache rejects malformed client results (e.g. a stale client still returning payload | null) into the existing fail-open cache_read error path, so the label vocabulary cannot go unbounded.
  • Shadow reads emit reasons on remote_shadow; shadow fill behavior is unchanged — a fenced dark read stays fill-eligible because the tracked write script re-fences server-side (pinned by a new test).
  • Internal CacheGetResult types unchanged: misses are recorded where discovered, no caller re-records.

Breaking changes

  • DialCacheRedisClient.read() and exported decodeRedisFrame/decodeTrackedRedisFrame return RedisReadOutcome instead of RedisCachePayload | null. Custom clients delegating to the exported decoders need zero logic changes; plain-JS stragglers fail loudly open (typed error → cache_read → fallback), never mislabel.
  • dialcache_miss_counter gains a reason label — an old-schema collector in the same Prometheus registry now fails adapter construction (validateExistingCollectors). sum without (reason) totals are unchanged. Datadog gains an additive tag (wire-compatible).
  • Custom metrics adapters typed miss(labels: CacheMetricLabels) still compile; widen to MissMetricLabels to consume the reason.

Verification

  • pnpm check green: typecheck, 447 unit tests (coverage 97.5% lines / 94.7% branches, gates pass), build, packed-tarball consumer test (incl. exhaustive MissReason record and ESM/CJS decoder checks asserting watermark_invalidated)
  • pnpm test:integration green: 113 tests against real Redis 6.2, Valkey 8, and cluster
  • New tests: decoder reason boundaries, e2e fenced-vs-cold miss labels after invalidateRemote, malformed-outcome guard (5 shapes), fenced shadow-read fill parity, Prometheus/Datadog exhaustive-enum guards

lan17 added 5 commits August 7, 2026 14:24
Add a reason label to the miss metric so watermark-fenced misses are
separable from cold keys. Decoders and DialCacheRedisClient.read() now
return a discriminated RedisReadOutcome instead of payload-or-null,
with four bounded read-miss reasons (not_found, frame_unsupported,
watermark_unreadable, watermark_invalidated) plus a metrics-level
deserialization_failed for serializer.load failures. Local layers
always emit not_found. A runtime outcome guard routes malformed client
results through the existing fail-open cache_read error path so the
label vocabulary stays bounded.
BREAKING CHANGE: DialCacheRedisClient.read() and the exported
decodeRedisFrame/decodeTrackedRedisFrame return RedisReadOutcome
instead of RedisCachePayload | null, and dialcache_miss_counter gains
a reason label, so an old-schema collector in the same Prometheus
registry now fails adapter construction.
- Migrate all four benchmark fake clients to RedisReadOutcome shapes;
the legacy payload/null returns tripped the new outcome guard, failing
three scenarios on their own asserts and hanging the dark-fill one
- Single-source the guard's reason set via satisfies
Record<RedisReadMissReason, true> so a future reason cannot drift out
of it, and pin that a client-supplied deserialization_failed is
rejected as malformed
- Pin frame-before-watermark precedence with competing-bad-state
decoder assertions and a real-Redis mirror
- Delegate FakeRedis read classification to the exported decoders on a
copied frame instead of hand-mirroring the ladder
- Fix the stale shadow-spec sentence still defining a clean miss as a
null read; scope deserialization_failed to the remote layer; note
that only the first fenced read per invalidation window labels
watermark_invalidated; qualify not_found's wrong-type doc as
tracked-MGET-only
Collapse the decoder singletons, the guard's exhaustive flags object, and
its derived Set into a single REDIS_READ_MISS_OUTCOMES table: one frozen
outcome per bounded reason, returned by the decoders and now also the
normal form the core collapses every client miss onto. The read guard
(renamed normalizeRedisReadOutcome) reads each client-owned property
exactly once, recaptures hit payloads, and returns canonical singletons
for misses, so accessor-backed or otherwise unstable client objects can
never flip answers between validation and metric emission. Rejections
now carry a bounded shape fingerprint naming the malformed class -
legacy payload-or-null, non-object value, unknown status, unbounded or
core-owned reason, payload-less hit - so a stale-client migration storm
names its own cause.
Also: unify the two identical started-read interfaces as StartedRead<T>;
pin the accessor-flip behavior in both directions plus the toString
prototype-chain forgery; add the missing negative type pin that
deserialization_failed is not assignable to RedisReadMissReason.
The decoders and the read normalizer alias one frozen outcome per miss
reason, so a mutable entry would let a single consumer corrupt every
later miss and its bounded metric label process-wide; pin identity,
frozenness, and mutation rejection. Also narrow not_found's doc to the
tracked MGET value member - a wrong-type watermark member reads as nil
and classifies watermark_unreadable, as the README and integration
tests already state.
…ication
Conflicts were the adjacent bounded-vocabulary blocks in the adapter
tests (MISS_REASONS vs COMPRESSION_OUTCOMES / ERROR_KINDS with the new
compression kind) - resolved by keeping both. Compression operates on
serializer output inside the frame payload, one layer below the read
outcome classification, so the two features compose without semantic
changes; a rejected compression envelope surfaces as
deserialization_failed alongside its fallback_raw/read_over_limit
compression outcome, now cross-referenced in the miss-reasons table.
@lan17

Copy link
Copy Markdown
OwnerAuthor

Closing this stale, conflicting implementation in favor of the protocol-current reconciliation spec in #145. A fresh implementation PR will follow from current main; this branch should not be rebased mechanically.

@lan17lan17 closed this Aug 29, 2026
lan17 added a commit that referenced this pull request Sep 3, 2026
## Summary
Implements #145 (replaces the
stale attempt in #130).
Every DialCache miss event now says **why** it missed. Until now, a miss
storm during a future-buffer invalidation window was indistinguishable
from a cold cache or an eviction problem: the miss counter only told you
*that* a layer missed, never whether the value was truly absent or a
stored frame was being rejected by an invalidation watermark. This PR
adds one required, bounded `reason` to the existing miss event — no new
metric instrument, no new Redis command, no extra round trip, and no
change to serving, refill, invalidation, stale-recovery, or shadow
behavior.
## What changes in your metrics
`miss` events now carry `reason` alongside the existing `cacheNamespace`
/ `useCase` / `keyType` / `layer` labels, on every layer
(`request_local`, `local`, `remote`, `remote_shadow`):
| `reason` | Fires when | Typical cause |
| --- | --- | --- |
| `value_absent` | The layer had no retrievable value at all: never
populated, physically expired or evicted, Redis `nil`, or a
tracked-`MGET` wrong-type member (which Redis reports as `nil`).
Request-local and process-local misses are always this. | Cold keys,
physical TTL expiry, eviction pressure. |
| `expired` | A complete, supported frame with a valid, non-future
timestamp was present, but its logical age against the reader's clock
reached the effective remote TTL `F`. Covers `age >= M` misses and `F <=
age < M` frames retained as stale-on-error candidates, on both caller
and shadow reads. | Ordinary TTL churn on hot keys; the routine
steady-state miss reason whenever `staleOnErrorMaxAgeSec` is configured.
|
| `watermark_fenced` | A complete, supported tracked frame with a
positive safe-integer timestamp was rejected because its `createdAtMs`
was at or below a valid observed invalidation watermark. Decided before
deserialization. | Reads inside an active `futureBufferMs` invalidation
window; writer/invalidator clock skew. |
| `unclassified` | The miss is real but attributable to none of the
above: unrecognized custom-adapter results (including `null`),
short/unsupported frames, zero-stamped tracked frames, malformed
watermark metadata paired with a present frame, future or invalid
timestamps, and deserialization failures. | Un-migrated custom adapters;
protocol-edge states. |
The operational win: during an invalidation window you can now separate
fence churn from genuine absence at a glance; routine TTL churn
(including the stale-on-error `F..M` window, where every read of a
retained frame is a miss) is `expired` rather than noise in the other
buckets; and because expiry no longer lands there, a sudden
`unclassified` plateau genuinely points at protocol-edge states (skewed
clocks, corrupt frames, deserialization failures, legacy adapters)
instead of hiding inside the aggregate.
**Deviation from #145:** the issue specified a three-value union and
named `unclassified` as the safe default for stale-on-error retained
frames "unless a separately justified reason is added". Review found
that with `staleOnErrorMaxAgeSec` configured every read of a retained
`F..M` frame is a miss, so `unclassified` would have been the routine
steady-state reason for exactly the use cases that enable
stale-on-error, and the plateau signal above would not hold. `expired`
is that separately justified reason; the union is four values and the
cardinality note below is 4x accordingly.
**Prometheus** — `dialcache_miss_counter` keeps its name and gains
`reason` as a fifth label:
```promql
# Fence churn by use case during an invalidation window
sum by (use_case) (rate(dialcache_miss_counter{layer="remote", reason="watermark_fenced"}[5m]))
# Pre-existing total-miss and hit-ratio queries: aggregate reason away
sum by (cache_namespace, use_case, key_type, layer) (rate(dialcache_miss_counter[5m]))
```
**Datadog** — `dialcache.miss.count` keeps its name and gains the same
`reason` tag, e.g. `sum:dialcache.miss.count{reason:watermark_fenced} by
{use_case}`. Expect up to 4× the miss-series cardinality (four bounded
reasons; only `remote` and `remote_shadow` tuples can carry every
reason, since request-local and local misses are always `value_absent`).
## Migration
- **Prometheus registries:** the label set changed, so registering
against a registry that already holds a 4-label `dialcache_miss_counter`
(e.g. a not-yet-upgraded sidecar library) fails loudly at construction —
upgrade producers together per registry. Nothing silently mislabels.
- **Mixed-fleet rollouts:** old processes emit reason-less series while
new ones carry `reason`. Total-miss and miss/request-ratio queries must
`sum by (...)` the shared labels (example above); reason-aware
dashboards should group by `reason` explicitly.
- **Custom metrics adapters:** `DialCacheMetricsAdapter.miss` now
receives `MissMetricLabels` (extends the unchanged `CacheMetricLabels`
with required `reason: CacheMissReason`). Adapters whose `miss`
parameter is typed as the broader `CacheMetricLabels` compile unchanged
and may ignore the field; exact label snapshots, exhaustive `Record`s
over reasons, and adapters that reject or forward unknown fields must
add it.
- **Custom Redis adapters:** `RedisReadResult` is now `DecodedRedisFrame
| RedisReadMiss { kind: "miss", reason, observedWatermarkMs? }`. `null`,
`RedisWatermarkMiss`, `decodeRedisFrame`, and `decodeTrackedRedisFrame`
are removed; use `decodeRedisReadResult` (untracked) /
`decodeTrackedRedisReadResult` (tracked) and the exported
`isRedisReadMiss` guard. An un-migrated adapter still fails open at
runtime (`null` or any unrecognized result is an `unclassified` miss
with a normal refill) but loses reason precision and fenced-refill
suppression until it returns the typed miss.
## How classification works
Bundled decoders return one miss shape, `RedisReadMiss { kind: "miss",
reason, observedWatermarkMs? }`, attaching `observedWatermarkMs` only
when the same atomic tracked snapshot carried a valid numeric watermark.
Cause and fence are deliberately independent: Redis `nil` is decisive
evidence of absence, so an absent value reports `value_absent` while
still carrying the observed watermark that lets PR 143's two-sample
admission skip a known-fenced refill — only a complete supported frame
actually rejected by the watermark reports `watermark_fenced`.
Core treats the adapter boundary as untrusted and normalizes every
result once, at one choke point: `kind: "miss"` is the only
discriminator; invalid or missing reasons become `unclassified`, a
`watermark_fenced` claim without a valid tracked fence is demoted to
`unclassified`, invalid fences (`NaN`, negatives, non-integers,
untracked keys) are dropped, and `null`, `undefined`, or any other
unrecognized value is an `unclassified` miss with a normal refill.
Objects without `kind: "miss"` are frame candidates and go through the
existing timestamp and serializer validation.
Decoded hits keep the existing `{ payload, createdAtMs }` shape.
Core-side rejections never acquire a refill fence: logical-age
rejections emit `expired`, future timestamps and deserialization
failures emit `unclassified`; conditional refill suppression remains
exactly PR 143's adapter-level watermark-miss path, byte-compatible with
`main` (differential-verified across the decode input matrix).
## Breaking change
`DialCacheMetricsAdapter.miss` now receives `MissMetricLabels`, and
first-party miss metrics require a `reason` label/tag. `RedisReadResult`
is now `DecodedRedisFrame | RedisReadMiss`; `null`,
`RedisWatermarkMiss`, `decodeRedisFrame`, and `decodeTrackedRedisFrame`
are removed, and bundled Redis adapters return `RedisReadMiss` for every
semantic miss.
Under the pre-1.0 release policy, this is a minor release.
## Validation
- Node.js 22.22.0: `corepack pnpm check`
- typecheck
- 656 unit tests with coverage thresholds
- ESM/CJS build and declaration generation
- packed-package type and runtime checks
- Node.js 22.22.0: `corepack pnpm test:integration`
- 143 passed
- 2 expected local GLIDE Cluster skips because announced container IPs
are not host-routable
- Review follow-up (`fix(metrics): preserve existing refill and decoder
behavior`) removed the fence-carry scope creep and restored
decoder/refill parity with `main`, differential-verified across the
decode input matrix
BREAKING CHANGE: DialCacheMetricsAdapter.miss now receives
MissMetricLabels and first-party miss metrics require a reason
label/tag; RedisReadResult is now DecodedRedisFrame | RedisReadMiss ({
kind: "miss", reason, observedWatermarkMs? }); null, RedisWatermarkMiss,
decodeRedisFrame, and decodeTrackedRedisFrame are removed, and bundled
Redis adapters return RedisReadMiss for every semantic miss.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@lan17
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(metrics)!: classify cache misses by bounded reason - #130

Closed
lan17 wants to merge 5 commits into
mainfrom
claude/cache-miss-watermark-instrumentation-7fee74
Closed

feat(metrics)!: classify cache misses by bounded reason#130
lan17 wants to merge 5 commits into
mainfrom
claude/cache-miss-watermark-instrumentation-7fee74

Conversation

@lan17

@lan17lan17 commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Summary

Cache misses were indistinguishable in metrics: the watermark fence decision in decodeTrackedRedisFrame collapsed "key absent", "frame unsupported", "watermark missing/malformed", and "fenced by watermark" into one null. Operators could not separate invalidation churn from cold keys.

The miss metric now carries a bounded reason label (mirroring the existing disabled{reason} precedent):

  • not_found — key absent/expired (all layers; local layers always use this)
  • frame_unsupported — short frame or unsupported frame version
  • watermark_unreadable — tracked read with missing/malformed/non-finite watermark
  • watermark_invalidated — tracked frame fenced (createdAt <= watermark)
  • deserialization_failed — payload read but serializer.load threw

sum by (use_case) (rate(dialcache_miss_counter{reason="watermark_invalidated"}[5m])) now measures invalidation churn directly, including the repeated stale-frame transfer cost during future-buffer windows. frame_unsupported/watermark_unreadable should be ~zero in steady state and make good anomaly alerts.

Design

  • Decoders and DialCacheRedisClient.read() return a discriminated RedisReadOutcome ({status:"hit",payload} | {status:"miss",reason}). The decoder remains the single source of truth for classification; bundled adapters needed zero code changes.
  • A runtime outcome guard in RedisCache rejects malformed client results (e.g. a stale client still returning payload | null) into the existing fail-open cache_read error path, so the label vocabulary cannot go unbounded.
  • Shadow reads emit reasons on remote_shadow; shadow fill behavior is unchanged — a fenced dark read stays fill-eligible because the tracked write script re-fences server-side (pinned by a new test).
  • Internal CacheGetResult types unchanged: misses are recorded where discovered, no caller re-records.

Breaking changes

  • DialCacheRedisClient.read() and exported decodeRedisFrame/decodeTrackedRedisFrame return RedisReadOutcome instead of RedisCachePayload | null. Custom clients delegating to the exported decoders need zero logic changes; plain-JS stragglers fail loudly open (typed error → cache_read → fallback), never mislabel.
  • dialcache_miss_counter gains a reason label — an old-schema collector in the same Prometheus registry now fails adapter construction (validateExistingCollectors). sum without (reason) totals are unchanged. Datadog gains an additive tag (wire-compatible).
  • Custom metrics adapters typed miss(labels: CacheMetricLabels) still compile; widen to MissMetricLabels to consume the reason.

Verification

  • pnpm check green: typecheck, 447 unit tests (coverage 97.5% lines / 94.7% branches, gates pass), build, packed-tarball consumer test (incl. exhaustive MissReason record and ESM/CJS decoder checks asserting watermark_invalidated)
  • pnpm test:integration green: 113 tests against real Redis 6.2, Valkey 8, and cluster
  • New tests: decoder reason boundaries, e2e fenced-vs-cold miss labels after invalidateRemote, malformed-outcome guard (5 shapes), fenced shadow-read fill parity, Prometheus/Datadog exhaustive-enum guards

lan17 added 5 commits August 7, 2026 14:24
Add a reason label to the miss metric so watermark-fenced misses are
separable from cold keys. Decoders and DialCacheRedisClient.read() now
return a discriminated RedisReadOutcome instead of payload-or-null,
with four bounded read-miss reasons (not_found, frame_unsupported,
watermark_unreadable, watermark_invalidated) plus a metrics-level
deserialization_failed for serializer.load failures. Local layers
always emit not_found. A runtime outcome guard routes malformed client
results through the existing fail-open cache_read error path so the
label vocabulary stays bounded.
BREAKING CHANGE: DialCacheRedisClient.read() and the exported
decodeRedisFrame/decodeTrackedRedisFrame return RedisReadOutcome
instead of RedisCachePayload | null, and dialcache_miss_counter gains
a reason label, so an old-schema collector in the same Prometheus
registry now fails adapter construction.
- Migrate all four benchmark fake clients to RedisReadOutcome shapes;
the legacy payload/null returns tripped the new outcome guard, failing
three scenarios on their own asserts and hanging the dark-fill one
- Single-source the guard's reason set via satisfies
Record<RedisReadMissReason, true> so a future reason cannot drift out
of it, and pin that a client-supplied deserialization_failed is
rejected as malformed
- Pin frame-before-watermark precedence with competing-bad-state
decoder assertions and a real-Redis mirror
- Delegate FakeRedis read classification to the exported decoders on a
copied frame instead of hand-mirroring the ladder
- Fix the stale shadow-spec sentence still defining a clean miss as a
null read; scope deserialization_failed to the remote layer; note
that only the first fenced read per invalidation window labels
watermark_invalidated; qualify not_found's wrong-type doc as
tracked-MGET-only
Collapse the decoder singletons, the guard's exhaustive flags object, and
its derived Set into a single REDIS_READ_MISS_OUTCOMES table: one frozen
outcome per bounded reason, returned by the decoders and now also the
normal form the core collapses every client miss onto. The read guard
(renamed normalizeRedisReadOutcome) reads each client-owned property
exactly once, recaptures hit payloads, and returns canonical singletons
for misses, so accessor-backed or otherwise unstable client objects can
never flip answers between validation and metric emission. Rejections
now carry a bounded shape fingerprint naming the malformed class -
legacy payload-or-null, non-object value, unknown status, unbounded or
core-owned reason, payload-less hit - so a stale-client migration storm
names its own cause.
Also: unify the two identical started-read interfaces as StartedRead<T>;
pin the accessor-flip behavior in both directions plus the toString
prototype-chain forgery; add the missing negative type pin that
deserialization_failed is not assignable to RedisReadMissReason.
The decoders and the read normalizer alias one frozen outcome per miss
reason, so a mutable entry would let a single consumer corrupt every
later miss and its bounded metric label process-wide; pin identity,
frozenness, and mutation rejection. Also narrow not_found's doc to the
tracked MGET value member - a wrong-type watermark member reads as nil
and classifies watermark_unreadable, as the README and integration
tests already state.
…ication
Conflicts were the adjacent bounded-vocabulary blocks in the adapter
tests (MISS_REASONS vs COMPRESSION_OUTCOMES / ERROR_KINDS with the new
compression kind) - resolved by keeping both. Compression operates on
serializer output inside the frame payload, one layer below the read
outcome classification, so the two features compose without semantic
changes; a rejected compression envelope surfaces as
deserialization_failed alongside its fallback_raw/read_over_limit
compression outcome, now cross-referenced in the miss-reasons table.
@lan17

Copy link
Copy Markdown
OwnerAuthor

Closing this stale, conflicting implementation in favor of the protocol-current reconciliation spec in #145. A fresh implementation PR will follow from current main; this branch should not be rebased mechanically.

@lan17lan17 closed this Aug 29, 2026
lan17 added a commit that referenced this pull request Sep 3, 2026
## Summary
Implements #145 (replaces the
stale attempt in #130).
Every DialCache miss event now says **why** it missed. Until now, a miss
storm during a future-buffer invalidation window was indistinguishable
from a cold cache or an eviction problem: the miss counter only told you
*that* a layer missed, never whether the value was truly absent or a
stored frame was being rejected by an invalidation watermark. This PR
adds one required, bounded `reason` to the existing miss event — no new
metric instrument, no new Redis command, no extra round trip, and no
change to serving, refill, invalidation, stale-recovery, or shadow
behavior.
## What changes in your metrics
`miss` events now carry `reason` alongside the existing `cacheNamespace`
/ `useCase` / `keyType` / `layer` labels, on every layer
(`request_local`, `local`, `remote`, `remote_shadow`):
| `reason` | Fires when | Typical cause |
| --- | --- | --- |
| `value_absent` | The layer had no retrievable value at all: never
populated, physically expired or evicted, Redis `nil`, or a
tracked-`MGET` wrong-type member (which Redis reports as `nil`).
Request-local and process-local misses are always this. | Cold keys,
physical TTL expiry, eviction pressure. |
| `expired` | A complete, supported frame with a valid, non-future
timestamp was present, but its logical age against the reader's clock
reached the effective remote TTL `F`. Covers `age >= M` misses and `F <=
age < M` frames retained as stale-on-error candidates, on both caller
and shadow reads. | Ordinary TTL churn on hot keys; the routine
steady-state miss reason whenever `staleOnErrorMaxAgeSec` is configured.
|
| `watermark_fenced` | A complete, supported tracked frame with a
positive safe-integer timestamp was rejected because its `createdAtMs`
was at or below a valid observed invalidation watermark. Decided before
deserialization. | Reads inside an active `futureBufferMs` invalidation
window; writer/invalidator clock skew. |
| `unclassified` | The miss is real but attributable to none of the
above: unrecognized custom-adapter results (including `null`),
short/unsupported frames, zero-stamped tracked frames, malformed
watermark metadata paired with a present frame, future or invalid
timestamps, and deserialization failures. | Un-migrated custom adapters;
protocol-edge states. |
The operational win: during an invalidation window you can now separate
fence churn from genuine absence at a glance; routine TTL churn
(including the stale-on-error `F..M` window, where every read of a
retained frame is a miss) is `expired` rather than noise in the other
buckets; and because expiry no longer lands there, a sudden
`unclassified` plateau genuinely points at protocol-edge states (skewed
clocks, corrupt frames, deserialization failures, legacy adapters)
instead of hiding inside the aggregate.
**Deviation from #145:** the issue specified a three-value union and
named `unclassified` as the safe default for stale-on-error retained
frames "unless a separately justified reason is added". Review found
that with `staleOnErrorMaxAgeSec` configured every read of a retained
`F..M` frame is a miss, so `unclassified` would have been the routine
steady-state reason for exactly the use cases that enable
stale-on-error, and the plateau signal above would not hold. `expired`
is that separately justified reason; the union is four values and the
cardinality note below is 4x accordingly.
**Prometheus** — `dialcache_miss_counter` keeps its name and gains
`reason` as a fifth label:
```promql
# Fence churn by use case during an invalidation window
sum by (use_case) (rate(dialcache_miss_counter{layer="remote", reason="watermark_fenced"}[5m]))
# Pre-existing total-miss and hit-ratio queries: aggregate reason away
sum by (cache_namespace, use_case, key_type, layer) (rate(dialcache_miss_counter[5m]))
```
**Datadog** — `dialcache.miss.count` keeps its name and gains the same
`reason` tag, e.g. `sum:dialcache.miss.count{reason:watermark_fenced} by
{use_case}`. Expect up to 4× the miss-series cardinality (four bounded
reasons; only `remote` and `remote_shadow` tuples can carry every
reason, since request-local and local misses are always `value_absent`).
## Migration
- **Prometheus registries:** the label set changed, so registering
against a registry that already holds a 4-label `dialcache_miss_counter`
(e.g. a not-yet-upgraded sidecar library) fails loudly at construction —
upgrade producers together per registry. Nothing silently mislabels.
- **Mixed-fleet rollouts:** old processes emit reason-less series while
new ones carry `reason`. Total-miss and miss/request-ratio queries must
`sum by (...)` the shared labels (example above); reason-aware
dashboards should group by `reason` explicitly.
- **Custom metrics adapters:** `DialCacheMetricsAdapter.miss` now
receives `MissMetricLabels` (extends the unchanged `CacheMetricLabels`
with required `reason: CacheMissReason`). Adapters whose `miss`
parameter is typed as the broader `CacheMetricLabels` compile unchanged
and may ignore the field; exact label snapshots, exhaustive `Record`s
over reasons, and adapters that reject or forward unknown fields must
add it.
- **Custom Redis adapters:** `RedisReadResult` is now `DecodedRedisFrame
| RedisReadMiss { kind: "miss", reason, observedWatermarkMs? }`. `null`,
`RedisWatermarkMiss`, `decodeRedisFrame`, and `decodeTrackedRedisFrame`
are removed; use `decodeRedisReadResult` (untracked) /
`decodeTrackedRedisReadResult` (tracked) and the exported
`isRedisReadMiss` guard. An un-migrated adapter still fails open at
runtime (`null` or any unrecognized result is an `unclassified` miss
with a normal refill) but loses reason precision and fenced-refill
suppression until it returns the typed miss.
## How classification works
Bundled decoders return one miss shape, `RedisReadMiss { kind: "miss",
reason, observedWatermarkMs? }`, attaching `observedWatermarkMs` only
when the same atomic tracked snapshot carried a valid numeric watermark.
Cause and fence are deliberately independent: Redis `nil` is decisive
evidence of absence, so an absent value reports `value_absent` while
still carrying the observed watermark that lets PR 143's two-sample
admission skip a known-fenced refill — only a complete supported frame
actually rejected by the watermark reports `watermark_fenced`.
Core treats the adapter boundary as untrusted and normalizes every
result once, at one choke point: `kind: "miss"` is the only
discriminator; invalid or missing reasons become `unclassified`, a
`watermark_fenced` claim without a valid tracked fence is demoted to
`unclassified`, invalid fences (`NaN`, negatives, non-integers,
untracked keys) are dropped, and `null`, `undefined`, or any other
unrecognized value is an `unclassified` miss with a normal refill.
Objects without `kind: "miss"` are frame candidates and go through the
existing timestamp and serializer validation.
Decoded hits keep the existing `{ payload, createdAtMs }` shape.
Core-side rejections never acquire a refill fence: logical-age
rejections emit `expired`, future timestamps and deserialization
failures emit `unclassified`; conditional refill suppression remains
exactly PR 143's adapter-level watermark-miss path, byte-compatible with
`main` (differential-verified across the decode input matrix).
## Breaking change
`DialCacheMetricsAdapter.miss` now receives `MissMetricLabels`, and
first-party miss metrics require a `reason` label/tag. `RedisReadResult`
is now `DecodedRedisFrame | RedisReadMiss`; `null`,
`RedisWatermarkMiss`, `decodeRedisFrame`, and `decodeTrackedRedisFrame`
are removed, and bundled Redis adapters return `RedisReadMiss` for every
semantic miss.
Under the pre-1.0 release policy, this is a minor release.
## Validation
- Node.js 22.22.0: `corepack pnpm check`
- typecheck
- 656 unit tests with coverage thresholds
- ESM/CJS build and declaration generation
- packed-package type and runtime checks
- Node.js 22.22.0: `corepack pnpm test:integration`
- 143 passed
- 2 expected local GLIDE Cluster skips because announced container IPs
are not host-routable
- Review follow-up (`fix(metrics): preserve existing refill and decoder
behavior`) removed the fence-carry scope creep and restored
decoder/refill parity with `main`, differential-verified across the
decode input matrix
BREAKING CHANGE: DialCacheMetricsAdapter.miss now receives
MissMetricLabels and first-party miss metrics require a reason
label/tag; RedisReadResult is now DecodedRedisFrame | RedisReadMiss ({
kind: "miss", reason, observedWatermarkMs? }); null, RedisWatermarkMiss,
decodeRedisFrame, and decodeTrackedRedisFrame are removed, and bundled
Redis adapters return RedisReadMiss for every semantic miss.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@lan17
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(metrics)!: classify cache misses by bounded reason - #130

Closed
lan17 wants to merge 5 commits into
mainfrom
claude/cache-miss-watermark-instrumentation-7fee74
Closed

feat(metrics)!: classify cache misses by bounded reason#130
lan17 wants to merge 5 commits into
mainfrom
claude/cache-miss-watermark-instrumentation-7fee74

Conversation

@lan17

@lan17lan17 commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Summary

Cache misses were indistinguishable in metrics: the watermark fence decision in decodeTrackedRedisFrame collapsed "key absent", "frame unsupported", "watermark missing/malformed", and "fenced by watermark" into one null. Operators could not separate invalidation churn from cold keys.

The miss metric now carries a bounded reason label (mirroring the existing disabled{reason} precedent):

  • not_found — key absent/expired (all layers; local layers always use this)
  • frame_unsupported — short frame or unsupported frame version
  • watermark_unreadable — tracked read with missing/malformed/non-finite watermark
  • watermark_invalidated — tracked frame fenced (createdAt <= watermark)
  • deserialization_failed — payload read but serializer.load threw

sum by (use_case) (rate(dialcache_miss_counter{reason="watermark_invalidated"}[5m])) now measures invalidation churn directly, including the repeated stale-frame transfer cost during future-buffer windows. frame_unsupported/watermark_unreadable should be ~zero in steady state and make good anomaly alerts.

Design

  • Decoders and DialCacheRedisClient.read() return a discriminated RedisReadOutcome ({status:"hit",payload} | {status:"miss",reason}). The decoder remains the single source of truth for classification; bundled adapters needed zero code changes.
  • A runtime outcome guard in RedisCache rejects malformed client results (e.g. a stale client still returning payload | null) into the existing fail-open cache_read error path, so the label vocabulary cannot go unbounded.
  • Shadow reads emit reasons on remote_shadow; shadow fill behavior is unchanged — a fenced dark read stays fill-eligible because the tracked write script re-fences server-side (pinned by a new test).
  • Internal CacheGetResult types unchanged: misses are recorded where discovered, no caller re-records.

Breaking changes

  • DialCacheRedisClient.read() and exported decodeRedisFrame/decodeTrackedRedisFrame return RedisReadOutcome instead of RedisCachePayload | null. Custom clients delegating to the exported decoders need zero logic changes; plain-JS stragglers fail loudly open (typed error → cache_read → fallback), never mislabel.
  • dialcache_miss_counter gains a reason label — an old-schema collector in the same Prometheus registry now fails adapter construction (validateExistingCollectors). sum without (reason) totals are unchanged. Datadog gains an additive tag (wire-compatible).
  • Custom metrics adapters typed miss(labels: CacheMetricLabels) still compile; widen to MissMetricLabels to consume the reason.

Verification

  • pnpm check green: typecheck, 447 unit tests (coverage 97.5% lines / 94.7% branches, gates pass), build, packed-tarball consumer test (incl. exhaustive MissReason record and ESM/CJS decoder checks asserting watermark_invalidated)
  • pnpm test:integration green: 113 tests against real Redis 6.2, Valkey 8, and cluster
  • New tests: decoder reason boundaries, e2e fenced-vs-cold miss labels after invalidateRemote, malformed-outcome guard (5 shapes), fenced shadow-read fill parity, Prometheus/Datadog exhaustive-enum guards

lan17 added 5 commits August 7, 2026 14:24
Add a reason label to the miss metric so watermark-fenced misses are
separable from cold keys. Decoders and DialCacheRedisClient.read() now
return a discriminated RedisReadOutcome instead of payload-or-null,
with four bounded read-miss reasons (not_found, frame_unsupported,
watermark_unreadable, watermark_invalidated) plus a metrics-level
deserialization_failed for serializer.load failures. Local layers
always emit not_found. A runtime outcome guard routes malformed client
results through the existing fail-open cache_read error path so the
label vocabulary stays bounded.
BREAKING CHANGE: DialCacheRedisClient.read() and the exported
decodeRedisFrame/decodeTrackedRedisFrame return RedisReadOutcome
instead of RedisCachePayload | null, and dialcache_miss_counter gains
a reason label, so an old-schema collector in the same Prometheus
registry now fails adapter construction.
- Migrate all four benchmark fake clients to RedisReadOutcome shapes;
the legacy payload/null returns tripped the new outcome guard, failing
three scenarios on their own asserts and hanging the dark-fill one
- Single-source the guard's reason set via satisfies
Record<RedisReadMissReason, true> so a future reason cannot drift out
of it, and pin that a client-supplied deserialization_failed is
rejected as malformed
- Pin frame-before-watermark precedence with competing-bad-state
decoder assertions and a real-Redis mirror
- Delegate FakeRedis read classification to the exported decoders on a
copied frame instead of hand-mirroring the ladder
- Fix the stale shadow-spec sentence still defining a clean miss as a
null read; scope deserialization_failed to the remote layer; note
that only the first fenced read per invalidation window labels
watermark_invalidated; qualify not_found's wrong-type doc as
tracked-MGET-only
Collapse the decoder singletons, the guard's exhaustive flags object, and
its derived Set into a single REDIS_READ_MISS_OUTCOMES table: one frozen
outcome per bounded reason, returned by the decoders and now also the
normal form the core collapses every client miss onto. The read guard
(renamed normalizeRedisReadOutcome) reads each client-owned property
exactly once, recaptures hit payloads, and returns canonical singletons
for misses, so accessor-backed or otherwise unstable client objects can
never flip answers between validation and metric emission. Rejections
now carry a bounded shape fingerprint naming the malformed class -
legacy payload-or-null, non-object value, unknown status, unbounded or
core-owned reason, payload-less hit - so a stale-client migration storm
names its own cause.
Also: unify the two identical started-read interfaces as StartedRead<T>;
pin the accessor-flip behavior in both directions plus the toString
prototype-chain forgery; add the missing negative type pin that
deserialization_failed is not assignable to RedisReadMissReason.
The decoders and the read normalizer alias one frozen outcome per miss
reason, so a mutable entry would let a single consumer corrupt every
later miss and its bounded metric label process-wide; pin identity,
frozenness, and mutation rejection. Also narrow not_found's doc to the
tracked MGET value member - a wrong-type watermark member reads as nil
and classifies watermark_unreadable, as the README and integration
tests already state.
…ication
Conflicts were the adjacent bounded-vocabulary blocks in the adapter
tests (MISS_REASONS vs COMPRESSION_OUTCOMES / ERROR_KINDS with the new
compression kind) - resolved by keeping both. Compression operates on
serializer output inside the frame payload, one layer below the read
outcome classification, so the two features compose without semantic
changes; a rejected compression envelope surfaces as
deserialization_failed alongside its fallback_raw/read_over_limit
compression outcome, now cross-referenced in the miss-reasons table.
@lan17

Copy link
Copy Markdown
OwnerAuthor

Closing this stale, conflicting implementation in favor of the protocol-current reconciliation spec in #145. A fresh implementation PR will follow from current main; this branch should not be rebased mechanically.

@lan17lan17 closed this Aug 29, 2026
lan17 added a commit that referenced this pull request Sep 3, 2026
## Summary
Implements #145 (replaces the
stale attempt in #130).
Every DialCache miss event now says **why** it missed. Until now, a miss
storm during a future-buffer invalidation window was indistinguishable
from a cold cache or an eviction problem: the miss counter only told you
*that* a layer missed, never whether the value was truly absent or a
stored frame was being rejected by an invalidation watermark. This PR
adds one required, bounded `reason` to the existing miss event — no new
metric instrument, no new Redis command, no extra round trip, and no
change to serving, refill, invalidation, stale-recovery, or shadow
behavior.
## What changes in your metrics
`miss` events now carry `reason` alongside the existing `cacheNamespace`
/ `useCase` / `keyType` / `layer` labels, on every layer
(`request_local`, `local`, `remote`, `remote_shadow`):
| `reason` | Fires when | Typical cause |
| --- | --- | --- |
| `value_absent` | The layer had no retrievable value at all: never
populated, physically expired or evicted, Redis `nil`, or a
tracked-`MGET` wrong-type member (which Redis reports as `nil`).
Request-local and process-local misses are always this. | Cold keys,
physical TTL expiry, eviction pressure. |
| `expired` | A complete, supported frame with a valid, non-future
timestamp was present, but its logical age against the reader's clock
reached the effective remote TTL `F`. Covers `age >= M` misses and `F <=
age < M` frames retained as stale-on-error candidates, on both caller
and shadow reads. | Ordinary TTL churn on hot keys; the routine
steady-state miss reason whenever `staleOnErrorMaxAgeSec` is configured.
|
| `watermark_fenced` | A complete, supported tracked frame with a
positive safe-integer timestamp was rejected because its `createdAtMs`
was at or below a valid observed invalidation watermark. Decided before
deserialization. | Reads inside an active `futureBufferMs` invalidation
window; writer/invalidator clock skew. |
| `unclassified` | The miss is real but attributable to none of the
above: unrecognized custom-adapter results (including `null`),
short/unsupported frames, zero-stamped tracked frames, malformed
watermark metadata paired with a present frame, future or invalid
timestamps, and deserialization failures. | Un-migrated custom adapters;
protocol-edge states. |
The operational win: during an invalidation window you can now separate
fence churn from genuine absence at a glance; routine TTL churn
(including the stale-on-error `F..M` window, where every read of a
retained frame is a miss) is `expired` rather than noise in the other
buckets; and because expiry no longer lands there, a sudden
`unclassified` plateau genuinely points at protocol-edge states (skewed
clocks, corrupt frames, deserialization failures, legacy adapters)
instead of hiding inside the aggregate.
**Deviation from #145:** the issue specified a three-value union and
named `unclassified` as the safe default for stale-on-error retained
frames "unless a separately justified reason is added". Review found
that with `staleOnErrorMaxAgeSec` configured every read of a retained
`F..M` frame is a miss, so `unclassified` would have been the routine
steady-state reason for exactly the use cases that enable
stale-on-error, and the plateau signal above would not hold. `expired`
is that separately justified reason; the union is four values and the
cardinality note below is 4x accordingly.
**Prometheus** — `dialcache_miss_counter` keeps its name and gains
`reason` as a fifth label:
```promql
# Fence churn by use case during an invalidation window
sum by (use_case) (rate(dialcache_miss_counter{layer="remote", reason="watermark_fenced"}[5m]))
# Pre-existing total-miss and hit-ratio queries: aggregate reason away
sum by (cache_namespace, use_case, key_type, layer) (rate(dialcache_miss_counter[5m]))
```
**Datadog** — `dialcache.miss.count` keeps its name and gains the same
`reason` tag, e.g. `sum:dialcache.miss.count{reason:watermark_fenced} by
{use_case}`. Expect up to 4× the miss-series cardinality (four bounded
reasons; only `remote` and `remote_shadow` tuples can carry every
reason, since request-local and local misses are always `value_absent`).
## Migration
- **Prometheus registries:** the label set changed, so registering
against a registry that already holds a 4-label `dialcache_miss_counter`
(e.g. a not-yet-upgraded sidecar library) fails loudly at construction —
upgrade producers together per registry. Nothing silently mislabels.
- **Mixed-fleet rollouts:** old processes emit reason-less series while
new ones carry `reason`. Total-miss and miss/request-ratio queries must
`sum by (...)` the shared labels (example above); reason-aware
dashboards should group by `reason` explicitly.
- **Custom metrics adapters:** `DialCacheMetricsAdapter.miss` now
receives `MissMetricLabels` (extends the unchanged `CacheMetricLabels`
with required `reason: CacheMissReason`). Adapters whose `miss`
parameter is typed as the broader `CacheMetricLabels` compile unchanged
and may ignore the field; exact label snapshots, exhaustive `Record`s
over reasons, and adapters that reject or forward unknown fields must
add it.
- **Custom Redis adapters:** `RedisReadResult` is now `DecodedRedisFrame
| RedisReadMiss { kind: "miss", reason, observedWatermarkMs? }`. `null`,
`RedisWatermarkMiss`, `decodeRedisFrame`, and `decodeTrackedRedisFrame`
are removed; use `decodeRedisReadResult` (untracked) /
`decodeTrackedRedisReadResult` (tracked) and the exported
`isRedisReadMiss` guard. An un-migrated adapter still fails open at
runtime (`null` or any unrecognized result is an `unclassified` miss
with a normal refill) but loses reason precision and fenced-refill
suppression until it returns the typed miss.
## How classification works
Bundled decoders return one miss shape, `RedisReadMiss { kind: "miss",
reason, observedWatermarkMs? }`, attaching `observedWatermarkMs` only
when the same atomic tracked snapshot carried a valid numeric watermark.
Cause and fence are deliberately independent: Redis `nil` is decisive
evidence of absence, so an absent value reports `value_absent` while
still carrying the observed watermark that lets PR 143's two-sample
admission skip a known-fenced refill — only a complete supported frame
actually rejected by the watermark reports `watermark_fenced`.
Core treats the adapter boundary as untrusted and normalizes every
result once, at one choke point: `kind: "miss"` is the only
discriminator; invalid or missing reasons become `unclassified`, a
`watermark_fenced` claim without a valid tracked fence is demoted to
`unclassified`, invalid fences (`NaN`, negatives, non-integers,
untracked keys) are dropped, and `null`, `undefined`, or any other
unrecognized value is an `unclassified` miss with a normal refill.
Objects without `kind: "miss"` are frame candidates and go through the
existing timestamp and serializer validation.
Decoded hits keep the existing `{ payload, createdAtMs }` shape.
Core-side rejections never acquire a refill fence: logical-age
rejections emit `expired`, future timestamps and deserialization
failures emit `unclassified`; conditional refill suppression remains
exactly PR 143's adapter-level watermark-miss path, byte-compatible with
`main` (differential-verified across the decode input matrix).
## Breaking change
`DialCacheMetricsAdapter.miss` now receives `MissMetricLabels`, and
first-party miss metrics require a `reason` label/tag. `RedisReadResult`
is now `DecodedRedisFrame | RedisReadMiss`; `null`,
`RedisWatermarkMiss`, `decodeRedisFrame`, and `decodeTrackedRedisFrame`
are removed, and bundled Redis adapters return `RedisReadMiss` for every
semantic miss.
Under the pre-1.0 release policy, this is a minor release.
## Validation
- Node.js 22.22.0: `corepack pnpm check`
- typecheck
- 656 unit tests with coverage thresholds
- ESM/CJS build and declaration generation
- packed-package type and runtime checks
- Node.js 22.22.0: `corepack pnpm test:integration`
- 143 passed
- 2 expected local GLIDE Cluster skips because announced container IPs
are not host-routable
- Review follow-up (`fix(metrics): preserve existing refill and decoder
behavior`) removed the fence-carry scope creep and restored
decoder/refill parity with `main`, differential-verified across the
decode input matrix
BREAKING CHANGE: DialCacheMetricsAdapter.miss now receives
MissMetricLabels and first-party miss metrics require a reason
label/tag; RedisReadResult is now DecodedRedisFrame | RedisReadMiss ({
kind: "miss", reason, observedWatermarkMs? }); null, RedisWatermarkMiss,
decodeRedisFrame, and decodeTrackedRedisFrame are removed, and bundled
Redis adapters return RedisReadMiss for every semantic miss.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@lan17
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

feat(metrics)!: classify cache misses by bounded reason - #130

Closed
lan17 wants to merge 5 commits into
mainfrom
claude/cache-miss-watermark-instrumentation-7fee74
Closed

feat(metrics)!: classify cache misses by bounded reason#130
lan17 wants to merge 5 commits into
mainfrom
claude/cache-miss-watermark-instrumentation-7fee74

Conversation

@lan17

@lan17lan17 commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Summary

Cache misses were indistinguishable in metrics: the watermark fence decision in decodeTrackedRedisFrame collapsed "key absent", "frame unsupported", "watermark missing/malformed", and "fenced by watermark" into one null. Operators could not separate invalidation churn from cold keys.

The miss metric now carries a bounded reason label (mirroring the existing disabled{reason} precedent):

  • not_found — key absent/expired (all layers; local layers always use this)
  • frame_unsupported — short frame or unsupported frame version
  • watermark_unreadable — tracked read with missing/malformed/non-finite watermark
  • watermark_invalidated — tracked frame fenced (createdAt <= watermark)
  • deserialization_failed — payload read but serializer.load threw

sum by (use_case) (rate(dialcache_miss_counter{reason="watermark_invalidated"}[5m])) now measures invalidation churn directly, including the repeated stale-frame transfer cost during future-buffer windows. frame_unsupported/watermark_unreadable should be ~zero in steady state and make good anomaly alerts.

Design

  • Decoders and DialCacheRedisClient.read() return a discriminated RedisReadOutcome ({status:"hit",payload} | {status:"miss",reason}). The decoder remains the single source of truth for classification; bundled adapters needed zero code changes.
  • A runtime outcome guard in RedisCache rejects malformed client results (e.g. a stale client still returning payload | null) into the existing fail-open cache_read error path, so the label vocabulary cannot go unbounded.
  • Shadow reads emit reasons on remote_shadow; shadow fill behavior is unchanged — a fenced dark read stays fill-eligible because the tracked write script re-fences server-side (pinned by a new test).
  • Internal CacheGetResult types unchanged: misses are recorded where discovered, no caller re-records.

Breaking changes

  • DialCacheRedisClient.read() and exported decodeRedisFrame/decodeTrackedRedisFrame return RedisReadOutcome instead of RedisCachePayload | null. Custom clients delegating to the exported decoders need zero logic changes; plain-JS stragglers fail loudly open (typed error → cache_read → fallback), never mislabel.
  • dialcache_miss_counter gains a reason label — an old-schema collector in the same Prometheus registry now fails adapter construction (validateExistingCollectors). sum without (reason) totals are unchanged. Datadog gains an additive tag (wire-compatible).
  • Custom metrics adapters typed miss(labels: CacheMetricLabels) still compile; widen to MissMetricLabels to consume the reason.

Verification

  • pnpm check green: typecheck, 447 unit tests (coverage 97.5% lines / 94.7% branches, gates pass), build, packed-tarball consumer test (incl. exhaustive MissReason record and ESM/CJS decoder checks asserting watermark_invalidated)
  • pnpm test:integration green: 113 tests against real Redis 6.2, Valkey 8, and cluster
  • New tests: decoder reason boundaries, e2e fenced-vs-cold miss labels after invalidateRemote, malformed-outcome guard (5 shapes), fenced shadow-read fill parity, Prometheus/Datadog exhaustive-enum guards

lan17 added 5 commits August 7, 2026 14:24
Add a reason label to the miss metric so watermark-fenced misses are
separable from cold keys. Decoders and DialCacheRedisClient.read() now
return a discriminated RedisReadOutcome instead of payload-or-null,
with four bounded read-miss reasons (not_found, frame_unsupported,
watermark_unreadable, watermark_invalidated) plus a metrics-level
deserialization_failed for serializer.load failures. Local layers
always emit not_found. A runtime outcome guard routes malformed client
results through the existing fail-open cache_read error path so the
label vocabulary stays bounded.
BREAKING CHANGE: DialCacheRedisClient.read() and the exported
decodeRedisFrame/decodeTrackedRedisFrame return RedisReadOutcome
instead of RedisCachePayload | null, and dialcache_miss_counter gains
a reason label, so an old-schema collector in the same Prometheus
registry now fails adapter construction.
- Migrate all four benchmark fake clients to RedisReadOutcome shapes;
the legacy payload/null returns tripped the new outcome guard, failing
three scenarios on their own asserts and hanging the dark-fill one
- Single-source the guard's reason set via satisfies
Record<RedisReadMissReason, true> so a future reason cannot drift out
of it, and pin that a client-supplied deserialization_failed is
rejected as malformed
- Pin frame-before-watermark precedence with competing-bad-state
decoder assertions and a real-Redis mirror
- Delegate FakeRedis read classification to the exported decoders on a
copied frame instead of hand-mirroring the ladder
- Fix the stale shadow-spec sentence still defining a clean miss as a
null read; scope deserialization_failed to the remote layer; note
that only the first fenced read per invalidation window labels
watermark_invalidated; qualify not_found's wrong-type doc as
tracked-MGET-only
Collapse the decoder singletons, the guard's exhaustive flags object, and
its derived Set into a single REDIS_READ_MISS_OUTCOMES table: one frozen
outcome per bounded reason, returned by the decoders and now also the
normal form the core collapses every client miss onto. The read guard
(renamed normalizeRedisReadOutcome) reads each client-owned property
exactly once, recaptures hit payloads, and returns canonical singletons
for misses, so accessor-backed or otherwise unstable client objects can
never flip answers between validation and metric emission. Rejections
now carry a bounded shape fingerprint naming the malformed class -
legacy payload-or-null, non-object value, unknown status, unbounded or
core-owned reason, payload-less hit - so a stale-client migration storm
names its own cause.
Also: unify the two identical started-read interfaces as StartedRead<T>;
pin the accessor-flip behavior in both directions plus the toString
prototype-chain forgery; add the missing negative type pin that
deserialization_failed is not assignable to RedisReadMissReason.
The decoders and the read normalizer alias one frozen outcome per miss
reason, so a mutable entry would let a single consumer corrupt every
later miss and its bounded metric label process-wide; pin identity,
frozenness, and mutation rejection. Also narrow not_found's doc to the
tracked MGET value member - a wrong-type watermark member reads as nil
and classifies watermark_unreadable, as the README and integration
tests already state.
…ication
Conflicts were the adjacent bounded-vocabulary blocks in the adapter
tests (MISS_REASONS vs COMPRESSION_OUTCOMES / ERROR_KINDS with the new
compression kind) - resolved by keeping both. Compression operates on
serializer output inside the frame payload, one layer below the read
outcome classification, so the two features compose without semantic
changes; a rejected compression envelope surfaces as
deserialization_failed alongside its fallback_raw/read_over_limit
compression outcome, now cross-referenced in the miss-reasons table.
@lan17

Copy link
Copy Markdown
OwnerAuthor

Closing this stale, conflicting implementation in favor of the protocol-current reconciliation spec in #145. A fresh implementation PR will follow from current main; this branch should not be rebased mechanically.

@lan17lan17 closed this Aug 29, 2026
lan17 added a commit that referenced this pull request Sep 3, 2026
## Summary
Implements #145 (replaces the
stale attempt in #130).
Every DialCache miss event now says **why** it missed. Until now, a miss
storm during a future-buffer invalidation window was indistinguishable
from a cold cache or an eviction problem: the miss counter only told you
*that* a layer missed, never whether the value was truly absent or a
stored frame was being rejected by an invalidation watermark. This PR
adds one required, bounded `reason` to the existing miss event — no new
metric instrument, no new Redis command, no extra round trip, and no
change to serving, refill, invalidation, stale-recovery, or shadow
behavior.
## What changes in your metrics
`miss` events now carry `reason` alongside the existing `cacheNamespace`
/ `useCase` / `keyType` / `layer` labels, on every layer
(`request_local`, `local`, `remote`, `remote_shadow`):
| `reason` | Fires when | Typical cause |
| --- | --- | --- |
| `value_absent` | The layer had no retrievable value at all: never
populated, physically expired or evicted, Redis `nil`, or a
tracked-`MGET` wrong-type member (which Redis reports as `nil`).
Request-local and process-local misses are always this. | Cold keys,
physical TTL expiry, eviction pressure. |
| `expired` | A complete, supported frame with a valid, non-future
timestamp was present, but its logical age against the reader's clock
reached the effective remote TTL `F`. Covers `age >= M` misses and `F <=
age < M` frames retained as stale-on-error candidates, on both caller
and shadow reads. | Ordinary TTL churn on hot keys; the routine
steady-state miss reason whenever `staleOnErrorMaxAgeSec` is configured.
|
| `watermark_fenced` | A complete, supported tracked frame with a
positive safe-integer timestamp was rejected because its `createdAtMs`
was at or below a valid observed invalidation watermark. Decided before
deserialization. | Reads inside an active `futureBufferMs` invalidation
window; writer/invalidator clock skew. |
| `unclassified` | The miss is real but attributable to none of the
above: unrecognized custom-adapter results (including `null`),
short/unsupported frames, zero-stamped tracked frames, malformed
watermark metadata paired with a present frame, future or invalid
timestamps, and deserialization failures. | Un-migrated custom adapters;
protocol-edge states. |
The operational win: during an invalidation window you can now separate
fence churn from genuine absence at a glance; routine TTL churn
(including the stale-on-error `F..M` window, where every read of a
retained frame is a miss) is `expired` rather than noise in the other
buckets; and because expiry no longer lands there, a sudden
`unclassified` plateau genuinely points at protocol-edge states (skewed
clocks, corrupt frames, deserialization failures, legacy adapters)
instead of hiding inside the aggregate.
**Deviation from #145:** the issue specified a three-value union and
named `unclassified` as the safe default for stale-on-error retained
frames "unless a separately justified reason is added". Review found
that with `staleOnErrorMaxAgeSec` configured every read of a retained
`F..M` frame is a miss, so `unclassified` would have been the routine
steady-state reason for exactly the use cases that enable
stale-on-error, and the plateau signal above would not hold. `expired`
is that separately justified reason; the union is four values and the
cardinality note below is 4x accordingly.
**Prometheus** — `dialcache_miss_counter` keeps its name and gains
`reason` as a fifth label:
```promql
# Fence churn by use case during an invalidation window
sum by (use_case) (rate(dialcache_miss_counter{layer="remote", reason="watermark_fenced"}[5m]))
# Pre-existing total-miss and hit-ratio queries: aggregate reason away
sum by (cache_namespace, use_case, key_type, layer) (rate(dialcache_miss_counter[5m]))
```
**Datadog** — `dialcache.miss.count` keeps its name and gains the same
`reason` tag, e.g. `sum:dialcache.miss.count{reason:watermark_fenced} by
{use_case}`. Expect up to 4× the miss-series cardinality (four bounded
reasons; only `remote` and `remote_shadow` tuples can carry every
reason, since request-local and local misses are always `value_absent`).
## Migration
- **Prometheus registries:** the label set changed, so registering
against a registry that already holds a 4-label `dialcache_miss_counter`
(e.g. a not-yet-upgraded sidecar library) fails loudly at construction —
upgrade producers together per registry. Nothing silently mislabels.
- **Mixed-fleet rollouts:** old processes emit reason-less series while
new ones carry `reason`. Total-miss and miss/request-ratio queries must
`sum by (...)` the shared labels (example above); reason-aware
dashboards should group by `reason` explicitly.
- **Custom metrics adapters:** `DialCacheMetricsAdapter.miss` now
receives `MissMetricLabels` (extends the unchanged `CacheMetricLabels`
with required `reason: CacheMissReason`). Adapters whose `miss`
parameter is typed as the broader `CacheMetricLabels` compile unchanged
and may ignore the field; exact label snapshots, exhaustive `Record`s
over reasons, and adapters that reject or forward unknown fields must
add it.
- **Custom Redis adapters:** `RedisReadResult` is now `DecodedRedisFrame
| RedisReadMiss { kind: "miss", reason, observedWatermarkMs? }`. `null`,
`RedisWatermarkMiss`, `decodeRedisFrame`, and `decodeTrackedRedisFrame`
are removed; use `decodeRedisReadResult` (untracked) /
`decodeTrackedRedisReadResult` (tracked) and the exported
`isRedisReadMiss` guard. An un-migrated adapter still fails open at
runtime (`null` or any unrecognized result is an `unclassified` miss
with a normal refill) but loses reason precision and fenced-refill
suppression until it returns the typed miss.
## How classification works
Bundled decoders return one miss shape, `RedisReadMiss { kind: "miss",
reason, observedWatermarkMs? }`, attaching `observedWatermarkMs` only
when the same atomic tracked snapshot carried a valid numeric watermark.
Cause and fence are deliberately independent: Redis `nil` is decisive
evidence of absence, so an absent value reports `value_absent` while
still carrying the observed watermark that lets PR 143's two-sample
admission skip a known-fenced refill — only a complete supported frame
actually rejected by the watermark reports `watermark_fenced`.
Core treats the adapter boundary as untrusted and normalizes every
result once, at one choke point: `kind: "miss"` is the only
discriminator; invalid or missing reasons become `unclassified`, a
`watermark_fenced` claim without a valid tracked fence is demoted to
`unclassified`, invalid fences (`NaN`, negatives, non-integers,
untracked keys) are dropped, and `null`, `undefined`, or any other
unrecognized value is an `unclassified` miss with a normal refill.
Objects without `kind: "miss"` are frame candidates and go through the
existing timestamp and serializer validation.
Decoded hits keep the existing `{ payload, createdAtMs }` shape.
Core-side rejections never acquire a refill fence: logical-age
rejections emit `expired`, future timestamps and deserialization
failures emit `unclassified`; conditional refill suppression remains
exactly PR 143's adapter-level watermark-miss path, byte-compatible with
`main` (differential-verified across the decode input matrix).
## Breaking change
`DialCacheMetricsAdapter.miss` now receives `MissMetricLabels`, and
first-party miss metrics require a `reason` label/tag. `RedisReadResult`
is now `DecodedRedisFrame | RedisReadMiss`; `null`,
`RedisWatermarkMiss`, `decodeRedisFrame`, and `decodeTrackedRedisFrame`
are removed, and bundled Redis adapters return `RedisReadMiss` for every
semantic miss.
Under the pre-1.0 release policy, this is a minor release.
## Validation
- Node.js 22.22.0: `corepack pnpm check`
- typecheck
- 656 unit tests with coverage thresholds
- ESM/CJS build and declaration generation
- packed-package type and runtime checks
- Node.js 22.22.0: `corepack pnpm test:integration`
- 143 passed
- 2 expected local GLIDE Cluster skips because announced container IPs
are not host-routable
- Review follow-up (`fix(metrics): preserve existing refill and decoder
behavior`) removed the fence-carry scope creep and restored
decoder/refill parity with `main`, differential-verified across the
decode input matrix
BREAKING CHANGE: DialCacheMetricsAdapter.miss now receives
MissMetricLabels and first-party miss metrics require a reason
label/tag; RedisReadResult is now DecodedRedisFrame | RedisReadMiss ({
kind: "miss", reason, observedWatermarkMs? }); null, RedisWatermarkMiss,
decodeRedisFrame, and decodeTrackedRedisFrame are removed, and bundled
Redis adapters return RedisReadMiss for every semantic miss.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@lan17
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(metrics)!: classify cache misses by bounded reason - #130

Closed
lan17 wants to merge 5 commits into
mainfrom
claude/cache-miss-watermark-instrumentation-7fee74
Closed

feat(metrics)!: classify cache misses by bounded reason#130
lan17 wants to merge 5 commits into
mainfrom
claude/cache-miss-watermark-instrumentation-7fee74

Conversation

@lan17

@lan17lan17 commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Summary

Cache misses were indistinguishable in metrics: the watermark fence decision in decodeTrackedRedisFrame collapsed "key absent", "frame unsupported", "watermark missing/malformed", and "fenced by watermark" into one null. Operators could not separate invalidation churn from cold keys.

The miss metric now carries a bounded reason label (mirroring the existing disabled{reason} precedent):

  • not_found — key absent/expired (all layers; local layers always use this)
  • frame_unsupported — short frame or unsupported frame version
  • watermark_unreadable — tracked read with missing/malformed/non-finite watermark
  • watermark_invalidated — tracked frame fenced (createdAt <= watermark)
  • deserialization_failed — payload read but serializer.load threw

sum by (use_case) (rate(dialcache_miss_counter{reason="watermark_invalidated"}[5m])) now measures invalidation churn directly, including the repeated stale-frame transfer cost during future-buffer windows. frame_unsupported/watermark_unreadable should be ~zero in steady state and make good anomaly alerts.

Design

  • Decoders and DialCacheRedisClient.read() return a discriminated RedisReadOutcome ({status:"hit",payload} | {status:"miss",reason}). The decoder remains the single source of truth for classification; bundled adapters needed zero code changes.
  • A runtime outcome guard in RedisCache rejects malformed client results (e.g. a stale client still returning payload | null) into the existing fail-open cache_read error path, so the label vocabulary cannot go unbounded.
  • Shadow reads emit reasons on remote_shadow; shadow fill behavior is unchanged — a fenced dark read stays fill-eligible because the tracked write script re-fences server-side (pinned by a new test).
  • Internal CacheGetResult types unchanged: misses are recorded where discovered, no caller re-records.

Breaking changes

  • DialCacheRedisClient.read() and exported decodeRedisFrame/decodeTrackedRedisFrame return RedisReadOutcome instead of RedisCachePayload | null. Custom clients delegating to the exported decoders need zero logic changes; plain-JS stragglers fail loudly open (typed error → cache_read → fallback), never mislabel.
  • dialcache_miss_counter gains a reason label — an old-schema collector in the same Prometheus registry now fails adapter construction (validateExistingCollectors). sum without (reason) totals are unchanged. Datadog gains an additive tag (wire-compatible).
  • Custom metrics adapters typed miss(labels: CacheMetricLabels) still compile; widen to MissMetricLabels to consume the reason.

Verification

  • pnpm check green: typecheck, 447 unit tests (coverage 97.5% lines / 94.7% branches, gates pass), build, packed-tarball consumer test (incl. exhaustive MissReason record and ESM/CJS decoder checks asserting watermark_invalidated)
  • pnpm test:integration green: 113 tests against real Redis 6.2, Valkey 8, and cluster
  • New tests: decoder reason boundaries, e2e fenced-vs-cold miss labels after invalidateRemote, malformed-outcome guard (5 shapes), fenced shadow-read fill parity, Prometheus/Datadog exhaustive-enum guards

lan17 added 5 commits August 7, 2026 14:24
Add a reason label to the miss metric so watermark-fenced misses are
separable from cold keys. Decoders and DialCacheRedisClient.read() now
return a discriminated RedisReadOutcome instead of payload-or-null,
with four bounded read-miss reasons (not_found, frame_unsupported,
watermark_unreadable, watermark_invalidated) plus a metrics-level
deserialization_failed for serializer.load failures. Local layers
always emit not_found. A runtime outcome guard routes malformed client
results through the existing fail-open cache_read error path so the
label vocabulary stays bounded.
BREAKING CHANGE: DialCacheRedisClient.read() and the exported
decodeRedisFrame/decodeTrackedRedisFrame return RedisReadOutcome
instead of RedisCachePayload | null, and dialcache_miss_counter gains
a reason label, so an old-schema collector in the same Prometheus
registry now fails adapter construction.
- Migrate all four benchmark fake clients to RedisReadOutcome shapes;
the legacy payload/null returns tripped the new outcome guard, failing
three scenarios on their own asserts and hanging the dark-fill one
- Single-source the guard's reason set via satisfies
Record<RedisReadMissReason, true> so a future reason cannot drift out
of it, and pin that a client-supplied deserialization_failed is
rejected as malformed
- Pin frame-before-watermark precedence with competing-bad-state
decoder assertions and a real-Redis mirror
- Delegate FakeRedis read classification to the exported decoders on a
copied frame instead of hand-mirroring the ladder
- Fix the stale shadow-spec sentence still defining a clean miss as a
null read; scope deserialization_failed to the remote layer; note
that only the first fenced read per invalidation window labels
watermark_invalidated; qualify not_found's wrong-type doc as
tracked-MGET-only
Collapse the decoder singletons, the guard's exhaustive flags object, and
its derived Set into a single REDIS_READ_MISS_OUTCOMES table: one frozen
outcome per bounded reason, returned by the decoders and now also the
normal form the core collapses every client miss onto. The read guard
(renamed normalizeRedisReadOutcome) reads each client-owned property
exactly once, recaptures hit payloads, and returns canonical singletons
for misses, so accessor-backed or otherwise unstable client objects can
never flip answers between validation and metric emission. Rejections
now carry a bounded shape fingerprint naming the malformed class -
legacy payload-or-null, non-object value, unknown status, unbounded or
core-owned reason, payload-less hit - so a stale-client migration storm
names its own cause.
Also: unify the two identical started-read interfaces as StartedRead<T>;
pin the accessor-flip behavior in both directions plus the toString
prototype-chain forgery; add the missing negative type pin that
deserialization_failed is not assignable to RedisReadMissReason.
The decoders and the read normalizer alias one frozen outcome per miss
reason, so a mutable entry would let a single consumer corrupt every
later miss and its bounded metric label process-wide; pin identity,
frozenness, and mutation rejection. Also narrow not_found's doc to the
tracked MGET value member - a wrong-type watermark member reads as nil
and classifies watermark_unreadable, as the README and integration
tests already state.
…ication
Conflicts were the adjacent bounded-vocabulary blocks in the adapter
tests (MISS_REASONS vs COMPRESSION_OUTCOMES / ERROR_KINDS with the new
compression kind) - resolved by keeping both. Compression operates on
serializer output inside the frame payload, one layer below the read
outcome classification, so the two features compose without semantic
changes; a rejected compression envelope surfaces as
deserialization_failed alongside its fallback_raw/read_over_limit
compression outcome, now cross-referenced in the miss-reasons table.
@lan17

Copy link
Copy Markdown
OwnerAuthor

Closing this stale, conflicting implementation in favor of the protocol-current reconciliation spec in #145. A fresh implementation PR will follow from current main; this branch should not be rebased mechanically.

@lan17lan17 closed this Aug 29, 2026
lan17 added a commit that referenced this pull request Sep 3, 2026
## Summary
Implements #145 (replaces the
stale attempt in #130).
Every DialCache miss event now says **why** it missed. Until now, a miss
storm during a future-buffer invalidation window was indistinguishable
from a cold cache or an eviction problem: the miss counter only told you
*that* a layer missed, never whether the value was truly absent or a
stored frame was being rejected by an invalidation watermark. This PR
adds one required, bounded `reason` to the existing miss event — no new
metric instrument, no new Redis command, no extra round trip, and no
change to serving, refill, invalidation, stale-recovery, or shadow
behavior.
## What changes in your metrics
`miss` events now carry `reason` alongside the existing `cacheNamespace`
/ `useCase` / `keyType` / `layer` labels, on every layer
(`request_local`, `local`, `remote`, `remote_shadow`):
| `reason` | Fires when | Typical cause |
| --- | --- | --- |
| `value_absent` | The layer had no retrievable value at all: never
populated, physically expired or evicted, Redis `nil`, or a
tracked-`MGET` wrong-type member (which Redis reports as `nil`).
Request-local and process-local misses are always this. | Cold keys,
physical TTL expiry, eviction pressure. |
| `expired` | A complete, supported frame with a valid, non-future
timestamp was present, but its logical age against the reader's clock
reached the effective remote TTL `F`. Covers `age >= M` misses and `F <=
age < M` frames retained as stale-on-error candidates, on both caller
and shadow reads. | Ordinary TTL churn on hot keys; the routine
steady-state miss reason whenever `staleOnErrorMaxAgeSec` is configured.
|
| `watermark_fenced` | A complete, supported tracked frame with a
positive safe-integer timestamp was rejected because its `createdAtMs`
was at or below a valid observed invalidation watermark. Decided before
deserialization. | Reads inside an active `futureBufferMs` invalidation
window; writer/invalidator clock skew. |
| `unclassified` | The miss is real but attributable to none of the
above: unrecognized custom-adapter results (including `null`),
short/unsupported frames, zero-stamped tracked frames, malformed
watermark metadata paired with a present frame, future or invalid
timestamps, and deserialization failures. | Un-migrated custom adapters;
protocol-edge states. |
The operational win: during an invalidation window you can now separate
fence churn from genuine absence at a glance; routine TTL churn
(including the stale-on-error `F..M` window, where every read of a
retained frame is a miss) is `expired` rather than noise in the other
buckets; and because expiry no longer lands there, a sudden
`unclassified` plateau genuinely points at protocol-edge states (skewed
clocks, corrupt frames, deserialization failures, legacy adapters)
instead of hiding inside the aggregate.
**Deviation from #145:** the issue specified a three-value union and
named `unclassified` as the safe default for stale-on-error retained
frames "unless a separately justified reason is added". Review found
that with `staleOnErrorMaxAgeSec` configured every read of a retained
`F..M` frame is a miss, so `unclassified` would have been the routine
steady-state reason for exactly the use cases that enable
stale-on-error, and the plateau signal above would not hold. `expired`
is that separately justified reason; the union is four values and the
cardinality note below is 4x accordingly.
**Prometheus** — `dialcache_miss_counter` keeps its name and gains
`reason` as a fifth label:
```promql
# Fence churn by use case during an invalidation window
sum by (use_case) (rate(dialcache_miss_counter{layer="remote", reason="watermark_fenced"}[5m]))
# Pre-existing total-miss and hit-ratio queries: aggregate reason away
sum by (cache_namespace, use_case, key_type, layer) (rate(dialcache_miss_counter[5m]))
```
**Datadog** — `dialcache.miss.count` keeps its name and gains the same
`reason` tag, e.g. `sum:dialcache.miss.count{reason:watermark_fenced} by
{use_case}`. Expect up to 4× the miss-series cardinality (four bounded
reasons; only `remote` and `remote_shadow` tuples can carry every
reason, since request-local and local misses are always `value_absent`).
## Migration
- **Prometheus registries:** the label set changed, so registering
against a registry that already holds a 4-label `dialcache_miss_counter`
(e.g. a not-yet-upgraded sidecar library) fails loudly at construction —
upgrade producers together per registry. Nothing silently mislabels.
- **Mixed-fleet rollouts:** old processes emit reason-less series while
new ones carry `reason`. Total-miss and miss/request-ratio queries must
`sum by (...)` the shared labels (example above); reason-aware
dashboards should group by `reason` explicitly.
- **Custom metrics adapters:** `DialCacheMetricsAdapter.miss` now
receives `MissMetricLabels` (extends the unchanged `CacheMetricLabels`
with required `reason: CacheMissReason`). Adapters whose `miss`
parameter is typed as the broader `CacheMetricLabels` compile unchanged
and may ignore the field; exact label snapshots, exhaustive `Record`s
over reasons, and adapters that reject or forward unknown fields must
add it.
- **Custom Redis adapters:** `RedisReadResult` is now `DecodedRedisFrame
| RedisReadMiss { kind: "miss", reason, observedWatermarkMs? }`. `null`,
`RedisWatermarkMiss`, `decodeRedisFrame`, and `decodeTrackedRedisFrame`
are removed; use `decodeRedisReadResult` (untracked) /
`decodeTrackedRedisReadResult` (tracked) and the exported
`isRedisReadMiss` guard. An un-migrated adapter still fails open at
runtime (`null` or any unrecognized result is an `unclassified` miss
with a normal refill) but loses reason precision and fenced-refill
suppression until it returns the typed miss.
## How classification works
Bundled decoders return one miss shape, `RedisReadMiss { kind: "miss",
reason, observedWatermarkMs? }`, attaching `observedWatermarkMs` only
when the same atomic tracked snapshot carried a valid numeric watermark.
Cause and fence are deliberately independent: Redis `nil` is decisive
evidence of absence, so an absent value reports `value_absent` while
still carrying the observed watermark that lets PR 143's two-sample
admission skip a known-fenced refill — only a complete supported frame
actually rejected by the watermark reports `watermark_fenced`.
Core treats the adapter boundary as untrusted and normalizes every
result once, at one choke point: `kind: "miss"` is the only
discriminator; invalid or missing reasons become `unclassified`, a
`watermark_fenced` claim without a valid tracked fence is demoted to
`unclassified`, invalid fences (`NaN`, negatives, non-integers,
untracked keys) are dropped, and `null`, `undefined`, or any other
unrecognized value is an `unclassified` miss with a normal refill.
Objects without `kind: "miss"` are frame candidates and go through the
existing timestamp and serializer validation.
Decoded hits keep the existing `{ payload, createdAtMs }` shape.
Core-side rejections never acquire a refill fence: logical-age
rejections emit `expired`, future timestamps and deserialization
failures emit `unclassified`; conditional refill suppression remains
exactly PR 143's adapter-level watermark-miss path, byte-compatible with
`main` (differential-verified across the decode input matrix).
## Breaking change
`DialCacheMetricsAdapter.miss` now receives `MissMetricLabels`, and
first-party miss metrics require a `reason` label/tag. `RedisReadResult`
is now `DecodedRedisFrame | RedisReadMiss`; `null`,
`RedisWatermarkMiss`, `decodeRedisFrame`, and `decodeTrackedRedisFrame`
are removed, and bundled Redis adapters return `RedisReadMiss` for every
semantic miss.
Under the pre-1.0 release policy, this is a minor release.
## Validation
- Node.js 22.22.0: `corepack pnpm check`
- typecheck
- 656 unit tests with coverage thresholds
- ESM/CJS build and declaration generation
- packed-package type and runtime checks
- Node.js 22.22.0: `corepack pnpm test:integration`
- 143 passed
- 2 expected local GLIDE Cluster skips because announced container IPs
are not host-routable
- Review follow-up (`fix(metrics): preserve existing refill and decoder
behavior`) removed the fence-carry scope creep and restored
decoder/refill parity with `main`, differential-verified across the
decode input matrix
BREAKING CHANGE: DialCacheMetricsAdapter.miss now receives
MissMetricLabels and first-party miss metrics require a reason
label/tag; RedisReadResult is now DecodedRedisFrame | RedisReadMiss ({
kind: "miss", reason, observedWatermarkMs? }); null, RedisWatermarkMiss,
decodeRedisFrame, and decodeTrackedRedisFrame are removed, and bundled
Redis adapters return RedisReadMiss for every semantic miss.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@lan17
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(metrics)!: classify cache misses by bounded reason - #130

Closed
lan17 wants to merge 5 commits into
mainfrom
claude/cache-miss-watermark-instrumentation-7fee74
Closed

feat(metrics)!: classify cache misses by bounded reason#130
lan17 wants to merge 5 commits into
mainfrom
claude/cache-miss-watermark-instrumentation-7fee74

Conversation

@lan17

@lan17lan17 commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Summary

Cache misses were indistinguishable in metrics: the watermark fence decision in decodeTrackedRedisFrame collapsed "key absent", "frame unsupported", "watermark missing/malformed", and "fenced by watermark" into one null. Operators could not separate invalidation churn from cold keys.

The miss metric now carries a bounded reason label (mirroring the existing disabled{reason} precedent):

  • not_found — key absent/expired (all layers; local layers always use this)
  • frame_unsupported — short frame or unsupported frame version
  • watermark_unreadable — tracked read with missing/malformed/non-finite watermark
  • watermark_invalidated — tracked frame fenced (createdAt <= watermark)
  • deserialization_failed — payload read but serializer.load threw

sum by (use_case) (rate(dialcache_miss_counter{reason="watermark_invalidated"}[5m])) now measures invalidation churn directly, including the repeated stale-frame transfer cost during future-buffer windows. frame_unsupported/watermark_unreadable should be ~zero in steady state and make good anomaly alerts.

Design

  • Decoders and DialCacheRedisClient.read() return a discriminated RedisReadOutcome ({status:"hit",payload} | {status:"miss",reason}). The decoder remains the single source of truth for classification; bundled adapters needed zero code changes.
  • A runtime outcome guard in RedisCache rejects malformed client results (e.g. a stale client still returning payload | null) into the existing fail-open cache_read error path, so the label vocabulary cannot go unbounded.
  • Shadow reads emit reasons on remote_shadow; shadow fill behavior is unchanged — a fenced dark read stays fill-eligible because the tracked write script re-fences server-side (pinned by a new test).
  • Internal CacheGetResult types unchanged: misses are recorded where discovered, no caller re-records.

Breaking changes

  • DialCacheRedisClient.read() and exported decodeRedisFrame/decodeTrackedRedisFrame return RedisReadOutcome instead of RedisCachePayload | null. Custom clients delegating to the exported decoders need zero logic changes; plain-JS stragglers fail loudly open (typed error → cache_read → fallback), never mislabel.
  • dialcache_miss_counter gains a reason label — an old-schema collector in the same Prometheus registry now fails adapter construction (validateExistingCollectors). sum without (reason) totals are unchanged. Datadog gains an additive tag (wire-compatible).
  • Custom metrics adapters typed miss(labels: CacheMetricLabels) still compile; widen to MissMetricLabels to consume the reason.

Verification

  • pnpm check green: typecheck, 447 unit tests (coverage 97.5% lines / 94.7% branches, gates pass), build, packed-tarball consumer test (incl. exhaustive MissReason record and ESM/CJS decoder checks asserting watermark_invalidated)
  • pnpm test:integration green: 113 tests against real Redis 6.2, Valkey 8, and cluster
  • New tests: decoder reason boundaries, e2e fenced-vs-cold miss labels after invalidateRemote, malformed-outcome guard (5 shapes), fenced shadow-read fill parity, Prometheus/Datadog exhaustive-enum guards

lan17 added 5 commits August 7, 2026 14:24
Add a reason label to the miss metric so watermark-fenced misses are
separable from cold keys. Decoders and DialCacheRedisClient.read() now
return a discriminated RedisReadOutcome instead of payload-or-null,
with four bounded read-miss reasons (not_found, frame_unsupported,
watermark_unreadable, watermark_invalidated) plus a metrics-level
deserialization_failed for serializer.load failures. Local layers
always emit not_found. A runtime outcome guard routes malformed client
results through the existing fail-open cache_read error path so the
label vocabulary stays bounded.
BREAKING CHANGE: DialCacheRedisClient.read() and the exported
decodeRedisFrame/decodeTrackedRedisFrame return RedisReadOutcome
instead of RedisCachePayload | null, and dialcache_miss_counter gains
a reason label, so an old-schema collector in the same Prometheus
registry now fails adapter construction.
- Migrate all four benchmark fake clients to RedisReadOutcome shapes;
the legacy payload/null returns tripped the new outcome guard, failing
three scenarios on their own asserts and hanging the dark-fill one
- Single-source the guard's reason set via satisfies
Record<RedisReadMissReason, true> so a future reason cannot drift out
of it, and pin that a client-supplied deserialization_failed is
rejected as malformed
- Pin frame-before-watermark precedence with competing-bad-state
decoder assertions and a real-Redis mirror
- Delegate FakeRedis read classification to the exported decoders on a
copied frame instead of hand-mirroring the ladder
- Fix the stale shadow-spec sentence still defining a clean miss as a
null read; scope deserialization_failed to the remote layer; note
that only the first fenced read per invalidation window labels
watermark_invalidated; qualify not_found's wrong-type doc as
tracked-MGET-only
Collapse the decoder singletons, the guard's exhaustive flags object, and
its derived Set into a single REDIS_READ_MISS_OUTCOMES table: one frozen
outcome per bounded reason, returned by the decoders and now also the
normal form the core collapses every client miss onto. The read guard
(renamed normalizeRedisReadOutcome) reads each client-owned property
exactly once, recaptures hit payloads, and returns canonical singletons
for misses, so accessor-backed or otherwise unstable client objects can
never flip answers between validation and metric emission. Rejections
now carry a bounded shape fingerprint naming the malformed class -
legacy payload-or-null, non-object value, unknown status, unbounded or
core-owned reason, payload-less hit - so a stale-client migration storm
names its own cause.
Also: unify the two identical started-read interfaces as StartedRead<T>;
pin the accessor-flip behavior in both directions plus the toString
prototype-chain forgery; add the missing negative type pin that
deserialization_failed is not assignable to RedisReadMissReason.
The decoders and the read normalizer alias one frozen outcome per miss
reason, so a mutable entry would let a single consumer corrupt every
later miss and its bounded metric label process-wide; pin identity,
frozenness, and mutation rejection. Also narrow not_found's doc to the
tracked MGET value member - a wrong-type watermark member reads as nil
and classifies watermark_unreadable, as the README and integration
tests already state.
…ication
Conflicts were the adjacent bounded-vocabulary blocks in the adapter
tests (MISS_REASONS vs COMPRESSION_OUTCOMES / ERROR_KINDS with the new
compression kind) - resolved by keeping both. Compression operates on
serializer output inside the frame payload, one layer below the read
outcome classification, so the two features compose without semantic
changes; a rejected compression envelope surfaces as
deserialization_failed alongside its fallback_raw/read_over_limit
compression outcome, now cross-referenced in the miss-reasons table.
@lan17

Copy link
Copy Markdown
OwnerAuthor

Closing this stale, conflicting implementation in favor of the protocol-current reconciliation spec in #145. A fresh implementation PR will follow from current main; this branch should not be rebased mechanically.

@lan17lan17 closed this Aug 29, 2026
lan17 added a commit that referenced this pull request Sep 3, 2026
## Summary
Implements #145 (replaces the
stale attempt in #130).
Every DialCache miss event now says **why** it missed. Until now, a miss
storm during a future-buffer invalidation window was indistinguishable
from a cold cache or an eviction problem: the miss counter only told you
*that* a layer missed, never whether the value was truly absent or a
stored frame was being rejected by an invalidation watermark. This PR
adds one required, bounded `reason` to the existing miss event — no new
metric instrument, no new Redis command, no extra round trip, and no
change to serving, refill, invalidation, stale-recovery, or shadow
behavior.
## What changes in your metrics
`miss` events now carry `reason` alongside the existing `cacheNamespace`
/ `useCase` / `keyType` / `layer` labels, on every layer
(`request_local`, `local`, `remote`, `remote_shadow`):
| `reason` | Fires when | Typical cause |
| --- | --- | --- |
| `value_absent` | The layer had no retrievable value at all: never
populated, physically expired or evicted, Redis `nil`, or a
tracked-`MGET` wrong-type member (which Redis reports as `nil`).
Request-local and process-local misses are always this. | Cold keys,
physical TTL expiry, eviction pressure. |
| `expired` | A complete, supported frame with a valid, non-future
timestamp was present, but its logical age against the reader's clock
reached the effective remote TTL `F`. Covers `age >= M` misses and `F <=
age < M` frames retained as stale-on-error candidates, on both caller
and shadow reads. | Ordinary TTL churn on hot keys; the routine
steady-state miss reason whenever `staleOnErrorMaxAgeSec` is configured.
|
| `watermark_fenced` | A complete, supported tracked frame with a
positive safe-integer timestamp was rejected because its `createdAtMs`
was at or below a valid observed invalidation watermark. Decided before
deserialization. | Reads inside an active `futureBufferMs` invalidation
window; writer/invalidator clock skew. |
| `unclassified` | The miss is real but attributable to none of the
above: unrecognized custom-adapter results (including `null`),
short/unsupported frames, zero-stamped tracked frames, malformed
watermark metadata paired with a present frame, future or invalid
timestamps, and deserialization failures. | Un-migrated custom adapters;
protocol-edge states. |
The operational win: during an invalidation window you can now separate
fence churn from genuine absence at a glance; routine TTL churn
(including the stale-on-error `F..M` window, where every read of a
retained frame is a miss) is `expired` rather than noise in the other
buckets; and because expiry no longer lands there, a sudden
`unclassified` plateau genuinely points at protocol-edge states (skewed
clocks, corrupt frames, deserialization failures, legacy adapters)
instead of hiding inside the aggregate.
**Deviation from #145:** the issue specified a three-value union and
named `unclassified` as the safe default for stale-on-error retained
frames "unless a separately justified reason is added". Review found
that with `staleOnErrorMaxAgeSec` configured every read of a retained
`F..M` frame is a miss, so `unclassified` would have been the routine
steady-state reason for exactly the use cases that enable
stale-on-error, and the plateau signal above would not hold. `expired`
is that separately justified reason; the union is four values and the
cardinality note below is 4x accordingly.
**Prometheus** — `dialcache_miss_counter` keeps its name and gains
`reason` as a fifth label:
```promql
# Fence churn by use case during an invalidation window
sum by (use_case) (rate(dialcache_miss_counter{layer="remote", reason="watermark_fenced"}[5m]))
# Pre-existing total-miss and hit-ratio queries: aggregate reason away
sum by (cache_namespace, use_case, key_type, layer) (rate(dialcache_miss_counter[5m]))
```
**Datadog** — `dialcache.miss.count` keeps its name and gains the same
`reason` tag, e.g. `sum:dialcache.miss.count{reason:watermark_fenced} by
{use_case}`. Expect up to 4× the miss-series cardinality (four bounded
reasons; only `remote` and `remote_shadow` tuples can carry every
reason, since request-local and local misses are always `value_absent`).
## Migration
- **Prometheus registries:** the label set changed, so registering
against a registry that already holds a 4-label `dialcache_miss_counter`
(e.g. a not-yet-upgraded sidecar library) fails loudly at construction —
upgrade producers together per registry. Nothing silently mislabels.
- **Mixed-fleet rollouts:** old processes emit reason-less series while
new ones carry `reason`. Total-miss and miss/request-ratio queries must
`sum by (...)` the shared labels (example above); reason-aware
dashboards should group by `reason` explicitly.
- **Custom metrics adapters:** `DialCacheMetricsAdapter.miss` now
receives `MissMetricLabels` (extends the unchanged `CacheMetricLabels`
with required `reason: CacheMissReason`). Adapters whose `miss`
parameter is typed as the broader `CacheMetricLabels` compile unchanged
and may ignore the field; exact label snapshots, exhaustive `Record`s
over reasons, and adapters that reject or forward unknown fields must
add it.
- **Custom Redis adapters:** `RedisReadResult` is now `DecodedRedisFrame
| RedisReadMiss { kind: "miss", reason, observedWatermarkMs? }`. `null`,
`RedisWatermarkMiss`, `decodeRedisFrame`, and `decodeTrackedRedisFrame`
are removed; use `decodeRedisReadResult` (untracked) /
`decodeTrackedRedisReadResult` (tracked) and the exported
`isRedisReadMiss` guard. An un-migrated adapter still fails open at
runtime (`null` or any unrecognized result is an `unclassified` miss
with a normal refill) but loses reason precision and fenced-refill
suppression until it returns the typed miss.
## How classification works
Bundled decoders return one miss shape, `RedisReadMiss { kind: "miss",
reason, observedWatermarkMs? }`, attaching `observedWatermarkMs` only
when the same atomic tracked snapshot carried a valid numeric watermark.
Cause and fence are deliberately independent: Redis `nil` is decisive
evidence of absence, so an absent value reports `value_absent` while
still carrying the observed watermark that lets PR 143's two-sample
admission skip a known-fenced refill — only a complete supported frame
actually rejected by the watermark reports `watermark_fenced`.
Core treats the adapter boundary as untrusted and normalizes every
result once, at one choke point: `kind: "miss"` is the only
discriminator; invalid or missing reasons become `unclassified`, a
`watermark_fenced` claim without a valid tracked fence is demoted to
`unclassified`, invalid fences (`NaN`, negatives, non-integers,
untracked keys) are dropped, and `null`, `undefined`, or any other
unrecognized value is an `unclassified` miss with a normal refill.
Objects without `kind: "miss"` are frame candidates and go through the
existing timestamp and serializer validation.
Decoded hits keep the existing `{ payload, createdAtMs }` shape.
Core-side rejections never acquire a refill fence: logical-age
rejections emit `expired`, future timestamps and deserialization
failures emit `unclassified`; conditional refill suppression remains
exactly PR 143's adapter-level watermark-miss path, byte-compatible with
`main` (differential-verified across the decode input matrix).
## Breaking change
`DialCacheMetricsAdapter.miss` now receives `MissMetricLabels`, and
first-party miss metrics require a `reason` label/tag. `RedisReadResult`
is now `DecodedRedisFrame | RedisReadMiss`; `null`,
`RedisWatermarkMiss`, `decodeRedisFrame`, and `decodeTrackedRedisFrame`
are removed, and bundled Redis adapters return `RedisReadMiss` for every
semantic miss.
Under the pre-1.0 release policy, this is a minor release.
## Validation
- Node.js 22.22.0: `corepack pnpm check`
- typecheck
- 656 unit tests with coverage thresholds
- ESM/CJS build and declaration generation
- packed-package type and runtime checks
- Node.js 22.22.0: `corepack pnpm test:integration`
- 143 passed
- 2 expected local GLIDE Cluster skips because announced container IPs
are not host-routable
- Review follow-up (`fix(metrics): preserve existing refill and decoder
behavior`) removed the fence-carry scope creep and restored
decoder/refill parity with `main`, differential-verified across the
decode input matrix
BREAKING CHANGE: DialCacheMetricsAdapter.miss now receives
MissMetricLabels and first-party miss metrics require a reason
label/tag; RedisReadResult is now DecodedRedisFrame | RedisReadMiss ({
kind: "miss", reason, observedWatermarkMs? }); null, RedisWatermarkMiss,
decodeRedisFrame, and decodeTrackedRedisFrame are removed, and bundled
Redis adapters return RedisReadMiss for every semantic miss.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@lan17
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

feat(metrics)!: classify cache misses by bounded reason - #130

Closed
lan17 wants to merge 5 commits into
mainfrom
claude/cache-miss-watermark-instrumentation-7fee74
Closed

feat(metrics)!: classify cache misses by bounded reason#130
lan17 wants to merge 5 commits into
mainfrom
claude/cache-miss-watermark-instrumentation-7fee74

Conversation

@lan17

@lan17lan17 commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Summary

Cache misses were indistinguishable in metrics: the watermark fence decision in decodeTrackedRedisFrame collapsed "key absent", "frame unsupported", "watermark missing/malformed", and "fenced by watermark" into one null. Operators could not separate invalidation churn from cold keys.

The miss metric now carries a bounded reason label (mirroring the existing disabled{reason} precedent):

  • not_found — key absent/expired (all layers; local layers always use this)
  • frame_unsupported — short frame or unsupported frame version
  • watermark_unreadable — tracked read with missing/malformed/non-finite watermark
  • watermark_invalidated — tracked frame fenced (createdAt <= watermark)
  • deserialization_failed — payload read but serializer.load threw

sum by (use_case) (rate(dialcache_miss_counter{reason="watermark_invalidated"}[5m])) now measures invalidation churn directly, including the repeated stale-frame transfer cost during future-buffer windows. frame_unsupported/watermark_unreadable should be ~zero in steady state and make good anomaly alerts.

Design

  • Decoders and DialCacheRedisClient.read() return a discriminated RedisReadOutcome ({status:"hit",payload} | {status:"miss",reason}). The decoder remains the single source of truth for classification; bundled adapters needed zero code changes.
  • A runtime outcome guard in RedisCache rejects malformed client results (e.g. a stale client still returning payload | null) into the existing fail-open cache_read error path, so the label vocabulary cannot go unbounded.
  • Shadow reads emit reasons on remote_shadow; shadow fill behavior is unchanged — a fenced dark read stays fill-eligible because the tracked write script re-fences server-side (pinned by a new test).
  • Internal CacheGetResult types unchanged: misses are recorded where discovered, no caller re-records.

Breaking changes

  • DialCacheRedisClient.read() and exported decodeRedisFrame/decodeTrackedRedisFrame return RedisReadOutcome instead of RedisCachePayload | null. Custom clients delegating to the exported decoders need zero logic changes; plain-JS stragglers fail loudly open (typed error → cache_read → fallback), never mislabel.
  • dialcache_miss_counter gains a reason label — an old-schema collector in the same Prometheus registry now fails adapter construction (validateExistingCollectors). sum without (reason) totals are unchanged. Datadog gains an additive tag (wire-compatible).
  • Custom metrics adapters typed miss(labels: CacheMetricLabels) still compile; widen to MissMetricLabels to consume the reason.

Verification

  • pnpm check green: typecheck, 447 unit tests (coverage 97.5% lines / 94.7% branches, gates pass), build, packed-tarball consumer test (incl. exhaustive MissReason record and ESM/CJS decoder checks asserting watermark_invalidated)
  • pnpm test:integration green: 113 tests against real Redis 6.2, Valkey 8, and cluster
  • New tests: decoder reason boundaries, e2e fenced-vs-cold miss labels after invalidateRemote, malformed-outcome guard (5 shapes), fenced shadow-read fill parity, Prometheus/Datadog exhaustive-enum guards

lan17 added 5 commits August 7, 2026 14:24
Add a reason label to the miss metric so watermark-fenced misses are
separable from cold keys. Decoders and DialCacheRedisClient.read() now
return a discriminated RedisReadOutcome instead of payload-or-null,
with four bounded read-miss reasons (not_found, frame_unsupported,
watermark_unreadable, watermark_invalidated) plus a metrics-level
deserialization_failed for serializer.load failures. Local layers
always emit not_found. A runtime outcome guard routes malformed client
results through the existing fail-open cache_read error path so the
label vocabulary stays bounded.
BREAKING CHANGE: DialCacheRedisClient.read() and the exported
decodeRedisFrame/decodeTrackedRedisFrame return RedisReadOutcome
instead of RedisCachePayload | null, and dialcache_miss_counter gains
a reason label, so an old-schema collector in the same Prometheus
registry now fails adapter construction.
- Migrate all four benchmark fake clients to RedisReadOutcome shapes;
the legacy payload/null returns tripped the new outcome guard, failing
three scenarios on their own asserts and hanging the dark-fill one
- Single-source the guard's reason set via satisfies
Record<RedisReadMissReason, true> so a future reason cannot drift out
of it, and pin that a client-supplied deserialization_failed is
rejected as malformed
- Pin frame-before-watermark precedence with competing-bad-state
decoder assertions and a real-Redis mirror
- Delegate FakeRedis read classification to the exported decoders on a
copied frame instead of hand-mirroring the ladder
- Fix the stale shadow-spec sentence still defining a clean miss as a
null read; scope deserialization_failed to the remote layer; note
that only the first fenced read per invalidation window labels
watermark_invalidated; qualify not_found's wrong-type doc as
tracked-MGET-only
Collapse the decoder singletons, the guard's exhaustive flags object, and
its derived Set into a single REDIS_READ_MISS_OUTCOMES table: one frozen
outcome per bounded reason, returned by the decoders and now also the
normal form the core collapses every client miss onto. The read guard
(renamed normalizeRedisReadOutcome) reads each client-owned property
exactly once, recaptures hit payloads, and returns canonical singletons
for misses, so accessor-backed or otherwise unstable client objects can
never flip answers between validation and metric emission. Rejections
now carry a bounded shape fingerprint naming the malformed class -
legacy payload-or-null, non-object value, unknown status, unbounded or
core-owned reason, payload-less hit - so a stale-client migration storm
names its own cause.
Also: unify the two identical started-read interfaces as StartedRead<T>;
pin the accessor-flip behavior in both directions plus the toString
prototype-chain forgery; add the missing negative type pin that
deserialization_failed is not assignable to RedisReadMissReason.
The decoders and the read normalizer alias one frozen outcome per miss
reason, so a mutable entry would let a single consumer corrupt every
later miss and its bounded metric label process-wide; pin identity,
frozenness, and mutation rejection. Also narrow not_found's doc to the
tracked MGET value member - a wrong-type watermark member reads as nil
and classifies watermark_unreadable, as the README and integration
tests already state.
…ication
Conflicts were the adjacent bounded-vocabulary blocks in the adapter
tests (MISS_REASONS vs COMPRESSION_OUTCOMES / ERROR_KINDS with the new
compression kind) - resolved by keeping both. Compression operates on
serializer output inside the frame payload, one layer below the read
outcome classification, so the two features compose without semantic
changes; a rejected compression envelope surfaces as
deserialization_failed alongside its fallback_raw/read_over_limit
compression outcome, now cross-referenced in the miss-reasons table.
@lan17

Copy link
Copy Markdown
OwnerAuthor

Closing this stale, conflicting implementation in favor of the protocol-current reconciliation spec in #145. A fresh implementation PR will follow from current main; this branch should not be rebased mechanically.

@lan17lan17 closed this Aug 29, 2026
lan17 added a commit that referenced this pull request Sep 3, 2026
## Summary
Implements #145 (replaces the
stale attempt in #130).
Every DialCache miss event now says **why** it missed. Until now, a miss
storm during a future-buffer invalidation window was indistinguishable
from a cold cache or an eviction problem: the miss counter only told you
*that* a layer missed, never whether the value was truly absent or a
stored frame was being rejected by an invalidation watermark. This PR
adds one required, bounded `reason` to the existing miss event — no new
metric instrument, no new Redis command, no extra round trip, and no
change to serving, refill, invalidation, stale-recovery, or shadow
behavior.
## What changes in your metrics
`miss` events now carry `reason` alongside the existing `cacheNamespace`
/ `useCase` / `keyType` / `layer` labels, on every layer
(`request_local`, `local`, `remote`, `remote_shadow`):
| `reason` | Fires when | Typical cause |
| --- | --- | --- |
| `value_absent` | The layer had no retrievable value at all: never
populated, physically expired or evicted, Redis `nil`, or a
tracked-`MGET` wrong-type member (which Redis reports as `nil`).
Request-local and process-local misses are always this. | Cold keys,
physical TTL expiry, eviction pressure. |
| `expired` | A complete, supported frame with a valid, non-future
timestamp was present, but its logical age against the reader's clock
reached the effective remote TTL `F`. Covers `age >= M` misses and `F <=
age < M` frames retained as stale-on-error candidates, on both caller
and shadow reads. | Ordinary TTL churn on hot keys; the routine
steady-state miss reason whenever `staleOnErrorMaxAgeSec` is configured.
|
| `watermark_fenced` | A complete, supported tracked frame with a
positive safe-integer timestamp was rejected because its `createdAtMs`
was at or below a valid observed invalidation watermark. Decided before
deserialization. | Reads inside an active `futureBufferMs` invalidation
window; writer/invalidator clock skew. |
| `unclassified` | The miss is real but attributable to none of the
above: unrecognized custom-adapter results (including `null`),
short/unsupported frames, zero-stamped tracked frames, malformed
watermark metadata paired with a present frame, future or invalid
timestamps, and deserialization failures. | Un-migrated custom adapters;
protocol-edge states. |
The operational win: during an invalidation window you can now separate
fence churn from genuine absence at a glance; routine TTL churn
(including the stale-on-error `F..M` window, where every read of a
retained frame is a miss) is `expired` rather than noise in the other
buckets; and because expiry no longer lands there, a sudden
`unclassified` plateau genuinely points at protocol-edge states (skewed
clocks, corrupt frames, deserialization failures, legacy adapters)
instead of hiding inside the aggregate.
**Deviation from #145:** the issue specified a three-value union and
named `unclassified` as the safe default for stale-on-error retained
frames "unless a separately justified reason is added". Review found
that with `staleOnErrorMaxAgeSec` configured every read of a retained
`F..M` frame is a miss, so `unclassified` would have been the routine
steady-state reason for exactly the use cases that enable
stale-on-error, and the plateau signal above would not hold. `expired`
is that separately justified reason; the union is four values and the
cardinality note below is 4x accordingly.
**Prometheus** — `dialcache_miss_counter` keeps its name and gains
`reason` as a fifth label:
```promql
# Fence churn by use case during an invalidation window
sum by (use_case) (rate(dialcache_miss_counter{layer="remote", reason="watermark_fenced"}[5m]))
# Pre-existing total-miss and hit-ratio queries: aggregate reason away
sum by (cache_namespace, use_case, key_type, layer) (rate(dialcache_miss_counter[5m]))
```
**Datadog** — `dialcache.miss.count` keeps its name and gains the same
`reason` tag, e.g. `sum:dialcache.miss.count{reason:watermark_fenced} by
{use_case}`. Expect up to 4× the miss-series cardinality (four bounded
reasons; only `remote` and `remote_shadow` tuples can carry every
reason, since request-local and local misses are always `value_absent`).
## Migration
- **Prometheus registries:** the label set changed, so registering
against a registry that already holds a 4-label `dialcache_miss_counter`
(e.g. a not-yet-upgraded sidecar library) fails loudly at construction —
upgrade producers together per registry. Nothing silently mislabels.
- **Mixed-fleet rollouts:** old processes emit reason-less series while
new ones carry `reason`. Total-miss and miss/request-ratio queries must
`sum by (...)` the shared labels (example above); reason-aware
dashboards should group by `reason` explicitly.
- **Custom metrics adapters:** `DialCacheMetricsAdapter.miss` now
receives `MissMetricLabels` (extends the unchanged `CacheMetricLabels`
with required `reason: CacheMissReason`). Adapters whose `miss`
parameter is typed as the broader `CacheMetricLabels` compile unchanged
and may ignore the field; exact label snapshots, exhaustive `Record`s
over reasons, and adapters that reject or forward unknown fields must
add it.
- **Custom Redis adapters:** `RedisReadResult` is now `DecodedRedisFrame
| RedisReadMiss { kind: "miss", reason, observedWatermarkMs? }`. `null`,
`RedisWatermarkMiss`, `decodeRedisFrame`, and `decodeTrackedRedisFrame`
are removed; use `decodeRedisReadResult` (untracked) /
`decodeTrackedRedisReadResult` (tracked) and the exported
`isRedisReadMiss` guard. An un-migrated adapter still fails open at
runtime (`null` or any unrecognized result is an `unclassified` miss
with a normal refill) but loses reason precision and fenced-refill
suppression until it returns the typed miss.
## How classification works
Bundled decoders return one miss shape, `RedisReadMiss { kind: "miss",
reason, observedWatermarkMs? }`, attaching `observedWatermarkMs` only
when the same atomic tracked snapshot carried a valid numeric watermark.
Cause and fence are deliberately independent: Redis `nil` is decisive
evidence of absence, so an absent value reports `value_absent` while
still carrying the observed watermark that lets PR 143's two-sample
admission skip a known-fenced refill — only a complete supported frame
actually rejected by the watermark reports `watermark_fenced`.
Core treats the adapter boundary as untrusted and normalizes every
result once, at one choke point: `kind: "miss"` is the only
discriminator; invalid or missing reasons become `unclassified`, a
`watermark_fenced` claim without a valid tracked fence is demoted to
`unclassified`, invalid fences (`NaN`, negatives, non-integers,
untracked keys) are dropped, and `null`, `undefined`, or any other
unrecognized value is an `unclassified` miss with a normal refill.
Objects without `kind: "miss"` are frame candidates and go through the
existing timestamp and serializer validation.
Decoded hits keep the existing `{ payload, createdAtMs }` shape.
Core-side rejections never acquire a refill fence: logical-age
rejections emit `expired`, future timestamps and deserialization
failures emit `unclassified`; conditional refill suppression remains
exactly PR 143's adapter-level watermark-miss path, byte-compatible with
`main` (differential-verified across the decode input matrix).
## Breaking change
`DialCacheMetricsAdapter.miss` now receives `MissMetricLabels`, and
first-party miss metrics require a `reason` label/tag. `RedisReadResult`
is now `DecodedRedisFrame | RedisReadMiss`; `null`,
`RedisWatermarkMiss`, `decodeRedisFrame`, and `decodeTrackedRedisFrame`
are removed, and bundled Redis adapters return `RedisReadMiss` for every
semantic miss.
Under the pre-1.0 release policy, this is a minor release.
## Validation
- Node.js 22.22.0: `corepack pnpm check`
- typecheck
- 656 unit tests with coverage thresholds
- ESM/CJS build and declaration generation
- packed-package type and runtime checks
- Node.js 22.22.0: `corepack pnpm test:integration`
- 143 passed
- 2 expected local GLIDE Cluster skips because announced container IPs
are not host-routable
- Review follow-up (`fix(metrics): preserve existing refill and decoder
behavior`) removed the fence-carry scope creep and restored
decoder/refill parity with `main`, differential-verified across the
decode input matrix
BREAKING CHANGE: DialCacheMetricsAdapter.miss now receives
MissMetricLabels and first-party miss metrics require a reason
label/tag; RedisReadResult is now DecodedRedisFrame | RedisReadMiss ({
kind: "miss", reason, observedWatermarkMs? }); null, RedisWatermarkMiss,
decodeRedisFrame, and decodeTrackedRedisFrame are removed, and bundled
Redis adapters return RedisReadMiss for every semantic miss.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@lan17