Skip to content

Classify cache misses with a bounded reason label #145

Description

@lan17

Summary

Add one required, bounded reason dimension directly to DialCache's existing cache-miss event and first-party miss metrics:

  • Prometheus: dialcache_miss_counter{cache_namespace,use_case,key_type,layer,reason}
  • Datadog: dialcache.miss.count with the same bounded reason tag

Do not add a parallel reason counter or compatibility counter. A miss is one event; its cause is a dimension of that event. Existing total-miss queries can aggregate without reason, while reason-aware queries can separate ordinary absence from invalidation churn.

This is a follow-up to #141 and #143. It is also the canonical reconciliation spec for the older, now-conflicting attempt in #130; that branch predates the current client-clock protocol and must not be merged or rebased mechanically.

Problem

dialcache_miss_counter and dialcache.miss.count currently identify only cache namespace, use case, key type, and layer. Operators cannot distinguish cold, expired, or evicted entries from tracked Redis frames rejected by an invalidation watermark.

The typed result introduced by PR 143 does not itself provide that attribution. RedisWatermarkMiss means that a tracked read observed a trustworthy refill fence; it can represent an absent value, a short or unsupported frame, or a frame actually rejected by the watermark. Likewise, shadow fill_fenced describes a post-fallback refill decision, not why the original read missed. Miss cause and refill-fence capability are independent.

Confirmed design

Keep one miss instrument

Extend the backend-neutral miss callback rather than adding another callback or metric:

export type CacheMissReason =
  | "value_absent"
  | "watermark_fenced"
  | "unclassified";

export interface MissMetricLabels extends CacheMetricLabels {
  readonly reason: CacheMissReason;
}

export interface DialCacheMetricsAdapter {
  miss(labels: MissMetricLabels): void;
  // existing methods unchanged
}

Keep CacheMetricLabels unchanged so reason does not leak into request, latency, size, compression, or other metric callbacks. Export CacheMissReason and MissMetricLabels from the package root.

Every existing miss emission must receive exactly one reason:

Reason Meaning
value_absent The layer had no retrievable value. This covers request-local/process-local absence and Redis nil, including never-populated, expired, evicted, and tracked-MGET wrong-type-as-null states. DialCache must not claim to distinguish those cases without additional evidence.
watermark_fenced A present tracked frame with a supported version and complete header was rejected because createdAtMs <= observedWatermarkMs for a present, valid numeric watermark. This is the decisive read condition; it does not claim that the application serializer would have accepted the payload.
unclassified The miss is real but its cause is not attributable to either category above. This includes legacy/custom-adapter null, short or unsupported frames, malformed metadata, invalid or future timestamps, and caller-side deserialization failure.

For bundled Redis reads, classification precedence is value_absent when the native value reply is nil, then watermark_fenced when the frame-versus-watermark comparison is decisive, then unclassified. Preserve existing error-versus-miss behavior; observability must not convert protocol errors into misses.

Request-local and process-local misses use value_absent. Caller-serving Redis uses layer="remote"; detached initial and confirmation reads use layer="remote_shadow". Read failures and timeouts remain bounded errors, not misses.

Carry cause independently from a write fence

The shared Redis decoder/result must preserve two orthogonal facts:

  1. the bounded reason the read missed; and
  2. whether the same authoritative snapshot supplied a valid observedWatermarkMs that can fence a refill.

An absent value can still carry a valid observed watermark and later produce fill_fenced. A watermark-fenced old frame can later admit a refill once the candidate client timestamp advances. Do not infer one dimension from the other.

Bundled node-redis and GLIDE adapters already have the required native GET/MGET evidence and must share one decoder path. Add no Redis command, round trip, key, frame field, watermark field, Lua path, or server-side classification. Legacy custom adapters returning null remain correct and emit unclassified; adapters can opt into precise reasons through the public decoder/result contract.

Accept the metric-schema migration

This intentionally changes the existing miss metric schema:

  • Prometheus keeps the same metric name but changes from four labels to five. An old four-label collector registered under the same prefix in the same Registry is incompatible and must continue to fail exact-schema validation before partial registration. Old and new DialCache versions cannot share one in-process registry/prefix.
  • During a mixed fleet rollout, old scraped series have no reason label and new series do. Miss/request ratios must aggregate miss series without reason before vector matching; reason dashboards should group explicitly by reason.
  • Datadog keeps the same metric name and adds one bounded tag. The three reasons increase steady-state custom-metric combinations by at most 3x per existing miss-label tuple; document the billing and mixed-tag-set implications.
  • Custom metric adapters that accept the broader CacheMetricLabels can ignore the new property, but direct callers, exact type assertions, exhaustive label snapshots, or adapters that reject/forward unknown fields may require migration.
  • The implementation PR must include a BREAKING CHANGE: footer and migration notes. Under the current pre-1.0 release policy this requires a minor release.

Do not retain or add a second miss counter for compatibility. The desired steady-state API is one miss event with one bounded reason.

Coordination

Acceptance criteria

  • CacheMissReason is the exact closed union value_absent | watermark_fenced | unclassified.
  • MissMetricLabels extends CacheMetricLabels with required reason; CacheMetricLabels itself is unchanged.
  • DialCacheMetricsAdapter.miss() receives MissMetricLabels, and the new types are exported from the package root.
  • Every existing request-local, process-local, remote, and remote_shadow miss emits exactly one reason and one total miss event; no read path double-counts.
  • watermark_fenced is emitted only from actual tracked frame-versus-watermark rejection, never merely because a miss carries observedWatermarkMs or a later refill is skipped.
  • A Redis nil value emits value_absent whether its paired watermark is absent or valid; a valid observed watermark remains available independently for PR 143's conditional refill decision.
  • Legacy/custom Redis clients returning null preserve their existing fail-open/refill behavior and emit unclassified, not cache_read and not fabricated absence.
  • node-redis and GLIDE derive reasons through the same semantic decoder and add no Redis operations.
  • Prometheus retains dialcache_miss_counter, adds required reason, rejects a pre-registered old-schema collector transactionally, and reuses a compatible new-schema collector.
  • Datadog retains dialcache.miss.count and adds the exact bounded reason tag.
  • Existing total-miss queries are documented as aggregations without reason; mixed-version Prometheus vector matching and Datadog cardinality migration are documented.
  • Tests cover all three reasons, every metric layer, caller/shadow parity, reason/write-fence orthogonality, throwing/rejecting custom metric adapters, exhaustive bounded-value guards, and packed ESM/CJS/TypeScript consumers.
  • Real Redis/Valkey coverage distinguishes an absent value from a present watermark-fenced frame across node-redis and GLIDE without adding commands.
  • The implementation PR includes an explicit breaking-change release note and updates both first-party metric tables and custom-adapter guidance.

Out of scope

  • Distinguishing never-created, expired, evicted, deleted, and tracked-MGET wrong-type values; Redis returns the same nil evidence for these cases.
  • Emitting timestamps, watermarks, cache keys, IDs, raw errors, or other unbounded values as metric labels.
  • Adding miss events for paths currently represented only as read errors/timeouts.
  • Changing cache serving, refill, invalidation, stale-recovery, or shadow-verdict behavior.
  • Adding a parallel reason metric or a compatibility counter.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions