From 83e8a7c0d918942a5b0e8f6b1370a65ae9eec35c Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sat, 29 Aug 2026 02:02:43 -0700 Subject: [PATCH 1/2] feat(metrics)!: classify cache misses by reason Add a bounded reason to the existing miss event while preserving the independent observed-watermark refill fence for typed Redis reads. BREAKING CHANGE: DialCacheMetricsAdapter.miss now receives MissMetricLabels and first-party miss metrics require a reason label/tag; RedisReadResult now includes RedisReadMiss, and bundled Redis adapters return typed miss objects instead of null for semantic misses. --- README.md | 42 +++-- scripts/test-package.mjs | 118 ++++++++++-- src/datadog.ts | 5 +- src/dialcache.ts | 17 +- src/index.ts | 3 + src/internal/cache-result.ts | 1 + src/internal/redis-cache.ts | 111 ++++++++--- src/internal/redis-payload.ts | 115 ++++++++---- src/metrics.ts | 9 +- src/node-redis.ts | 4 +- src/prometheus.ts | 10 +- src/redis-client.ts | 69 +++++-- src/redis-protocol.ts | 2 + src/valkey-glide.ts | 4 +- test/datadog.test.ts | 24 ++- test/dialcache-coalescing.test.ts | 1 + test/dialcache-invalidation.test.ts | 7 +- test/dialcache-metrics.test.ts | 203 ++++++++++++++++++++- test/dialcache-redis.test.ts | 48 ++++- test/dialcache-shadow-confirmation.test.ts | 69 +++++-- test/dialcache-shadow-validation.test.ts | 10 +- test/dialcache-stale-on-error.test.ts | 59 ++++++ test/fake-redis.ts | 47 ++--- test/node-redis.test.ts | 18 +- test/prometheus.test.ts | 92 +++++++++- test/redis-cluster.integration.test.ts | 2 + test/redis-payload.test.ts | 40 ++-- test/redis-real.integration.test.ts | 38 +++- test/valkey-glide.test.ts | 24 ++- 29 files changed, 967 insertions(+), 225 deletions(-) diff --git a/README.md b/README.md index 837550a..87e047e 100644 --- a/README.md +++ b/README.md @@ -408,13 +408,13 @@ Awaiting those public promises does not drain detached shadow work. Shadow sched Neither adapter owns additional resources or native script handles, so the application simply closes the underlying client after draining work. -Reads use native `GET` for untracked entries and one atomic `MGET` for each tracked value-and-watermark pair. The adapters validate and decode the returned frame in the Node process. Tracked reads are deliberately routed to primaries so a lagging replica cannot hide an invalidation watermark. When a tracked semantic miss observes a present, valid numeric watermark, the bundled adapters return `RedisWatermarkMiss { kind: "watermark_miss", observedWatermarkMs }`; generic and legacy misses remain `null`. After a read settles, DialCache evaluates the frame against the observing application's `Date.now()`. Without stale-on-error, the read accepts only nonnegative ages strictly below the effective remote TTL `F`. With a positive recovery maximum `M`, that same initial read is bounded by `M`: ages below `F` deserialize and serve normally, while ages from `F` through strictly below `M` remain raw as a possible source-error recovery candidate. Future-dated frames fail closed before deserialization and emit the bounded offset observation described under [Metrics](#metrics). A shadow confirmation read still observes a future offset but retains the payload only for supersession comparison; it can never serve that frame. +Reads use native `GET` for untracked entries and one atomic `MGET` for each tracked value-and-watermark pair. The adapters validate and decode the returned frame in the Node process. Tracked reads are deliberately routed to primaries so a lagging replica cannot hide an invalidation watermark. Bundled adapters return bounded classified misses: `RedisReadMiss { reason }` when no trustworthy refill fence exists, or `RedisWatermarkMiss { kind: "watermark_miss", reason, observedWatermarkMs }` when the same tracked snapshot carried a valid numeric watermark. Redis `nil` is `value_absent`; only a complete supported tracked frame rejected at or below the watermark is `watermark_fenced`; and short or unsupported frames and malformed metadata are `unclassified`. Cause remains independent from the fence, so an absent value can still carry `observedWatermarkMs` and suppress a later refill. Legacy custom `null` results remain correct and are reported as `unclassified`. After a read settles, DialCache evaluates a decoded frame against the observing application's `Date.now()`. Without stale-on-error, the read accepts only nonnegative ages strictly below the effective remote TTL `F`. With a positive recovery maximum `M`, that same initial read is bounded by `M`: ages below `F` deserialize and serve normally, while ages from `F` through strictly below `M` remain raw as a possible source-error recovery candidate. Future-dated frames fail closed before deserialization and emit the bounded offset observation described under [Metrics](#metrics). A shadow confirmation read still observes a future offset but retains the payload only for supersession comparison; it can never serve that frame. Writes are native too, so the payload never crosses the Redis-to-Lua boundary. Conditional tracked refills follow the two-sample fence described under [targeted invalidation](#targeted-invalidation-and-watermarks): a preflight can avoid payload preparation, while an admitted fill uses a final dispatch-adjacent timestamp so serialization time does not consume its logical TTL. Ordinary, untracked, missing/malformed-watermark, and legacy-adapter misses leave optional `RedisWriteRequest.createdAtMs` absent, preserving the adapter-side `Date.now()` sample immediately before dispatch. Every dispatched write issues one `SET valueKey frame PX cacheTtlMs` containing the complete version-1 frame; only tracked reads include the watermark key. With stale-on-error enabled, the requested physical TTL is `M` instead of `F`. `M` remains the configured logical recovery ceiling even when it exceeds one hour. Core separately caps every tracked Redis value's physical TTL at one hour, so such a tracked candidate may be evicted by expiry before it reaches logical age `M`; untracked Redis and local TTLs retain their configured limits. Each dispatched tracked write whose requested TTL exceeds that cap emits `error="tracked_ttl_clamped"`. The write never reads, creates, or extends a watermark. A dispatched frame can still be fenced if the watermark advances after the read snapshot. Same-key writes are ordinary Redis last-writer-wins operations, with no Lua, pipeline, or transaction on the write path. The network shape remains one top-level Redis command and one round trip per semantic read (`GET` or `MGET`) and one `SET` per dispatched write. Stale recovery reuses the frame returned by that initial command and never adds a second Redis read, including after a source rejection. Retaining a raw candidate instead consumes process memory until that source attempt settles, once per distinct in-flight key (same-key coalesced callers share it). Conditional refill suppression reuses the existing tracked `MGET` result and adds no command or round trip. DialCache does not call Redis `TIME` or maintain a Redis-clock offset. Use the maintainer benchmarks below to measure the target Redis/Valkey version, payload distribution, and high-cardinality in-flight memory exposure. -Native commands retain Redis's wrong-type behavior. An untracked `GET` surfaces `WRONGTYPE`; tracked `MGET` represents a wrong-type member as a missing value. A wrong-type tracked value is therefore a semantic miss; when the paired watermark is a valid numeric string, the miss carries that fence and may skip a known-dead refill, while an absent or wrong-type watermark yields the ordinary zero-baseline behavior. A wrong-type watermark is indistinguishable from an absent watermark to `MGET`; it does not prevent or alter native value writes. The next explicit invalidation replaces that wrong-type key with a valid string watermark. Other script read failures remain errors and cannot bypass the monotonic update. +Native commands retain Redis's wrong-type behavior. An untracked `GET` surfaces `WRONGTYPE`; tracked `MGET` represents a wrong-type member as a missing value. A wrong-type tracked value is therefore `value_absent`; when the paired watermark is a valid numeric string, the result also carries that independent refill fence, while an absent or wrong-type watermark yields the ordinary zero-baseline behavior. A wrong-type watermark is indistinguishable from an absent watermark to `MGET`; it does not prevent or alter native value writes. The next explicit invalidation replaces that wrong-type key with a valid string watermark. Other script read failures remain errors and cannot bypass the monotonic update. Node-redis forces tracked cluster commands to the slot primary. GLIDE uses an explicit primary route in cluster mode; in standalone mode it sends `MGET` through a one-command non-atomic batch because direct read commands follow the client's replica-read preference. Standalone batches use the primary, and `MGET` itself provides the atomic snapshot without consuming caller-owned `WATCH` state. The GLIDE helper distinguishes those modes from the direct client's runtime identity and rejects ambiguous clients instead of silently choosing a route. @@ -482,9 +482,9 @@ Writes, invalidations, async `cacheConfigProvider` calls, and custom serializer #### Serialization -The core Redis boundary is the client-agnostic `DialCacheRedisClient` interface. Writes accept serialized values as `string | Buffer`; reads return `RedisReadResult`, which is a `DecodedRedisFrame` — the decoded `string | Buffer` payload plus the frame header's writer-client `createdAtMs` — `RedisWatermarkMiss { kind: "watermark_miss", observedWatermarkMs }`, or `null`. The interface does not expose client commands or wire encodings. A decoded frame's timestamp is correctness-relevant: custom clients must return the frame's valid nonnegative safe-integer epoch timestamp rather than a constant. `RedisWriteRequest` contains `valueKey`, `cacheTtlMs`, `value`, and optional `createdAtMs`, and `write()` returns `void`; watermark ownership remains exclusive to tracked reads and invalidation. Core supplies `createdAtMs` only after a discriminated miss passes both the preflight and final watermark checks; the supplied value is the final dispatch-adjacent sample. A custom client that continues returning only `DecodedRedisFrame | null` remains correct and source-compatible, but does not enable the known-fenced refill optimization. A custom client that returns the `RedisWatermarkMiss` variant opts into that optimization and must encode a supplied `RedisWriteRequest.createdAtMs` exactly so the final fence decision and stored frame cannot diverge. +The core Redis boundary is the client-agnostic `DialCacheRedisClient` interface. Writes accept serialized values as `string | Buffer`; reads return `RedisReadResult`, which is a `DecodedRedisFrame` — the decoded `string | Buffer` payload plus the frame header's writer-client `createdAtMs` — `RedisReadMiss { reason }`, `RedisWatermarkMiss { kind: "watermark_miss", reason, observedWatermarkMs }`, or `null`. The interface does not expose client commands or wire encodings. A decoded frame's timestamp is correctness-relevant: custom clients must return the frame's valid nonnegative safe-integer epoch timestamp rather than a constant. `RedisWriteRequest` contains `valueKey`, `cacheTtlMs`, `value`, and optional `createdAtMs`, and `write()` returns `void`; watermark ownership remains exclusive to tracked reads and invalidation. Core supplies `createdAtMs` only after a discriminated watermark miss passes both the preflight and final checks; the supplied value is the final dispatch-adjacent sample. A custom client that continues returning only `DecodedRedisFrame | null` remains correct and source-compatible, but its `null` misses are reported as `unclassified` and do not enable the known-fenced refill optimization. A custom client that returns a classified miss with the watermark discriminant and a valid `observedWatermarkMs` opts into that optimization and must encode a supplied `RedisWriteRequest.createdAtMs` exactly so the final fence decision and stored frame cannot diverge. Older typed watermark-miss shapes remain accepted at runtime and normalize a missing or invalid reason to `unclassified`. -The shared `encodeRedisFrame`, `decodeRedisFrame`, `decodeTrackedRedisFrame`, and `decodeTrackedRedisReadResult` helpers, the `validateRedisSetReply` and `validateRedisScriptInvalidationReply` reply helpers, the `ceilSupportedCacheTtlMs` TTL guard, and the invalidation Lua source are available from `dialcache/redis-protocol`. A custom write chooses `const createdAtMs = request.createdAtMs === undefined ? Date.now() : request.createdAtMs`, calls `encodeRedisFrame(request.value, createdAtMs)`, and sends one `SET valueKey frame PX cacheTtlMs` after validating the TTL. The fallback clock covers ordinary core writes and direct callers that omit the optional field; supplied values must not be resampled or replaced, and invalid runtime values must still be rejected by the frame encoder. A custom tracked read atomically obtains `[value, watermark]` from the primary. Passing both replies to `decodeTrackedRedisReadResult` opts into discriminated watermark misses; the backward-compatible `decodeTrackedRedisFrame` collapses those misses to `null` and preserves the established `DecodedRedisFrame | null` surface. A missing watermark is treated as zero for valid frames, while a malformed numeric watermark fails closed as a generic miss. Invalidation passes `KEYS = [watermarkKey]` and `ARGV = [futureBufferMs, invalidatedAtMs]`, with one stable client timestamp reused across retries. Custom adapters can throw the root-exported `DialCacheRedisPayloadError`, `DialCacheRedisPayloadEncodingError`, and `DialCacheRedisProtocolError` classes to distinguish malformed replies, unsupported encodings, and reply-domain violations. DialCache records bounded `cache_read`, `cache_write`, or `invalidation` metrics by failure site. +The shared `encodeRedisFrame`, `decodeRedisFrame`, `decodeRedisReadResult`, `decodeTrackedRedisFrame`, and `decodeTrackedRedisReadResult` helpers, the `validateRedisSetReply` and `validateRedisScriptInvalidationReply` reply helpers, the `ceilSupportedCacheTtlMs` TTL guard, and the invalidation Lua source are available from `dialcache/redis-protocol`. A custom write chooses `const createdAtMs = request.createdAtMs === undefined ? Date.now() : request.createdAtMs`, calls `encodeRedisFrame(request.value, createdAtMs)`, and sends one `SET valueKey frame PX cacheTtlMs` after validating the TTL. The fallback clock covers ordinary core writes and direct callers that omit the optional field; supplied values must not be resampled or replaced, and invalid runtime values must still be rejected by the frame encoder. A custom untracked read can pass its native reply to `decodeRedisReadResult`; a custom tracked read atomically obtains `[value, watermark]` from the primary and passes both replies to `decodeTrackedRedisReadResult`. Those typed helpers preserve the exact bounded reason and carry any valid observed fence on the discriminated variant, while the backward-compatible frame helpers collapse misses to `null` and preserve the established `DecodedRedisFrame | null` surface. A missing watermark is treated as zero for valid frames; malformed numeric watermark metadata is `unclassified`, except that a native `nil` value remains `value_absent`. Invalidation passes `KEYS = [watermarkKey]` and `ARGV = [futureBufferMs, invalidatedAtMs]`, with one stable client timestamp reused across retries. Custom adapters can throw the root-exported `DialCacheRedisPayloadError`, `DialCacheRedisPayloadEncodingError`, and `DialCacheRedisProtocolError` classes to distinguish malformed replies, unsupported encodings, and reply-domain violations. DialCache records bounded `cache_read`, `cache_write`, or `invalidation` metrics by failure site. Redis values use a compact binary frame: @@ -495,7 +495,7 @@ byte 10 payload encoding (0 = UTF-8, 1 = raw binary) bytes 11... serialized payload (optionally zstd-compressed; see Compression) ``` -Adapters build complete frames in the Node process with `encodeRedisFrame` and decode them with Node buffer primitives. `RedisWatermarkMiss` exists only across the in-process semantic adapter boundary; it is not a new Redis value. The version-1 value-envelope format, Redis value-key derivation, decimal watermark encoding, tracked `MGET`, and dispatched `SET` are unchanged. That wire compatibility does not make old tracked state safe to carry across the protocol cutover: old watermark lifetimes were derived for the old write protocol. +Adapters build complete frames in the Node process with `encodeRedisFrame` and decode them with Node buffer primitives. `RedisReadMiss` and the backward-compatible `RedisWatermarkMiss` exist only across the in-process semantic adapter boundary; neither is a new Redis value. The version-1 value-envelope format, Redis value-key derivation, decimal watermark encoding, tracked `MGET`, and dispatched `SET` are unchanged. That wire compatibility does not make old tracked state safe to carry across the protocol cutover: old watermark lifetimes were derived for the old write protocol. Redis physical TTL bounds how long the stored key remains available to future reads. A completed read owns its returned frame, so later expiry, deletion, or eviction cannot revoke that in-process snapshot. The frame timestamp enforces logical `F`/`M` age, future-frame rejection, and shadow value-age observability. `payload` is produced by the operation's serializer, or by `JsonSerializer` by default. Custom serializers can return either `string` or `Buffer`. Payloads stored raw keep their exact serialized bytes: strings are stored as UTF-8 and Buffers byte-for-byte without base64 expansion, except that binary output beginning with a [compression envelope byte](#compression) (`0x00`–`0x02`) gains a one-byte escape prefix on the wire. Payloads at or above the compression threshold may instead be stored as a zstd envelope (see [Compression](#compression)), so wire bytes for large values are not the serializer's output. Adapters return the frame payload as-is; the envelope — including restoring a compressed string's representation before `serializer.load` — is interpreted by the core above them. @@ -615,16 +615,16 @@ On a served hit, DialCache returns the already-decoded cached value before start The detached job uses this bounded algorithm: 1. Obtain the original Redis payload as `C0` using the key's existing tracked or untracked read mode. -2. If `C0` is missing, wait for the caller's successfully accepted `S` after its configured fallback boundary. For a discriminated tracked watermark miss, sample a preflight timestamp before serialization: emit `fill_fenced` and stop without invoking `serializer.dump`, compression, or Redis when that timestamp is at or before the observed watermark. Otherwise prepare the serialized payload, sample a final timestamp dispatch-adjacent, and recheck the watermark. Emit `fill_fenced` without dispatching `SET` if the final timestamp is at or before the watermark; otherwise attempt one normal Redis write using the resolved TTL and that exact final timestamp. For a generic `null` miss, attempt the ordinary write without supplying a core timestamp, preserving adapter-side sampling. Before the whole-job deadline, emit `filled` when Redis accepts the write or `fill_error` when serialization or the write fails. A tracked fill can still be physically stored yet remain fenced if the watermark advances after `C0`. +2. If `C0` is missing, wait for the caller's successfully accepted `S` after its configured fallback boundary. For a discriminated tracked watermark miss, sample a preflight timestamp before serialization: emit `fill_fenced` and stop without invoking `serializer.dump`, compression, or Redis when that timestamp is at or before the observed watermark. Otherwise prepare the serialized payload, sample a final timestamp dispatch-adjacent, and recheck the watermark. Emit `fill_fenced` without dispatching `SET` if the final timestamp is at or before the watermark; otherwise attempt one normal Redis write using the resolved TTL and that exact final timestamp. For a classified miss without a valid observed fence, or a legacy `null` miss, attempt the ordinary write without supplying a core timestamp, preserving adapter-side sampling. Before the whole-job deadline, emit `filled` when Redis accepts the write or `fill_error` when serialization or the write fails. A tracked fill can still be physically stored yet remain fenced if the watermark advances after `C0`. 3. If `C0` is non-null, obtain `S`, deserialize an isolated snapshot of `C0`, and run the default or custom semantic comparator. Any non-null `C0` is observation-only: DialCache never repairs or overwrites it, including when deserialization fails. 4. If `C0` and `S` match semantically, emit `match` without another Redis read. 5. Otherwise, reread Redis directly in the same mode as `C1`, bypassing request-local and process-local cache. 6. If `C1` is missing under the normal value/watermark protocol or differs byte-for-byte from `C0`, emit `superseded`; if it is identical, emit `mismatch`. A future-dated `C1` records its offset but remains available for this payload comparison, so a reader-clock step does not change the verdict. 7. If the confirmation read fails or reaches its Redis-read deadline, emit `confirmation_error`. -Here a semantic miss means the Redis read returned `null` or `RedisWatermarkMiss { kind: "watermark_miss", observedWatermarkMs }`; it does not include a non-null payload that later fails deserialization. A physical tracked frame rejected by its watermark, timestamp domain, or future-time check is therefore a miss. Only the discriminated miss carries a trustworthy observed fence and can produce `fill_fenced`; generic or legacy `null` misses retain the normal refill behavior. A caller fallback rejection or timeout never becomes accepted `S` and never starts the fill. +Here a semantic miss means the Redis read returned a classified `RedisReadMiss`, a discriminated `RedisWatermarkMiss`, or legacy `null`; it does not include a non-null payload that later fails deserialization. A physical frame rejected by its watermark (for tracked reads), timestamp domain, logical-age check, or future-time check is therefore a miss. Only a watermark miss with a valid observed fence can produce `fill_fenced`; its independent reason can still be `value_absent`, `watermark_fenced`, or `unclassified`. Misses without a trustworthy fence and legacy `null` retain normal refill behavior. A caller fallback rejection or timeout never becomes accepted `S` and never starts the fill. -Both detached Redis reads use the effective `remoteReadTimeoutMs` and the key's normal native `GET` or `MGET` protocol. The initial `C0` observation enforces the logical remote TTL `F`; the non-serving `C1` confirmation bypasses logical age solely to determine whether the original payload bytes were superseded. Every dispatched semantic-miss fill uses the same serializer, resolved physical TTL, complete-frame `SET`, and conditional-refill rules as the caller path; generic `null` fills preserve the ordinary adapter-side timestamp sample. Tracked `C0` and `C1` reads remain watermark-aware and are routed to primaries by the bundled adapters. The fill itself never issues another watermark read or mutates the watermark; `fill_fenced` is decided against the watermark from the original `C0` snapshot and adds no round trip. Untracked reads use the ordinary one-key read route, which has no shadow-specific primary guarantee. Strings compare exactly, Buffers compare by bytes, and string/Buffer pairs compare by their UTF-8 bytes. DialCache does not deserialize `C1`, compare it with `S`, or chase another version. +Both detached Redis reads use the effective `remoteReadTimeoutMs` and the key's normal native `GET` or `MGET` protocol. The initial `C0` observation enforces the logical remote TTL `F`; the non-serving `C1` confirmation bypasses logical age solely to determine whether the original payload bytes were superseded. Every dispatched semantic-miss fill uses the same serializer, resolved physical TTL, complete-frame `SET`, and two-sample conditional-refill rules as the caller path; when stale-on-error is active, it requests physical retention through `M` while serving reads still enforce `F`. Misses without a valid observed fence preserve the ordinary adapter-side timestamp sample. Tracked `C0` and `C1` reads remain watermark-aware and are routed to primaries by the bundled adapters. The fill itself never issues another watermark read or mutates the watermark; `fill_fenced` is decided against the watermark from the original `C0` snapshot and adds no round trip. Untracked reads use the ordinary one-key read route, which has no shadow-specific primary guarantee. Strings compare exactly, Buffers compare by bytes, and string/Buffer pairs compare by their UTF-8 bytes. DialCache does not deserialize `C1`, compare it with `S`, or chase another version. The detached scheduler, Redis-read deadline timers, and overall shadow deadline timer are unreferenced, so they do not keep an otherwise idle process alive. Detachment is asynchronous work on the Node event loop, not a worker thread: synchronous source, serializer, or comparator work can still occupy the event loop after the request path has been released. @@ -660,7 +660,7 @@ Detached Redis reads, serializer loads/dumps, payload sizes, and read/write erro The command amplification is bounded: a selected served hit adds one SoT read and adds `C1` only for a semantic mismatch candidate; a selected ramped-down hit adds detached `C0`, reuses the caller's existing SoT read, and likewise adds `C1` only for a candidate; a selected ramped-down miss adds detached `C0` and at most one fill in the key's existing mode. A fenced tracked fill adds no `SET`, and a preflight fence also avoids payload preparation; no path adds a fence-check command. `superseded` means only that the original observation could not be confirmed. `mismatch` means the exact `C0` payload survived another Redis read after the SoT disagreement; it is not a cross-system atomic snapshot or a guarantee that the mismatch persists. For an untracked key it is also not proof of primary freshness or invalidation safety. -The initial `C0` read and later fill are not atomic. A discriminated miss can suppress a fill against the `C0` watermark, but an allowed fill is still a normal overwrite, not a compare-and-set or write-if-still-missing operation: another writer can populate Redis after the semantic miss and be overwritten by the shadow fill, or a later invalidation can fence it. For tracked keys, the next atomic value-and-watermark read still rejects a frame whose client timestamp is at or before the watermark, so size `futureBufferMs` to cover the complete SoT, serialization, client queue, network, write interval, and fleet clock-skew budget when stale-serving protection matters. An untracked shadow fill has no such read fence and retains the ordinary TTL-based last-writer-wins contract; because it is detached, an older accepted source value may be written after a concurrent source mutation and remain until expiry. Shadow mode never repairs a frame returned as a non-null semantic `C0`; a physical frame rejected by normal tracked-read semantics is a miss and may be overwritten with a fresh TTL unless its discriminated fence still rejects the refill. Shadow work never invalidates, evicts local state, or changes the value returned to the caller. +The initial `C0` read and later fill are not atomic. A discriminated miss with a valid observed watermark can suppress a fill against the `C0` fence regardless of the independent miss reason, but an allowed fill is still a normal overwrite, not a compare-and-set or write-if-still-missing operation: another writer can populate Redis after the semantic miss and be overwritten by the shadow fill, or a later invalidation can fence it. For tracked keys, the next atomic value-and-watermark read still rejects a frame whose client timestamp is at or before the watermark, so size `futureBufferMs` to cover the complete SoT, serialization, client queue, network, write interval, and fleet clock-skew budget when stale-serving protection matters. An untracked shadow fill has no such read fence and retains the ordinary TTL-based last-writer-wins contract; because it is detached, an older accepted source value may be written after a concurrent source mutation and remain until expiry. Shadow mode never repairs a frame returned as a non-null semantic `C0`; a physical frame rejected by normal tracked-read semantics is a miss and may be overwritten with a fresh TTL unless its observed fence still rejects the refill. Shadow work never invalidates, evicts local state, or changes the value returned to the caller. A served-hit sample invokes the wrapped function or inline loader as an additional source read, so that loader must be safe to call for observation. A ramped-down sample reuses the caller's ordinary invocation and does not add another SoT call. @@ -721,7 +721,7 @@ Invalidation writes a Redis watermark at `{encodedNamespace:encodedKeyType:encod The internal `:dialcache-frame-v1` suffix identifies values written with DialCache's binary protocol. Watermarks are stored as decimal timestamps. -A cached Redis value whose writer-provided `createdAtMs` is older than or equal to the watermark is a tracked miss. `invalidateRemote(keyType, id, futureBufferMs)` proposes the invalidating process's `Date.now()` plus the buffer, and Lua keeps the greater of that proposal and the existing watermark. A tracked read obtains value and watermark in one primary-routed `MGET`; a missing watermark is the zero baseline, malformed or out-of-range decimal state fails closed, and `createdAtMs <= watermark` misses. Redis also returns `nil` for a wrong-type member of `MGET`, so a wrong-type watermark has the same zero-baseline behavior as an absent one until the next explicit invalidation repairs it. When the same snapshot contains a present valid numeric watermark and an adapter-level semantic miss, the bundled adapter returns `RedisWatermarkMiss { kind: "watermark_miss", observedWatermarkMs }`. After the fallback succeeds, DialCache samples a preflight timestamp before serialization. If it is at or before the observed watermark, fallback still returns normally but the known-fenced replacement is skipped before serializer/compression/frame work and `SET`. Otherwise DialCache prepares the payload, samples a final dispatch-adjacent timestamp, and rechecks the same watermark. A final timestamp at or before the watermark suppresses `SET`; an admitted write encodes that exact final timestamp so serialization time does not consume the stored value's logical TTL. Missing or malformed watermark metadata and legacy custom adapters that return `null` retain the normal refill behavior. Native `MGET` must still transfer the full stored frame before Node can apply the fence verdict, so a future window can repeatedly transfer a large fenced payload even when replacement work is suppressed. A tracked invocation that reaches the Redis read/write path does not publish its fallback directly to process-local cache; a later validated Redis hit may warm it. Local-only, remote-policy-disabled, and ramped-down paths remain governed by local policy, while request-local memoization remains unconditional. A ramped-out invocation without shadow work does not consult Redis; selected tracked shadow reads remain watermark-aware. +A cached Redis value whose writer-provided `createdAtMs` is older than or equal to the watermark is a `watermark_fenced` tracked miss. `invalidateRemote(keyType, id, futureBufferMs)` proposes the invalidating process's `Date.now()` plus the buffer, and Lua keeps the greater of that proposal and the existing watermark. A tracked read obtains value and watermark in one primary-routed `MGET`; a missing watermark is the zero baseline, malformed or out-of-range decimal state is `unclassified`, and `createdAtMs <= watermark` is `watermark_fenced`. Redis also returns `nil` for a wrong-type member of `MGET`, so a wrong-type watermark has the same zero-baseline behavior as an absent one until the next explicit invalidation repairs it. When the same snapshot contains a valid numeric watermark and an adapter-level semantic miss, the bundled adapter returns `RedisWatermarkMiss { kind: "watermark_miss", reason, observedWatermarkMs }`. The reason remains independent: a missing value is `value_absent` while carrying the same refill fence, and only a complete supported frame actually rejected by the watermark is `watermark_fenced`. After the fallback succeeds, DialCache samples a preflight timestamp before serialization. If it is at or before `observedWatermarkMs`, fallback still returns normally but the replacement is skipped before serializer/compression/frame work and `SET`. Otherwise DialCache prepares the payload, samples a final dispatch-adjacent timestamp, and rechecks the same watermark. A final timestamp at or before the watermark suppresses `SET`; an admitted write encodes that exact final timestamp so serialization time does not consume the stored value's logical TTL. Classified misses without a valid observed fence and legacy custom adapters that return `null` retain normal refill behavior. Native `MGET` must still transfer the full stored frame before Node can apply the fence verdict, so a future window can repeatedly transfer a large fenced payload even when replacement work is suppressed. A tracked invocation that reaches the Redis read/write path does not publish its fallback directly to process-local cache; a later validated Redis hit may warm it. Local-only, remote-policy-disabled, and ramped-down paths remain governed by local policy, while request-local memoization remains unconditional. A ramped-out invocation without shadow work does not consult Redis; selected tracked shadow reads remain watermark-aware. All serving timestamps come from application-process epoch clocks; DialCache does not call Redis `TIME`, estimate an offset, or compensate for skew. Participating application nodes therefore need external clock synchronization and monitoring. Healthy managed node pools commonly stay close, but Kubernetes does not guarantee a maximum offset, and pauses or NTP faults can be much larger than normal millisecond-scale skew. Relative clock differences shift logical expiry early or late, while frames dated after a reader clock fail closed until that clock catches up. Operation durations and deadlines continue to use the monotonic `performance.now()` clock. @@ -737,7 +737,7 @@ The fixed one-minute watermark-TTL margin is retention slack after the covered v This is a read-time timing contract rather than a cancellation or acquisition fence. The buffer makes covered frames unreadable. A typed miss can locally avoid a replacement already known to be fenced, but it does not cancel previously dispatched work, prevent a later watermark advance from fencing an allowed `SET`, or force fallback to read from an authoritative source. -The version-1 value envelope, Redis keys, and decimal watermarks remain wire-format-compatible. The adapter contract and Lua surface are not source-compatible: `write()` is now void and has no `watermarkKey`; the stamp script, placeholder helpers, and `DialCacheRedisPlaceholderLostError` are removed; `dialcacheRedisScripts` and `DialCacheNodeRedisScripts` are removed because node-redis manages invalidation dispatch internally; `ValkeyGlideRuntime` no longer requires `ClusterBatch`; invalidation is the only script; `fill_blocked` is removed from `ShadowValidationOutcome`; and `tracked_ttl_clamped` is added to `MetricErrorKind`, so exhaustive switches and `Record` values must add it. The conditional-refill addition itself does not change the Redis wire format, key format, Lua surface, command types, or round-trip shape; it may suppress an otherwise-dispatched `SET`. `RedisReadResult` is widened with opt-in `RedisWatermarkMiss`, but a custom adapter returning the legacy `DecodedRedisFrame | null` remains correct. `RedisWriteRequest.createdAtMs` is optional for source compatibility and direct callers may omit it; adapters returning typed watermark misses must honor a supplied value exactly. `fill_fenced` is added to `ShadowValidationOutcome`, so exhaustive switches and `Record` values must include it. The Prometheus future-timestamp histogram now uses a dedicated skew-oriented bucket schema, which is incompatible with an existing same-name collector registered with the former default buckets. Follow the externally gated [protocol cutover](#protocol-cutover) before deployment. Deploy the future-timestamp metric and external node-clock alerts first. The metric is only a workload-shaped smoke detector: it cannot detect co-skewed readers and writers, an ahead invalidator, watermark skew hidden by a fenced miss, or which node is wrong. +The version-1 value envelope, Redis keys, and decimal watermarks remain wire-format-compatible. The adapter contract and Lua surface are not source-compatible: `write()` is now void and has no `watermarkKey`; the stamp script, placeholder helpers, and `DialCacheRedisPlaceholderLostError` are removed; `dialcacheRedisScripts` and `DialCacheNodeRedisScripts` are removed because node-redis manages invalidation dispatch internally; `ValkeyGlideRuntime` no longer requires `ClusterBatch`; invalidation is the only script; `fill_blocked` is removed from `ShadowValidationOutcome`; and `tracked_ttl_clamped` is added to `MetricErrorKind`, so exhaustive switches and `Record` values must add it. The conditional-refill addition itself does not change the Redis wire format, key format, Lua surface, command types, or round-trip shape; it may suppress an otherwise-dispatched `SET`. `RedisReadResult` is widened with opt-in classified `RedisReadMiss` results, while the prior `RedisWatermarkMiss` remains source-compatible; a custom adapter returning the legacy `DecodedRedisFrame | null` also remains correct. `RedisWriteRequest.createdAtMs` is optional for source compatibility and direct callers may omit it; adapters returning a typed miss with a valid observed watermark must honor a supplied value exactly. `fill_fenced` is added to `ShadowValidationOutcome`, so exhaustive switches and `Record` values must include it. The Prometheus future-timestamp histogram now uses a dedicated skew-oriented bucket schema, which is incompatible with an existing same-name collector registered with the former default buckets. Follow the externally gated [protocol cutover](#protocol-cutover) before deployment. Deploy the future-timestamp metric and external node-clock alerts first. The metric is only a workload-shaped smoke detector: it cannot detect co-skewed readers and writers, an ahead invalidator, watermark skew hidden by a fenced miss, or which node is wrong. Targeted invalidation is remote-only and enforced by Redis watermarks. `invalidateRemote` does not evict existing request-local or process-local entries. Strongly invalidated mutable data should disable request-local and process-local caching (or use a very short process-local TTL only when stale reads are acceptable). @@ -848,14 +848,14 @@ app.get("/metrics", async (_req, res) => { }); ``` -The adapter requires a caller-owned `Registry`; it never uses the global default registry and does not clear or otherwise own the registry lifecycle. Multiple adapters with the same registry and prefix reuse existing collectors when their type, help, labels, histogram buckets, and exemplar mode match. Adapter construction fails before registering anything if a same-name collector has an incompatible schema; use a unique prefix or a separate registry to resolve the collision. +The adapter requires a caller-owned `Registry`; it never uses the global default registry and does not clear or otherwise own the registry lifecycle. Multiple adapters with the same registry and prefix reuse existing collectors when their type, help, labels, histogram buckets, and exemplar mode match. Adapter construction fails before registering anything if a same-name collector has an incompatible schema; use a unique prefix or a separate registry to resolve the collision. In particular, `dialcache_miss_counter` now has five labels rather than four, so an old four-label collector under the same registry and prefix is incompatible. Old and new DialCache versions cannot share that in-process registry/prefix; the old-schema collision fails before any partial registration. The Prometheus adapter emits: | Metric | Type | Labels | Description | | --- | --- | --- | --- | | `dialcache_request_counter` | Counter | `cache_namespace`, `use_case`, `key_type`, `layer` | Cache-layer requests that reached an enabled layer | -| `dialcache_miss_counter` | Counter | `cache_namespace`, `use_case`, `key_type`, `layer` | Cache misses | +| `dialcache_miss_counter` | Counter | `cache_namespace`, `use_case`, `key_type`, `layer`, `reason` | Cache misses, classified by one required bounded reason | | `dialcache_disabled_counter` | Counter | `cache_namespace`, `use_case`, `key_type`, `layer`, `reason` | Cache skips (`context`, `policy_disabled`, `invalid_ttl`, `invalid_ramp`, `ramped_down`, `config_error`) | | `dialcache_error_counter` | Counter | `cache_namespace`, `use_case`, `key_type`, `layer`, `error`, `in_fallback` | Cache/fallback errors and the bounded `tracked_ttl_clamped` configuration signal | | `dialcache_invalidation_counter` | Counter | `cache_namespace`, `key_type`, `layer` | Invalidation calls for the layers touched | @@ -874,6 +874,16 @@ The Prometheus adapter emits: | `dialcache_compression_ratio_histogram` | Histogram | `cache_namespace`, `use_case`, `key_type`, `layer` | Compressed-to-original payload size ratio for compressed writes | | `dialcache_compression_timer` | Histogram | `cache_namespace`, `use_case`, `key_type`, `layer`, `operation` | Payload compression and decompression latency in seconds | +`dialcache_miss_counter` remains one miss counter, not a parallel reason or compatibility counter. Its required `reason` is exactly one of: + +| `reason` | Meaning | +| --- | --- | +| `value_absent` | The layer had no retrievable value, including never-populated, expired, evicted, Redis `nil`, and tracked-`MGET` wrong-type-as-null states. | +| `watermark_fenced` | A complete supported tracked frame was rejected because its timestamp was at or before a valid observed invalidation watermark; this happens before deserialization and does not assert that the payload would deserialize. | +| `unclassified` | The miss was real but cannot be attributed to either prior category, including legacy/custom Redis `null`, short or unsupported frames, malformed metadata, logically expired frames (including frames retained only as stale-on-error candidates), invalid or future timestamps, and caller-side deserialization failures. | + +This is an intentional Prometheus schema migration. During a mixed-fleet rollout, old scraped miss series lack `reason` while new ones carry it. For existing total-miss queries and miss/request ratios, aggregate away `reason` and scrape labels on both sides—for example, `sum by (cache_namespace, use_case, key_type, layer) (rate(dialcache_miss_counter[5m])) / sum by (cache_namespace, use_case, key_type, layer) (rate(dialcache_request_counter[5m]))`. Reason-aware dashboards should group explicitly by `reason`. + The future-timestamp histogram uses dedicated buckets from millisecond-scale skew through multi-hour clock faults. It records one positive offset after a valid frame is decoded. Caller-serving and initial shadow reads then reject that frame; confirmation reads retain it only for payload-equality classification. Invalid or non-finite timestamp values miss without entering histogram sums. The same future frame can be observed repeatedly. Alert against the deployment's allocated skew budget, not every millisecond-level sample. `policy_disabled` means that a process-local or Redis layer has no effective TTL after runtime overlays are applied. It is an intentional policy outcome, including the default when `defaultConfig` is omitted, rather than a configuration-loading failure. @@ -923,7 +933,7 @@ The Datadog adapter emits exact increments of `1` for counters and preserves sec | Metric | Type | Tags | Description | | --- | --- | --- | --- | | `dialcache.request.count` | Count | `cache_namespace`, `use_case`, `key_type`, `layer` | Cache-layer requests that reached an enabled layer | -| `dialcache.miss.count` | Count | `cache_namespace`, `use_case`, `key_type`, `layer` | Cache misses | +| `dialcache.miss.count` | Count | `cache_namespace`, `use_case`, `key_type`, `layer`, `reason` | Cache misses, classified by one required bounded reason | | `dialcache.disabled.count` | Count | `cache_namespace`, `use_case`, `key_type`, `layer`, `reason` | Cache skips by bounded reason | | `dialcache.error.count` | Count | `cache_namespace`, `use_case`, `key_type`, `layer`, `error`, `in_fallback` | Cache/fallback errors and the bounded `tracked_ttl_clamped` configuration signal | | `dialcache.invalidation.count` | Count | `cache_namespace`, `key_type`, `layer` | Invalidation calls for the layers touched | @@ -942,6 +952,8 @@ The Datadog adapter emits exact increments of `1` for counters and preserves sec | `dialcache.compression.ratio` | Distribution or histogram | `cache_namespace`, `use_case`, `key_type`, `layer` | Compressed-to-original payload size ratio for compressed writes | | `dialcache.compression.duration` | Distribution or histogram | `cache_namespace`, `use_case`, `key_type`, `layer`, `operation` | Payload compression and decompression latency in seconds | +`dialcache.miss.count` remains one metric and adds the same required bounded `reason` tag: `value_absent`, `watermark_fenced`, or `unclassified`. During a mixed-fleet rollout, older points have no `reason` tag and newer points do; keep total-miss dashboards ungrouped by `reason`, and group by it only for the reason breakdown. The three bounded values increase steady-state custom-metric combinations by at most 3x per pre-existing miss-tag tuple, so account for that added cardinality and the temporary mixed tag sets in Datadog billing and monitor design. + Observer throws and rejections from returned promises or thenables are isolated by DialCache's fail-open metrics boundary. Buffered transport failures that are not represented by a returned thenable happen outside that boundary, so configure the DogStatsD client's error handling and shutdown behavior as part of application ownership. ### Error categories @@ -966,7 +978,7 @@ These values are defined by the backend-neutral core and are identical for every ### Custom adapters -For other telemetry backends, implement `DialCacheMetricsAdapter` and pass the adapter through `new DialCache({ metrics })`. Every backend-neutral label object exposes the logical namespace as camel-case `cacheNamespace`; adapters should map it to their backend's `cache_namespace` label/tag. This field is present even when no key or cache layer was reached. Implement the optional `shadowValidation` method to enable shadow work as well as record its outcomes; omitting it leaves all shadow work disabled even when `shadow.ramp` is nonzero or mismatch logging is enabled. The optional `observeShadowValueAge` method records the validated value's age in seconds for `match` and `mismatch` outcomes; omitting it skips only that observation without affecting shadow eligibility. The optional `observeFutureTimestampOffset` method receives existing bounded cache labels plus the exact positive offset in seconds; omitting it does not change read decisions: serving and initial-shadow reads still miss, while confirmation reads still retain the frame for payload comparison. The optional `staleRecovery` method records one bounded terminal outcome for each classifier-authorized recovery check; omitting it disables only that observation, not recovery itself. The optional `observeStaleRecoveryValueAge` method records the actual return-time age in seconds only when recovery serves; omitting it skips only that observation. Every metrics callback is fire-and-forget: DialCache isolates synchronous throws and consumes rejections from returned promises or thenables, but never awaits or drains observer work. Omit `metrics` to disable metrics. +For other telemetry backends, implement `DialCacheMetricsAdapter` and pass the adapter through `new DialCache({ metrics })`. Every backend-neutral label object exposes the logical namespace as camel-case `cacheNamespace`; adapters should map it to their backend's `cache_namespace` label/tag. This field is present even when no key or cache layer was reached. `miss()` now receives `MissMetricLabels`, which extends unchanged `CacheMetricLabels` with the required bounded `reason`; every other callback keeps its existing label shape. Custom adapters whose `miss` parameter is typed as the broader `CacheMetricLabels` can ignore the new property, but direct callers, exact label snapshots, exhaustive reason handling, and adapters that reject or forward unknown fields must migrate to accept or map it. Implement the optional `shadowValidation` method to enable shadow work as well as record its outcomes; omitting it leaves all shadow work disabled even when `shadow.ramp` is nonzero or mismatch logging is enabled. The optional `observeShadowValueAge` method records the validated value's age in seconds for `match` and `mismatch` outcomes; omitting it skips only that observation without affecting shadow eligibility. The optional `observeFutureTimestampOffset` method receives existing bounded cache labels plus the exact positive offset in seconds; omitting it does not change read decisions: serving and initial-shadow reads still miss, while confirmation reads still retain the frame for payload comparison. The optional `staleRecovery` method records one bounded terminal outcome for each classifier-authorized recovery check; omitting it disables only that observation, not recovery itself. The optional `observeStaleRecoveryValueAge` method records the actual return-time age in seconds only when recovery serves; omitting it skips only that observation. Every metrics callback is fire-and-forget: DialCache isolates synchronous throws and consumes rejections from returned promises or thenables, but never awaits or drains observer work. This metric schema/API migration does not change cache serving, refill, invalidation, stale recovery, or shadow-verdict behavior. Omit `metrics` to disable metrics. ## Maintainers diff --git a/scripts/test-package.mjs b/scripts/test-package.mjs index 3397469..2f06b51 100644 --- a/scripts/test-package.mjs +++ b/scripts/test-package.mjs @@ -126,6 +126,7 @@ const rootConsumer = `import { FallbackTimeoutError, JsonSerializer, RedisReadTimeoutError, + type CacheMissReason, type CacheMetricLabels, type CacheConfigProvider, type CachedOptions, @@ -145,10 +146,12 @@ const rootConsumer = `import { type InvalidationMetricLabels, type MetricErrorKind, type MetricLayer, + type MissMetricLabels, type ProcessCoalescingState, type RedisConfig, type RedisInvalidationRequest, type RedisReadContext, + type RedisReadMiss, type RedisReadResult, type RedisWatermarkMiss, type RedisWriteRequest, @@ -173,6 +176,7 @@ import type { DialCacheNodeRedisScripts } from "dialcache/node-redis"; import { ceilSupportedCacheTtlMs, decodeRedisFrame, + decodeRedisReadResult, decodeTrackedRedisFrame, decodeTrackedRedisReadResult, encodeRedisFrame, @@ -220,7 +224,10 @@ const inlineOptionsFor = (useCase: string, key = "1") => ({ }); const metrics: DialCacheMetricsAdapter = { request: () => undefined, - miss: () => undefined, + miss: (labels: MissMetricLabels) => { + const reason: CacheMissReason = labels.reason; + void reason; + }, disabled: () => undefined, error: () => undefined, invalidation: () => undefined, @@ -339,6 +346,16 @@ const emptyRedisFrame = Buffer.alloc(10); emptyRedisFrame[0] = 1; emptyRedisFrame.writeBigUInt64BE(1n, 1); const decodedEmptyRedisFrame: DecodedRedisFrame | null = decodeRedisFrame(emptyRedisFrame); +const decodedRedisReadResult: RedisReadResult = decodeRedisReadResult(null); +if ( + decodedRedisReadResult !== null + && "reason" in decodedRedisReadResult + && !("kind" in decodedRedisReadResult) +) { + const typedRedisReadMiss: RedisReadMiss = decodedRedisReadResult; + const reason: CacheMissReason = typedRedisReadMiss.reason; + void reason; +} const decodedStaleRedisFrame: DecodedRedisFrame | null = decodeTrackedRedisFrame( emptyRedisFrame, Buffer.from("1"), @@ -503,6 +520,24 @@ const cacheMetricLabels: CacheMetricLabels = { keyType: "id", layer: CacheLayer.LOCAL, }; +const missMetricLabels: MissMetricLabels = { + ...cacheMetricLabels, + reason: "value_absent", +}; +const missReasons: Readonly> = { + value_absent: true, + watermark_fenced: true, + unclassified: true, +}; +// @ts-expect-error Miss reasons are a bounded public taxonomy. +const unboundedMissReason: CacheMissReason = "expired"; +// @ts-expect-error The reason is required only for the miss callback's labels. +const missingMissReason: MissMetricLabels = cacheMetricLabels; +const cacheMetricLabelsWithMissReason: CacheMetricLabels = { + ...cacheMetricLabels, + // @ts-expect-error CacheMetricLabels intentionally remains shared by non-miss callbacks. + reason: "value_absent", +}; const invalidationMetricLabels: InvalidationMetricLabels = { cacheNamespace: "consumer-cache", keyType: "id", @@ -704,6 +739,11 @@ void staleKeyConfig; void staleRecoveryMaxAgeSec; void requestLocalCoalescingLabels; void cacheMetricLabels; +void missMetricLabels; +void missReasons; +void unboundedMissReason; +void missingMissReason; +void cacheMetricLabelsWithMissReason; void invalidationMetricLabels; void keyInitHasNoUrnPrefix; void legacyKeyInit; @@ -1004,25 +1044,51 @@ const esmRoundTrip = redisProtocol.decodeRedisFrame(redisProtocol.encodeRedisFra if (esmRoundTrip?.payload !== "value" || esmRoundTrip.createdAtMs !== 1) { throw new Error("The packed ESM Redis protocol encoder did not round-trip through the decoder"); } +const esmAbsentRead = redisProtocol.decodeRedisReadResult(null); +if ( + esmAbsentRead?.reason !== "value_absent" + || "kind" in esmAbsentRead + || "payload" in esmAbsentRead +) { + throw new Error("The packed ESM Redis result decoder did not classify an absent value"); +} if (redisProtocol.decodeTrackedRedisFrame(redisProtocol.encodeRedisFrame("pending", 0), Buffer.from("0")) !== null) { throw new Error("The packed ESM tracked decoder did not fence an equal timestamp"); } const esmWatermarkMiss = redisProtocol.decodeTrackedRedisReadResult( - redisProtocol.encodeRedisFrame("pending", 0), - Buffer.from("0"), + redisProtocol.encodeRedisFrame("pending", 1), + Buffer.from("1"), ); if ( esmWatermarkMiss?.kind !== "watermark_miss" - || esmWatermarkMiss.observedWatermarkMs !== 0 + || esmWatermarkMiss.reason !== "watermark_fenced" + || esmWatermarkMiss.observedWatermarkMs !== 1 || "payload" in esmWatermarkMiss + || "createdAtMs" in esmWatermarkMiss ) { - throw new Error("The packed ESM tracked result decoder did not preserve the observed watermark miss"); + throw new Error("The packed ESM tracked result decoder did not classify a watermark-fenced miss"); } if (redisProtocol.decodeTrackedRedisFrame(redisProtocol.encodeRedisFrame("value", 1), null)?.payload !== "value") { throw new Error("The packed ESM tracked decoder did not use zero for a missing watermark"); } -if (redisProtocol.decodeTrackedRedisReadResult(null, null) !== null) { - throw new Error("The packed ESM tracked result decoder did not preserve a generic miss without a watermark"); +const esmAbsentTrackedRead = redisProtocol.decodeTrackedRedisReadResult(null, null); +if ( + esmAbsentTrackedRead?.reason !== "value_absent" + || "observedWatermarkMs" in esmAbsentTrackedRead + || "kind" in esmAbsentTrackedRead + || "payload" in esmAbsentTrackedRead +) { + throw new Error("The packed ESM tracked result decoder did not classify an absent value"); +} +const esmAbsentTrackedReadWithWatermark = redisProtocol.decodeTrackedRedisReadResult(null, Buffer.from("7")); +if ( + esmAbsentTrackedReadWithWatermark?.kind !== "watermark_miss" + || esmAbsentTrackedReadWithWatermark.reason !== "value_absent" + || esmAbsentTrackedReadWithWatermark.observedWatermarkMs !== 7 + || "payload" in esmAbsentTrackedReadWithWatermark + || "createdAtMs" in esmAbsentTrackedReadWithWatermark +) { + throw new Error("The packed ESM tracked result decoder did not preserve an absent-value refill fence"); } if ( "REDIS_FRAME_VERSION" in redisProtocol @@ -1393,25 +1459,51 @@ const cjsRoundTrip = redisProtocol.decodeRedisFrame(redisProtocol.encodeRedisFra if (cjsRoundTrip?.payload !== "value" || cjsRoundTrip.createdAtMs !== 1) { throw new Error("The packed CommonJS Redis protocol encoder did not round-trip through the decoder"); } +const cjsAbsentRead = redisProtocol.decodeRedisReadResult(null); +if ( + cjsAbsentRead?.reason !== "value_absent" + || "kind" in cjsAbsentRead + || "payload" in cjsAbsentRead +) { + throw new Error("The packed CommonJS Redis result decoder did not classify an absent value"); +} if (redisProtocol.decodeTrackedRedisFrame(redisProtocol.encodeRedisFrame("pending", 0), Buffer.from("0")) !== null) { throw new Error("The packed CommonJS tracked decoder did not fence an equal timestamp"); } const cjsWatermarkMiss = redisProtocol.decodeTrackedRedisReadResult( - redisProtocol.encodeRedisFrame("pending", 0), - Buffer.from("0"), + redisProtocol.encodeRedisFrame("pending", 1), + Buffer.from("1"), ); if ( cjsWatermarkMiss?.kind !== "watermark_miss" - || cjsWatermarkMiss.observedWatermarkMs !== 0 + || cjsWatermarkMiss.reason !== "watermark_fenced" + || cjsWatermarkMiss.observedWatermarkMs !== 1 || "payload" in cjsWatermarkMiss + || "createdAtMs" in cjsWatermarkMiss ) { - throw new Error("The packed CommonJS tracked result decoder did not preserve the observed watermark miss"); + throw new Error("The packed CommonJS tracked result decoder did not classify a watermark-fenced miss"); } if (redisProtocol.decodeTrackedRedisFrame(redisProtocol.encodeRedisFrame("value", 1), null)?.payload !== "value") { throw new Error("The packed CommonJS tracked decoder did not use zero for a missing watermark"); } -if (redisProtocol.decodeTrackedRedisReadResult(null, null) !== null) { - throw new Error("The packed CommonJS tracked result decoder did not preserve a generic miss without a watermark"); +const cjsAbsentTrackedRead = redisProtocol.decodeTrackedRedisReadResult(null, null); +if ( + cjsAbsentTrackedRead?.reason !== "value_absent" + || "observedWatermarkMs" in cjsAbsentTrackedRead + || "kind" in cjsAbsentTrackedRead + || "payload" in cjsAbsentTrackedRead +) { + throw new Error("The packed CommonJS tracked result decoder did not classify an absent value"); +} +const cjsAbsentTrackedReadWithWatermark = redisProtocol.decodeTrackedRedisReadResult(null, Buffer.from("7")); +if ( + cjsAbsentTrackedReadWithWatermark?.kind !== "watermark_miss" + || cjsAbsentTrackedReadWithWatermark.reason !== "value_absent" + || cjsAbsentTrackedReadWithWatermark.observedWatermarkMs !== 7 + || "payload" in cjsAbsentTrackedReadWithWatermark + || "createdAtMs" in cjsAbsentTrackedReadWithWatermark +) { + throw new Error("The packed CommonJS tracked result decoder did not preserve an absent-value refill fence"); } if ( "REDIS_FRAME_VERSION" in redisProtocol diff --git a/src/datadog.ts b/src/datadog.ts index 471a780..ea0f288 100644 --- a/src/datadog.ts +++ b/src/datadog.ts @@ -7,6 +7,7 @@ import type { DisabledMetricLabels, ErrorMetricLabels, InvalidationMetricLabels, + MissMetricLabels, SerializationMetricLabels, ShadowValidationMetricLabels, StaleRecoveryMetricLabels, @@ -92,8 +93,8 @@ export class DatadogDialCacheMetrics implements DialCacheMetricsAdapter { this.increment(this.metricNames.request, cacheTags(labels)); } - miss(labels: CacheMetricLabels): void { - this.increment(this.metricNames.miss, cacheTags(labels)); + miss(labels: MissMetricLabels): void { + this.increment(this.metricNames.miss, { ...cacheTags(labels), reason: labels.reason }); } disabled(labels: DisabledMetricLabels): void { diff --git a/src/dialcache.ts b/src/dialcache.ts index d0cec68..1ac3c96 100644 --- a/src/dialcache.ts +++ b/src/dialcache.ts @@ -25,6 +25,7 @@ import { type ShadowValidationOutcome, } from "./metrics.js"; import { + isRedisReadMiss, isRedisWatermarkMiss, type DecodedRedisFrame, type RedisCachePayload, @@ -607,7 +608,7 @@ export class DialCache { return result.value; } - this.metrics?.miss(labelsFor(key, REQUEST_LOCAL_CACHE_LAYER)); + this.metrics?.miss({ ...labelsFor(key, REQUEST_LOCAL_CACHE_LAYER), reason: "value_absent" }); const value = await this.getThroughSharedLayers( key, keyConfig, @@ -876,7 +877,9 @@ export class DialCache { key, value, remoteWriteConfig, - remote.status === "miss" ? remote.watermarkMiss : undefined, + remote.status === "miss" || remote.status === "retained" + ? remote.watermarkMiss + : undefined, ); } catch (error) { this.logger.warn("Error putting value in Redis cache", error); @@ -1111,11 +1114,9 @@ export class DialCache { if (abandonIfExpired()) { return "timeout"; } - if (isRedisWatermarkMiss(readResult)) { - shadowFillConfig = start.remoteConfig; - shadowFillWatermarkMiss = readResult; - } else if (readResult === null) { + if (isRedisReadMiss(readResult) || readResult === null) { shadowFillConfig = start.remoteConfig; + shadowFillWatermarkMiss = isRedisWatermarkMiss(readResult) ? readResult : undefined; } else { flight.cachedFrame = readResult; } @@ -1219,7 +1220,7 @@ export class DialCache { if (originalFrame === null) { return "timeout"; } - const confirmationFrame = isRedisWatermarkMiss(confirmationResult) + const confirmationFrame = isRedisReadMiss(confirmationResult) ? null : confirmationResult; if (confirmationFrame === null || !redisPayloadsEqual(originalFrame.payload, confirmationFrame.payload)) { @@ -1316,7 +1317,7 @@ export class DialCache { this.metrics?.request(labelsFor(key, CacheLayer.LOCAL)); this.metrics?.observeGet(labelsFor(key, CacheLayer.LOCAL), elapsedSeconds(start)); if (result.status === "miss") { - this.metrics?.miss(labelsFor(key, CacheLayer.LOCAL)); + this.metrics?.miss({ ...labelsFor(key, CacheLayer.LOCAL), reason: "value_absent" }); } return result; } catch (error) { diff --git a/src/index.ts b/src/index.ts index 9203d24..f717ff6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ export type { export { DialCacheContext } from "./context.js"; export type { CacheMetricLabels, + CacheMissReason, CoalescedMetricLabels, CoalescingScope, CompressionMetricLabels, @@ -22,6 +23,7 @@ export type { InvalidationMetricLabels, MetricErrorKind, MetricLayer, + MissMetricLabels, SerializationMetricLabels, ShadowValidationMetricLabels, ShadowValidationOutcome, @@ -61,6 +63,7 @@ export type { RedisCachePayload, RedisInvalidationRequest, RedisReadContext, + RedisReadMiss, RedisReadRequest, RedisReadResult, RedisWatermarkMiss, diff --git a/src/internal/cache-result.ts b/src/internal/cache-result.ts index 9b987f5..0bff158 100644 --- a/src/internal/cache-result.ts +++ b/src/internal/cache-result.ts @@ -20,6 +20,7 @@ export type RedisCacheGetResult = readonly status: "retained"; readonly frame: DecodedRedisFrame; readonly config: ResolvedRemoteLayerConfig; + readonly watermarkMiss?: RedisWatermarkMiss; } | { readonly status: "miss"; diff --git a/src/internal/redis-cache.ts b/src/internal/redis-cache.ts index db0b10c..4696fe0 100644 --- a/src/internal/redis-cache.ts +++ b/src/internal/redis-cache.ts @@ -6,16 +6,19 @@ import { invalidationPrefix, redisClusterHashTag, type DialCacheKey } from "../k import { labelsFor, REMOTE_SHADOW_CACHE_LAYER, + type CacheMissReason, type DialCacheMetricsAdapter, type MetricErrorKind, type MetricLayer, type StaleRecoveryOutcome, } from "../metrics.js"; import { + isRedisReadMiss, isRedisWatermarkMiss, type DecodedRedisFrame, type DialCacheRedisClient, type RedisCachePayload, + type RedisReadMiss, type RedisReadResult, type RedisWatermarkMiss, } from "../redis-client.js"; @@ -144,17 +147,14 @@ export class RedisCache { ); throw error; } - if (isRedisWatermarkMiss(result)) { - this.recordMetric((metrics) => metrics.miss(labelsFor(key, metricLayer))); - return { - status: "miss", - config: layerConfig, - reason: "cache_miss", - watermarkMiss: result, - }; + if (isRedisReadMiss(result)) { + this.recordMiss(key, metricLayer, missReason(result)); + return isRedisWatermarkMiss(result) + ? { status: "miss", config: layerConfig, reason: "cache_miss", watermarkMiss: result } + : { status: "miss", config: layerConfig, reason: "cache_miss" }; } if (result === null) { - this.recordMetric((metrics) => metrics.miss(labelsFor(key, metricLayer))); + this.recordMiss(key, metricLayer, "unclassified"); return { status: "miss", config: layerConfig, reason: "cache_miss" }; } @@ -163,20 +163,29 @@ export class RedisCache { // remains the ordinary serving boundary. const frameAge = this.frameAge(key, result, metricLayer); if (frameAge.status !== "valid" || frameAge.ageMs >= maximumAgeMs) { - this.recordMetric((metrics) => metrics.miss(labelsFor(key, metricLayer))); - return { status: "miss", config: layerConfig, reason: "cache_miss" }; + const miss = unclassifiedFrameMiss(key, result); + this.recordMiss(key, metricLayer, missReason(miss)); + return isRedisWatermarkMiss(miss) + ? { status: "miss", config: layerConfig, reason: "cache_miss", watermarkMiss: miss } + : { status: "miss", config: layerConfig, reason: "cache_miss" }; } if (frameAge.ageMs >= freshAgeMs) { - this.recordMetric((metrics) => metrics.miss(labelsFor(key, metricLayer))); - return { status: "retained", config: layerConfig, frame: result }; + const miss = unclassifiedFrameMiss(key, result); + this.recordMiss(key, metricLayer, missReason(miss)); + return isRedisWatermarkMiss(miss) + ? { status: "retained", config: layerConfig, frame: result, watermarkMiss: miss } + : { status: "retained", config: layerConfig, frame: result }; } try { const value = await this.deserializePayload(key, result.payload, metricLayer); return { status: "hit", value, frame: result }; } catch { - this.recordMetric((metrics) => metrics.miss(labelsFor(key, metricLayer))); - return { status: "miss", config: layerConfig, reason: "deserialization_error" }; + const miss = unclassifiedFrameMiss(key, result); + this.recordMiss(key, metricLayer, missReason(miss)); + return isRedisWatermarkMiss(miss) + ? { status: "miss", config: layerConfig, reason: "deserialization_error", watermarkMiss: miss } + : { status: "miss", config: layerConfig, reason: "deserialization_error" }; } } finally { // Preserve the caller-serving boundary: Redis read plus any ordinary @@ -461,8 +470,10 @@ export class RedisCache { ); const result = read.result.then( (result) => { - if (result === null || isRedisWatermarkMiss(result)) { - this.recordMetric((metrics) => metrics.miss(labelsFor(key, metricLayer))); + if (isRedisReadMiss(result)) { + this.recordMiss(key, metricLayer, missReason(result)); + } else if (result === null) { + this.recordMiss(key, metricLayer, "unclassified"); } return result; }, @@ -487,27 +498,38 @@ export class RedisCache { metricLayer: MetricLayer, futureFramePolicy: FutureFramePolicy, ): RedisReadResult { - if (result === null || isRedisWatermarkMiss(result)) { + if (result === null || isRedisReadMiss(result)) { return result; } const age = this.frameAge(key, result, metricLayer); if (age.status === "future") { - return futureFramePolicy === "reject" ? null : result; + return futureFramePolicy === "retain" + ? result + : unclassifiedFrameMiss(key, result); } if (age.status === "invalid") { - return null; + return unclassifiedFrameMiss(key, result); } - return maxAgeMs === null || age.ageMs < maxAgeMs ? result : null; + return maxAgeMs === null || age.ageMs < maxAgeMs + ? result + : unclassifiedFrameMiss(key, result); } private validateReadResult(key: DialCacheKey, result: RedisReadResult): RedisReadResult { try { - if (!isRedisWatermarkMiss(result)) { + if (!isRedisReadMiss(result)) { return result; } - return key.trackForInvalidation && isValidRedisWatermarkMiss(result) - ? result - : null; + const observedWatermarkMs = key.trackForInvalidation && isRedisWatermarkMiss(result) + ? validObservedWatermarkMs(result.observedWatermarkMs) + : undefined; + const reason = missReason(result); + return classifiedRedisReadMiss( + reason === "watermark_fenced" && observedWatermarkMs === undefined + ? "unclassified" + : reason, + observedWatermarkMs, + ); } catch { return null; } @@ -594,6 +616,10 @@ export class RedisCache { return createdAtMs; } + private recordMiss(key: DialCacheKey, layer: MetricLayer, reason: CacheMissReason): void { + this.recordMetric((metrics) => metrics.miss({ ...labelsFor(key, layer), reason })); + } + private recordStaleRecovery( key: DialCacheKey, outcome: StaleRecoveryOutcome, @@ -627,7 +653,36 @@ function elapsedSeconds(startMs: number): number { return Math.max((performance.now() - startMs) / 1000, 0); } -function isValidRedisWatermarkMiss(miss: RedisWatermarkMiss): boolean { - return Number.isSafeInteger(miss.observedWatermarkMs) - && miss.observedWatermarkMs >= 0; +function missReason(result: RedisReadMiss | RedisWatermarkMiss): CacheMissReason { + return isCacheMissReason(result.reason) ? result.reason : "unclassified"; +} + +function classifiedRedisReadMiss( + reason: CacheMissReason, + observedWatermarkMs: unknown, +): RedisReadMiss | RedisWatermarkMiss { + const observed = validObservedWatermarkMs(observedWatermarkMs); + return observed === undefined + ? { reason } + : { kind: "watermark_miss", reason, observedWatermarkMs: observed }; +} + +function unclassifiedFrameMiss( + key: DialCacheKey, + frame: DecodedRedisFrame, +): RedisReadMiss | RedisWatermarkMiss { + return classifiedRedisReadMiss( + "unclassified", + key.trackForInvalidation ? frame.observedWatermarkMs : undefined, + ); +} + +function validObservedWatermarkMs(value: unknown): number | undefined { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 + ? value + : undefined; +} + +function isCacheMissReason(value: unknown): value is CacheMissReason { + return value === "value_absent" || value === "watermark_fenced" || value === "unclassified"; } diff --git a/src/internal/redis-payload.ts b/src/internal/redis-payload.ts index 9123553..4f240e1 100644 --- a/src/internal/redis-payload.ts +++ b/src/internal/redis-payload.ts @@ -1,9 +1,10 @@ import { DialCacheRedisPayloadEncodingError, DialCacheRedisPayloadError, - isRedisWatermarkMiss, + isRedisReadMiss, type DecodedRedisFrame, type RedisCachePayload, + type RedisReadMiss, type RedisReadResult, type RedisWatermarkMiss, } from "../redis-client.js"; @@ -90,30 +91,37 @@ function isValidRedisTimestampMs(timestampMs: number): boolean { } /** - * Decode an untracked DialCache frame returned as a Redis bulk string into - * its serializer payload and header creation time (the writer's application - * clock). Missing, short, and unsupported-version frames are cache - * misses. Invalid runtime reply types and unsupported payload encodings throw - * typed errors. + * Decode an untracked DialCache read with a bounded miss reason. Invalid + * runtime reply types and unsupported payload encodings still throw typed + * errors rather than becoming misses. */ -export function decodeRedisFrame(raw: unknown): DecodedRedisFrame | null { +export function decodeRedisReadResult(raw: unknown): RedisReadResult { const frame = validateRedisBulkStringReply(raw); + if (frame === null) { + return redisReadMiss("value_absent"); + } if (!isSupportedRedisFrame(frame)) { - return null; + return redisReadMiss("unclassified"); } - return { - payload: decodeRedisPayload(frame.subarray(REDIS_FRAME_HEADER_BYTES)), - createdAtMs: readFrameCreatedAtMs(frame), - }; + return decodedRedisFrame(frame); +} + +/** + * Backward-compatible untracked-frame decoder. It collapses classified misses + * to the established `DecodedRedisFrame | null` surface. + */ +export function decodeRedisFrame(raw: unknown): DecodedRedisFrame | null { + const result = decodeRedisReadResult(raw); + return isRedisReadMiss(result) ? null : result; } /** * Decode a tracked DialCache read while preserving a trustworthy observed - * watermark for semantic misses. A present valid numeric watermark produces a - * `RedisWatermarkMiss` whenever the value is absent, unsupported, or fenced; - * missing or malformed watermark metadata retains the generic `null` miss. - * Invalid runtime reply types and unsupported payload encodings on otherwise - * eligible frames throw typed errors. + * watermark for semantic misses. Miss cause and a valid observed watermark are + * independent: an absent value can retain a refill fence, while only a + * supported, complete frame actually rejected by a valid watermark is + * `watermark_fenced`. Invalid runtime reply types and unsupported payload + * encodings on otherwise eligible frames throw typed errors. * * Custom adapters opting into this result must also honor a supplied * `RedisWriteRequest.createdAtMs` exactly. @@ -124,17 +132,30 @@ export function decodeTrackedRedisReadResult( ): RedisReadResult { const frame = validateRedisBulkStringReply(raw); const watermarkFrame = validateRedisBulkStringReply(rawWatermark); + + // Redis nil is decisive evidence of absence regardless of paired metadata. + // Preserve a valid paired watermark separately so a later refill can still + // be skipped before serialization if its client timestamp cannot clear it. + if (frame === null) { + return redisReadMiss( + "value_absent", + watermarkFrame === null ? undefined : parseRedisWatermark(watermarkFrame) ?? undefined, + ); + } + if (!isSupportedRedisFrame(frame)) { + return redisReadMiss( + "unclassified", + watermarkFrame === null ? undefined : parseRedisWatermark(watermarkFrame) ?? undefined, + ); + } if (watermarkFrame === null) { - return decodeTrackedFrame(frame, 0, null); + return decodeTrackedFrame(frame); } const watermark = parseRedisWatermark(watermarkFrame); if (watermark === null) { - return null; + return redisReadMiss("unclassified"); } - return decodeTrackedFrame(frame, watermark, { - kind: "watermark_miss", - observedWatermarkMs: watermark, - }); + return decodeTrackedFrame(frame, watermark); } /** @@ -148,24 +169,46 @@ export function decodeTrackedRedisFrame( rawWatermark: unknown, ): DecodedRedisFrame | null { const result = decodeTrackedRedisReadResult(raw, rawWatermark); - return isRedisWatermarkMiss(result) ? null : result; + if (result === null || isRedisReadMiss(result)) { + return null; + } + // Do not widen the established compatibility helper's served-frame shape. + return { payload: result.payload, createdAtMs: result.createdAtMs }; } function decodeTrackedFrame( - frame: Buffer | null, - watermark: number, - miss: RedisWatermarkMiss | null, + frame: Buffer, + observedWatermarkMs?: number, ): RedisReadResult { - if (!isSupportedRedisFrame(frame)) { - return miss; - } const createdAtMs = readFrameCreatedAtMs(frame); - return createdAtMs <= watermark - ? miss - : { - payload: decodeRedisPayload(frame.subarray(REDIS_FRAME_HEADER_BYTES)), - createdAtMs, - }; + // Tracked protocol frames require a positive safe-integer timestamp. Keep + // malformed/future decisions out of the watermark-fenced category. + if (!isValidRedisTimestampMs(createdAtMs) || createdAtMs === 0) { + return redisReadMiss("unclassified", observedWatermarkMs); + } + if (observedWatermarkMs !== undefined && createdAtMs <= observedWatermarkMs) { + return redisReadMiss("watermark_fenced", observedWatermarkMs); + } + return decodedRedisFrame(frame, observedWatermarkMs); +} + +function decodedRedisFrame(frame: Buffer, observedWatermarkMs?: number): DecodedRedisFrame { + const decoded = { + payload: decodeRedisPayload(frame.subarray(REDIS_FRAME_HEADER_BYTES)), + createdAtMs: readFrameCreatedAtMs(frame), + }; + return observedWatermarkMs === undefined + ? decoded + : { ...decoded, observedWatermarkMs }; +} + +function redisReadMiss( + reason: RedisReadMiss["reason"], + observedWatermarkMs?: number, +): RedisReadMiss | RedisWatermarkMiss { + return observedWatermarkMs === undefined + ? { reason } + : { kind: "watermark_miss", reason, observedWatermarkMs }; } function readFrameCreatedAtMs(frame: Buffer): number { diff --git a/src/metrics.ts b/src/metrics.ts index 5ba9af9..1126d40 100644 --- a/src/metrics.ts +++ b/src/metrics.ts @@ -71,6 +71,13 @@ export interface CacheMetricLabels { readonly layer: MetricLayer; } +/** Bounded causes for cache misses. */ +export type CacheMissReason = "value_absent" | "watermark_fenced" | "unclassified"; + +export interface MissMetricLabels extends CacheMetricLabels { + readonly reason: CacheMissReason; +} + export interface DisabledMetricLabels extends CacheMetricLabels { readonly reason: DisabledReason; } @@ -121,7 +128,7 @@ export interface StaleRecoveryMetricLabels { export interface DialCacheMetricsAdapter { request(labels: CacheMetricLabels): void; - miss(labels: CacheMetricLabels): void; + miss(labels: MissMetricLabels): void; disabled(labels: DisabledMetricLabels): void; error(labels: ErrorMetricLabels): void; invalidation(labels: InvalidationMetricLabels): void; diff --git a/src/node-redis.ts b/src/node-redis.ts index b88becc..71672e8 100644 --- a/src/node-redis.ts +++ b/src/node-redis.ts @@ -7,7 +7,7 @@ import { } from "./internal/redis-invalidation.js"; import { assertValidRedisTimestampMs, - decodeRedisFrame, + decodeRedisReadResult, decodeTrackedRedisReadResult, encodeRedisFrame, } from "./internal/redis-payload.js"; @@ -130,7 +130,7 @@ export function createNodeRedisDialCacheClient(client: NodeRedisClient): DialCac ? bufferReplyOptions : commandOptions({ returnBuffers: true, signal: context.signal }); if (watermarkKey === undefined) { - return decodeRedisFrame(await client.get(options, valueKey)); + return decodeRedisReadResult(await client.get(options, valueKey)); } const [rawValue, rawWatermark] = await readTracked( client, diff --git a/src/prometheus.ts b/src/prometheus.ts index 2f0a658..25e4046 100644 --- a/src/prometheus.ts +++ b/src/prometheus.ts @@ -9,6 +9,7 @@ import type { DisabledMetricLabels, ErrorMetricLabels, InvalidationMetricLabels, + MissMetricLabels, SerializationMetricLabels, ShadowValidationMetricLabels, StaleRecoveryMetricLabels, @@ -22,6 +23,7 @@ export interface PrometheusMetricsOptions { type PrometheusRegistry = Registry | Registry; type CounterLabels = "cache_namespace" | "use_case" | "key_type" | "layer"; +type MissLabels = CounterLabels | "reason"; type DisabledLabels = CounterLabels | "reason"; type ErrorLabels = CounterLabels | "error" | "in_fallback"; type SerializationLabels = CounterLabels | "operation"; @@ -84,7 +86,7 @@ const FUTURE_TIMESTAMP_OFFSET_BUCKETS = [ export class PrometheusDialCacheMetrics implements DialCacheMetricsAdapter { private readonly requestCounter: Counter; - private readonly missCounter: Counter; + private readonly missCounter: Counter; private readonly disabledCounter: Counter; private readonly errorCounter: Counter; private readonly invalidationCounter: Counter; @@ -134,8 +136,8 @@ export class PrometheusDialCacheMetrics implements DialCacheMetricsAdapter { this.requestCounter.inc(cacheLabels(labels)); } - miss(labels: CacheMetricLabels): void { - this.missCounter.inc(cacheLabels(labels)); + miss(labels: MissMetricLabels): void { + this.missCounter.inc({ ...cacheLabels(labels), reason: labels.reason }); } disabled(labels: DisabledMetricLabels): void { @@ -257,7 +259,7 @@ function collectorConfigs(prefix: string) { type: "counter", name: `${prefix}dialcache_miss_counter`, help: "DialCache cache misses.", - labelNames: ["cache_namespace", "use_case", "key_type", "layer"], + labelNames: ["cache_namespace", "use_case", "key_type", "layer", "reason"], }, requestCounter: { type: "counter", diff --git a/src/redis-client.ts b/src/redis-client.ts index da65290..216cfe9 100644 --- a/src/redis-client.ts +++ b/src/redis-client.ts @@ -1,4 +1,5 @@ import type { Awaitable } from "./config.js"; +import type { CacheMissReason } from "./metrics.js"; const redisPayloadErrorBrand = Symbol.for("dialcache.DialCacheRedisPayloadError"); const redisPayloadEncodingErrorBrand = Symbol.for("dialcache.DialCacheRedisPayloadEncodingError"); @@ -78,28 +79,58 @@ export interface DecodedRedisFrame { readonly payload: RedisCachePayload; /** Epoch milliseconds copied from the frame header. */ readonly createdAtMs: number; + /** + * A trustworthy watermark observed alongside this eligible tracked frame. + * It is retained so a later core-side rejection can carry the independent + * refill fence forward without relabeling that rejection as a watermark + * fence. Untracked and legacy decoder results omit it. + */ + readonly observedWatermarkMs?: number; +} + +/** A classified semantic Redis miss without a trustworthy refill fence. */ +export interface RedisReadMiss { + readonly reason: CacheMissReason; + readonly payload?: never; + readonly createdAtMs?: never; } /** * A semantic tracked-read miss carrying a trustworthy write fence from the * same authoritative value-and-watermark snapshot. A refill stamped at or - * before `observedWatermarkMs` is known to remain unreadable. + * before `observedWatermarkMs` is known to remain unreadable. `reason` is + * optional only for source compatibility with the pre-classification typed + * result; DialCache maps its absence to `unclassified`. */ export interface RedisWatermarkMiss { readonly kind: "watermark_miss"; readonly observedWatermarkMs: number; + readonly reason?: CacheMissReason; readonly payload?: never; readonly createdAtMs?: never; } /** - * Semantic Redis read result. `null` remains the generic/legacy miss; bundled - * adapters return `RedisWatermarkMiss` only when a tracked read observed a - * present, valid numeric watermark that can safely fence a candidate refill. + * Semantic Redis read result. `null` remains the generic/legacy miss. Bundled + * adapters return classified misses; a present, valid tracked watermark is + * carried independently by `RedisWatermarkMiss` so it can fence a candidate + * refill even when the miss reason is `value_absent`. */ -export type RedisReadResult = DecodedRedisFrame | RedisWatermarkMiss | null; +export type RedisReadResult = DecodedRedisFrame | RedisReadMiss | RedisWatermarkMiss | null; + +/** Package-private runtime guard for classified and pre-classification misses. */ +export function isRedisReadMiss(result: unknown): result is RedisReadMiss | RedisWatermarkMiss { + return isRedisWatermarkMiss(result) + || ( + typeof result === "object" + && result !== null + && "reason" in result + && !("payload" in result) + && !("createdAtMs" in result) + ); +} -/** Package-private runtime discriminator for the semantic miss variant. */ +/** Package-private runtime discriminator for a miss carrying a refill fence. */ export function isRedisWatermarkMiss(result: unknown): result is RedisWatermarkMiss { return typeof result === "object" && result !== null @@ -139,11 +170,12 @@ export interface RedisWriteRequest extends RedisValueRequest { /** * Nonnegative safe-integer epoch milliseconds to encode in the frame. * DialCache core supplies the final dispatch-adjacent sample for admitted - * refills following `RedisWatermarkMiss`. + * tracked refills whose read result carried a trustworthy observed watermark. * It remains optional so ordinary refills, existing direct adapter callers, * and custom adapter implementations keep their established behavior. An - * adapter that returns `RedisWatermarkMiss` must honor a supplied value - * exactly so the final fence decision and stored frame cannot diverge. + * adapter that returns a typed miss with an observed watermark must honor a + * supplied value exactly so the final fence decision and stored frame cannot + * diverge. */ readonly createdAtMs?: number; } @@ -174,9 +206,9 @@ export interface DialCacheRedisClient { /** * Read a DialCache Redis frame. Hits return the decoded serializer payload * with the frame header's creation time. Implementations must use - * `decodeRedisFrame` and either `decodeTrackedRedisFrame` (legacy null - * misses) or `decodeTrackedRedisReadResult` (typed watermark misses) from - * `dialcache/redis-protocol`, or preserve their exact behavior. + * `decodeRedisFrame` / `decodeTrackedRedisFrame` (legacy null misses), or + * `decodeRedisReadResult` / `decodeTrackedRedisReadResult` (classified + * misses) from `dialcache/redis-protocol`, or preserve their exact behavior. * * Raw values are Redis bulk strings (`Buffer`) or null. A missing value, a * frame shorter than the version/timestamp/encoding header, or an @@ -190,12 +222,13 @@ export interface DialCacheRedisClient { * Tracked implementations must read the value and watermark atomically from * one authoritative snapshot; replica lag must not hide an invalidation. * - * Implementations may return a `RedisWatermarkMiss` for a tracked semantic - * miss when the same snapshot contained a present, valid numeric watermark. - * Existing adapters may continue returning `null` and remain correct while - * missing the conditional refill optimization. Adapters that opt into the - * discriminated miss must also honor `RedisWriteRequest.createdAtMs` when - * supplied. + * Implementations may return a classified `RedisReadMiss`; when the same + * tracked snapshot contained a present, valid numeric watermark, return the + * discriminated `RedisWatermarkMiss` with that `observedWatermarkMs`. Existing + * adapters may continue returning `null` and remain correct, with their misses + * recorded as `unclassified`. Adapters that opt into a typed miss with an + * observed watermark must also honor + * `RedisWriteRequest.createdAtMs` when supplied. * * A returned frame's payload is transferred to DialCache. A returned Buffer * must remain stable and must not be mutated, pooled, or reused after this diff --git a/src/redis-protocol.ts b/src/redis-protocol.ts index d401e58..a7a1088 100644 --- a/src/redis-protocol.ts +++ b/src/redis-protocol.ts @@ -17,12 +17,14 @@ export { ceilSupportedCacheTtlMs } from "./internal/duration.js"; export { INVALIDATE_CACHE_SCRIPT } from "./internal/redis-scripts.js"; export { decodeRedisFrame, + decodeRedisReadResult, decodeTrackedRedisFrame, decodeTrackedRedisReadResult, encodeRedisFrame, } from "./internal/redis-payload.js"; export type { DecodedRedisFrame, + RedisReadMiss, RedisReadResult, RedisWatermarkMiss, } from "./redis-client.js"; diff --git a/src/valkey-glide.ts b/src/valkey-glide.ts index 6bc1c08..77cde32 100644 --- a/src/valkey-glide.ts +++ b/src/valkey-glide.ts @@ -6,7 +6,7 @@ import { } from "./internal/redis-invalidation.js"; import { assertValidRedisTimestampMs, - decodeRedisFrame, + decodeRedisReadResult, decodeTrackedRedisReadResult, encodeRedisFrame, } from "./internal/redis-payload.js"; @@ -143,7 +143,7 @@ export function createValkeyGlideDialCacheClient( async read({ valueKey, watermarkKey }) { if (watermarkKey === undefined) { const raw = await client.get(valueKey, { decoder: glide.Decoder.Bytes }); - return decodeRedisFrame(raw); + return decodeRedisReadResult(raw); } let pair: unknown; diff --git a/test/datadog.test.ts b/test/datadog.test.ts index a0c5b1b..e16638c 100644 --- a/test/datadog.test.ts +++ b/test/datadog.test.ts @@ -5,6 +5,7 @@ import { CacheLayer, DialCache, DialCacheKeyConfig, + type CacheMissReason, type CompressionOutcome, type DisabledReason, type DialCacheRedisClient, @@ -91,6 +92,12 @@ const DISABLED_REASONS: Readonly> = { config_error: true, }; const disabledReasons = Object.keys(DISABLED_REASONS) as DisabledReason[]; +const MISS_REASONS: Readonly> = { + value_absent: true, + watermark_fenced: true, + unclassified: true, +}; +const missReasons = Object.keys(MISS_REASONS) as CacheMissReason[]; const ERROR_KINDS: Readonly> = { key_construction: true, config_resolution: true, @@ -152,7 +159,7 @@ describe("Datadog metrics adapter", () => { const metrics = new DatadogDialCacheMetrics({ client, observationMetricType: "distribution" }); metrics.request(cacheLabels); - metrics.miss(cacheLabels); + metrics.miss({ ...cacheLabels, reason: "value_absent" }); metrics.disabled({ ...cacheLabels, reason: "ramped_down" }); metrics.error({ ...cacheLabels, error: "cache_read", inFallback: true }); metrics.invalidation({ cacheNamespace: cacheLabels.cacheNamespace, keyType: "user_id", layer: CacheLayer.REMOTE }); @@ -205,7 +212,12 @@ describe("Datadog metrics adapter", () => { const baseTags = { cache_namespace: "users", use_case: "LoadUser", key_type: "user_id", layer: "local" }; expect(client.calls).toEqual([ { method: "increment", name: "dialcache.request.count", value: 1, tags: baseTags }, - { method: "increment", name: "dialcache.miss.count", value: 1, tags: baseTags }, + { + method: "increment", + name: "dialcache.miss.count", + value: 1, + tags: { ...baseTags, reason: "value_absent" }, + }, { method: "increment", name: "dialcache.disabled.count", @@ -346,6 +358,9 @@ describe("Datadog metrics adapter", () => { for (const layer of metricLayers) { metrics.request({ ...cacheLabels, layer }); } + for (const reason of missReasons) { + metrics.miss({ ...cacheLabels, reason }); + } for (const reason of disabledReasons) { metrics.disabled({ ...cacheLabels, reason }); } @@ -385,6 +400,11 @@ describe("Datadog metrics adapter", () => { } expect(client.calls.slice(0, metricLayers.length).map(({ tags }) => tags.layer)).toEqual(metricLayers); + expect( + client.calls + .filter(({ name }) => name === "dialcache.miss.count") + .map(({ tags }) => tags.reason), + ).toEqual(missReasons); expect( client.calls .filter(({ name }) => name === "dialcache.disabled.count") diff --git a/test/dialcache-coalescing.test.ts b/test/dialcache-coalescing.test.ts index f51c408..30b11fd 100644 --- a/test/dialcache-coalescing.test.ts +++ b/test/dialcache-coalescing.test.ts @@ -250,6 +250,7 @@ describe("DialCache request coalescing", () => { useCase: "RequestThenProcessCoalescing", keyType: "user_id", layer: "request_local", + reason: "value_absent", }); expect(miss.mock.calls.filter(([labels]) => labels.layer === "request_local")).toHaveLength(2); }); diff --git a/test/dialcache-invalidation.test.ts b/test/dialcache-invalidation.test.ts index ea3f0e3..4901e2d 100644 --- a/test/dialcache-invalidation.test.ts +++ b/test/dialcache-invalidation.test.ts @@ -148,9 +148,12 @@ describe("DialCache targeted invalidation watermarks", () => { watermarkKey, })).resolves.toEqual({ kind: "watermark_miss", + reason: "value_absent", observedWatermarkMs: Date.parse("2026-05-12T18:00:01.000Z"), }); - await expect(redis.read({ valueKey: valueKey("FutureBufferUser") })).resolves.toBeNull(); + await expect(redis.read({ valueKey: valueKey("FutureBufferUser") })).resolves.toEqual({ + reason: "value_absent", + }); }); it("writes the exact refill candidate when it is newer than the observed watermark", async () => { @@ -325,6 +328,7 @@ describe("DialCache targeted invalidation watermarks", () => { watermarkKey, })).resolves.toEqual({ kind: "watermark_miss", + reason: "watermark_fenced", observedWatermarkMs: Date.parse("2026-05-12T18:00:01.000Z"), }); }); @@ -380,6 +384,7 @@ describe("DialCache targeted invalidation watermarks", () => { watermarkKey, })).resolves.toEqual({ kind: "watermark_miss", + reason: "watermark_fenced", observedWatermarkMs: Date.parse("2026-05-12T18:00:01.000Z"), }); }); diff --git a/test/dialcache-metrics.test.ts b/test/dialcache-metrics.test.ts index 42b76dd..3eea35a 100644 --- a/test/dialcache-metrics.test.ts +++ b/test/dialcache-metrics.test.ts @@ -12,6 +12,8 @@ import { type DialCacheMetricsAdapter, type DialCacheRedisClient, type InvalidationMetricLabels, + type MissMetricLabels, + type RedisReadResult, type SerializationMetricLabels, type Serializer, type ShadowValidationMetricLabels, @@ -26,7 +28,7 @@ class RecordingMetrics implements DialCacheMetricsAdapter { this.record("request", labels); } - miss(labels: CacheMetricLabels): void { + miss(labels: MissMetricLabels): void { this.record("miss", labels); } @@ -133,7 +135,7 @@ describe("DialCache observability metrics", () => { }; isolatedMetrics.request(labels); - isolatedMetrics.miss(labels); + isolatedMetrics.miss({ ...labels, reason: "value_absent" }); isolatedMetrics.disabled({ ...labels, reason: "ramped_down" }); isolatedMetrics.error({ ...labels, error: "cache_read", inFallback: false }); isolatedMetrics.invalidation({ @@ -268,11 +270,195 @@ describe("DialCache observability metrics", () => { expect(first).toEqual({ userId: "123", calls: 1 }); expect(second).toEqual({ userId: "123", calls: 1 }); expect(events(metrics, "request", { useCase: "CustomMetricsAdapter", layer: CacheLayer.LOCAL })).toHaveLength(2); - expect(events(metrics, "miss", { useCase: "CustomMetricsAdapter", layer: CacheLayer.LOCAL })).toHaveLength(1); + expect( + events(metrics, "miss", { + useCase: "CustomMetricsAdapter", + layer: CacheLayer.LOCAL, + reason: "value_absent", + }), + ).toHaveLength(1); expect(events(metrics, "fallback", { useCase: "CustomMetricsAdapter", layer: CacheLayer.LOCAL })).toHaveLength(1); expect(events(metrics, "get", { useCase: "CustomMetricsAdapter", layer: CacheLayer.LOCAL })).toHaveLength(2); }); + it("classifies request-local, local, and bundled Redis absence as value_absent", async () => { + const metrics = new RecordingMetrics(); + const requestLocalCache = new DialCache({ metrics }); + const localCache = new DialCache({ metrics }); + const remoteCache = new DialCache({ + metrics, + redis: { client: new FakeRedis(), readTimeoutMs: 1_000 }, + }); + + const requestLocal = requestLocalCache.cached(async () => "request-local", { + keyType: "user_id", + useCase: "RequestLocalAbsentReason", + cacheKey: () => "123", + defaultConfig: new DialCacheKeyConfig({ requestLocal: true }), + }); + const local = localCache.cached(async () => "local", { + keyType: "user_id", + useCase: "LocalAbsentReason", + cacheKey: () => "123", + defaultConfig: localOnly(), + }); + const remote = remoteCache.cached(async () => "remote", { + keyType: "user_id", + useCase: "BundledRedisAbsentReason", + cacheKey: () => "123", + defaultConfig: remoteOnly(), + }); + + await requestLocalCache.enable(async () => await requestLocal()); + await localCache.enable(async () => await local()); + await remoteCache.enable(async () => await remote()); + + expect( + events(metrics, "miss", { + useCase: "RequestLocalAbsentReason", + layer: "request_local", + reason: "value_absent", + }), + ).toHaveLength(1); + expect( + events(metrics, "miss", { + useCase: "LocalAbsentReason", + layer: CacheLayer.LOCAL, + reason: "value_absent", + }), + ).toHaveLength(1); + expect( + events(metrics, "miss", { + useCase: "BundledRedisAbsentReason", + layer: CacheLayer.REMOTE, + reason: "value_absent", + }), + ).toHaveLength(1); + }); + + it("classifies a legacy custom Redis null miss as unclassified", async () => { + const metrics = new RecordingMetrics(); + const redis: DialCacheRedisClient = { + read: vi.fn(async () => null), + write: vi.fn(async () => undefined), + invalidate: vi.fn(async () => undefined), + }; + const dialcache = new DialCache({ metrics, redis: { client: redis, readTimeoutMs: 1_000 } }); + const getUser = dialcache.cached(async () => "fallback", { + keyType: "user_id", + useCase: "LegacyRedisNullReason", + cacheKey: () => "123", + defaultConfig: remoteOnly(), + }); + + await dialcache.enable(async () => await getUser()); + + expect( + events(metrics, "miss", { + useCase: "LegacyRedisNullReason", + layer: CacheLayer.REMOTE, + reason: "unclassified", + }), + ).toHaveLength(1); + }); + + it("normalizes untrusted custom miss metadata to unclassified", async () => { + const cases: ReadonlyArray<{ + readonly useCase: string; + readonly trackForInvalidation: boolean; + readonly result: RedisReadResult; + }> = [ + { + useCase: "UntrackedCustomFenceReason", + trackForInvalidation: false, + result: { reason: "watermark_fenced" }, + }, + { + useCase: "UntrackedCustomWatermarkMiss", + trackForInvalidation: false, + result: { + kind: "watermark_miss", + reason: "watermark_fenced", + observedWatermarkMs: 1_700_000_000_000, + }, + }, + { + useCase: "UnboundedCustomReason", + trackForInvalidation: false, + result: { reason: "invented" } as unknown as RedisReadResult, + }, + { + useCase: "InvalidTrackedCustomFenceReason", + trackForInvalidation: true, + result: { + kind: "watermark_miss", + reason: "watermark_fenced", + observedWatermarkMs: Number.NaN, + }, + }, + ]; + let readIndex = 0; + const metrics = new RecordingMetrics(); + const redis: DialCacheRedisClient = { + read: vi.fn(async () => cases[readIndex++]?.result ?? null), + write: vi.fn(async () => undefined), + invalidate: vi.fn(async () => undefined), + }; + const dialcache = new DialCache({ metrics, redis: { client: redis, readTimeoutMs: 1_000 } }); + + for (const { useCase, trackForInvalidation } of cases) { + const getUser = dialcache.cached(async () => "fallback", { + keyType: "user_id", + useCase, + cacheKey: () => useCase, + trackForInvalidation, + defaultConfig: remoteOnly(), + }); + await expect(dialcache.enable(async () => await getUser())).resolves.toBe("fallback"); + expect( + events(metrics, "miss", { useCase, layer: CacheLayer.REMOTE, reason: "unclassified" }), + ).toHaveLength(1); + expect( + events(metrics, "miss", { useCase, layer: CacheLayer.REMOTE, reason: "watermark_fenced" }), + ).toHaveLength(0); + expect( + events(metrics, "error", { useCase, layer: CacheLayer.REMOTE, error: "cache_read", inFallback: false }), + ).toHaveLength(0); + } + }); + + it("classifies a tracked FakeRedis frame fenced by its observed watermark", async () => { + const metrics = new RecordingMetrics(); + const redis = new FakeRedis(); + const useCase = "TrackedWatermarkFencedReason"; + const key = new DialCacheKey({ + keyType: "user_id", + id: "123", + useCase, + trackForInvalidation: true, + }); + redis.setRaw(`${key.urn}:dialcache-frame-v1`, encodeFrame("stale", 100)); + redis.setRaw(`${key.prefix}#watermark`, "100"); + const dialcache = new DialCache({ metrics, redis: { client: redis, readTimeoutMs: 1_000 } }); + const getUser = dialcache.cached(async () => "fallback", { + keyType: "user_id", + useCase, + cacheKey: () => "123", + trackForInvalidation: true, + defaultConfig: remoteOnly(), + }); + + await dialcache.enable(async () => await getUser()); + + expect( + events(metrics, "miss", { + useCase, + layer: CacheLayer.REMOTE, + reason: "watermark_fenced", + }), + ).toHaveLength(1); + }); + it("reports request-local cache activity and request-scoped coalescing with bounded labels", async () => { const metrics = new RecordingMetrics(); const dialcache = new DialCache({ metrics }); @@ -293,7 +479,13 @@ describe("DialCache observability metrics", () => { expect(values[2]).toBe(values[0]); expect(calls).toBe(1); expect(events(metrics, "request", { useCase: "RequestLocalMetrics", layer: "request_local" })).toHaveLength(2); - expect(events(metrics, "miss", { useCase: "RequestLocalMetrics", layer: "request_local" })).toHaveLength(1); + expect( + events(metrics, "miss", { + useCase: "RequestLocalMetrics", + layer: "request_local", + reason: "value_absent", + }), + ).toHaveLength(1); expect(events(metrics, "get", { useCase: "RequestLocalMetrics", layer: "request_local" })).toHaveLength(2); expect(events(metrics, "fallback", { useCase: "RequestLocalMetrics", layer: "request_local" })).toHaveLength(1); expect(events(metrics, "coalesced", { useCase: "RequestLocalMetrics", scope: "request_local" })).toHaveLength(1); @@ -595,7 +787,7 @@ describe("DialCache observability metrics", () => { { name: "fallback", labels: remoteLabels, value: expect.any(Number) }, ]); expect(events(metrics, "miss", { useCase })).toEqual([ - { name: "miss", labels: remoteLabels }, + { name: "miss", labels: { ...remoteLabels, reason: "unclassified" } }, ]); expect(events(metrics, "request", { useCase })).toEqual([ { name: "request", labels: remoteLabels }, @@ -906,6 +1098,7 @@ describe("DialCache observability metrics", () => { events(metrics, "miss", { useCase: "SerializationLoadClassification", layer: CacheLayer.REMOTE, + reason: "unclassified", }), ).toHaveLength(1); expect(JSON.stringify(events(metrics, "error", {}))).not.toMatch( diff --git a/test/dialcache-redis.test.ts b/test/dialcache-redis.test.ts index 44fafec..69ce98e 100644 --- a/test/dialcache-redis.test.ts +++ b/test/dialcache-redis.test.ts @@ -177,6 +177,7 @@ describe("DialCache Redis TTL layer", () => { const nowMs = 1_700_000_000_000; vi.spyOn(Date, "now").mockReturnValue(nowMs); const redis = new FakeRedis(); + const write = vi.spyOn(redis, "write"); const useCase = "RedisTrackedFutureFrame"; const key = keyFor("123", useCase, true); redis.setRaw( @@ -213,6 +214,7 @@ describe("DialCache Redis TTL layer", () => { expect(serializer.load).not.toHaveBeenCalled(); expect(fallback).toHaveBeenCalledOnce(); + expect(write).toHaveBeenCalledWith(expect.objectContaining({ createdAtMs: nowMs })); expect(observeFutureTimestampOffset).toHaveBeenCalledOnce(); expect(observeFutureTimestampOffset).toHaveBeenCalledWith( { @@ -223,7 +225,13 @@ describe("DialCache Redis TTL layer", () => { }, 1.25, ); - expect(metrics.miss).toHaveBeenCalledOnce(); + expect(metrics.miss).toHaveBeenCalledWith({ + cacheNamespace: "urn", + useCase, + keyType: "user_id", + layer: CacheLayer.REMOTE, + reason: "unclassified", + }); }); it("rejects a future-dated untracked frame using the reader clock", async () => { @@ -404,7 +412,13 @@ describe("DialCache Redis TTL layer", () => { expect(serializer.load).not.toHaveBeenCalled(); expect(fallback).toHaveBeenCalledOnce(); expect(observeFutureTimestampOffset).not.toHaveBeenCalled(); - expect(metrics.miss).toHaveBeenCalledOnce(); + expect(metrics.miss).toHaveBeenCalledWith({ + cacheNamespace: "urn", + useCase: "RedisInvalidTrackedFrameTimestamp", + keyType: "user_id", + layer: CacheLayer.REMOTE, + reason: "unclassified", + }); }); it("keeps a frame stamped exactly at the reader clock eligible", async () => { @@ -762,6 +776,36 @@ describe("DialCache Redis TTL layer", () => { expect(logger.warn).not.toHaveBeenCalledWith("Error getting value from Redis cache", expect.any(Error)); }); + it("classifies a malformed bundled Redis frame as an unclassified miss", async () => { + const redis = new FakeRedis(); + const useCase = "RedisMalformedFrameReason"; + redis.setRaw(redisKeyFor("123", useCase), Buffer.from([2, 0, 0, 0, 0, 0, 0, 0, 0, 0])); + const metrics = metricsWithFutureTimestampObserver(vi.fn()); + const dialcache = new DialCache({ + metrics, + redis: { client: redis, readTimeoutMs: 1_000 }, + }); + const getUser = dialcache.cached(async () => ({ source: "fallback" }), { + keyType: "user_id", + useCase, + cacheKey: () => "123", + defaultConfig: new DialCacheKeyConfig({ + ttlSec: { [CacheLayer.REMOTE]: 60 }, + ramp: { [CacheLayer.REMOTE]: 100 }, + }), + }); + + await expect(dialcache.enable(async () => await getUser())).resolves.toEqual({ source: "fallback" }); + + expect(metrics.miss).toHaveBeenCalledWith({ + cacheNamespace: "urn", + useCase, + keyType: "user_id", + layer: CacheLayer.REMOTE, + reason: "unclassified", + }); + }); + it("records a distinct metric label when a Redis adapter reports invalid payload encoding", async () => { const redisClient: DialCacheRedisClient = { read: vi.fn(async () => { diff --git a/test/dialcache-shadow-confirmation.test.ts b/test/dialcache-shadow-confirmation.test.ts index 612c499..a04406e 100644 --- a/test/dialcache-shadow-confirmation.test.ts +++ b/test/dialcache-shadow-confirmation.test.ts @@ -19,6 +19,7 @@ import { type RedisCachePayload, type RedisInvalidationRequest, type RedisReadContext, + type RedisReadMiss, type RedisReadResult, type RedisReadRequest, type RedisWriteRequest, @@ -54,7 +55,7 @@ function deferred(): Deferred { return { promise, resolve, reject }; } -type ScriptedReadResult = RedisCachePayload | RedisWatermarkMiss | null; +type ScriptedReadResult = RedisCachePayload | RedisReadMiss | RedisWatermarkMiss | null; type ReadStep = () => ScriptedReadResult | Promise; const MAX_TRACKED_REDIS_VALUE_TTL_MS = 60 * 60 * 1_000; @@ -76,19 +77,17 @@ class ScriptedRedis implements DialCacheRedisClient { throw new Error("Unexpected Redis read"); } const result = await step(); - if (result === null || isWatermarkMiss(result)) { + if (result === null || isScriptedReadMiss(result)) { return result; } return { payload: result, createdAtMs: this.frameCreatedAtMs }; } } -function isWatermarkMiss(result: ScriptedReadResult): result is RedisWatermarkMiss { +function isScriptedReadMiss(result: ScriptedReadResult): result is RedisReadMiss | RedisWatermarkMiss { return typeof result === "object" && !Buffer.isBuffer(result) - && result !== null - && "kind" in result - && result.kind === "watermark_miss"; + && result !== null; } type OrdinaryMetricName = @@ -1068,7 +1067,9 @@ describe("DialCache Redis shadow confirmation", () => { expect(redis.write).toHaveBeenCalledOnce(); expectTrackedReads(redis, 1); expect(metrics.ordinaryEvents.filter(({ name, labels }) => - name === "miss" && labels.layer === REMOTE_SHADOW_CACHE_LAYER + name === "miss" + && labels.layer === REMOTE_SHADOW_CACHE_LAYER + && labels.reason === "unclassified" )).toHaveLength(1); } finally { nowSpy.mockRestore(); @@ -1428,7 +1429,9 @@ describe("DialCache Redis shadow confirmation", () => { name === "request" && labels.layer === REMOTE_SHADOW_CACHE_LAYER )).toHaveLength(1); expect(metrics.ordinaryEvents.filter(({ name, labels }) => - name === "miss" && labels.layer === REMOTE_SHADOW_CACHE_LAYER + name === "miss" + && labels.layer === REMOTE_SHADOW_CACHE_LAYER + && labels.reason === "unclassified" )).toHaveLength(1); expect(metrics.ordinaryEvents.filter(({ name, labels }) => name === "get" && labels.layer === REMOTE_SHADOW_CACHE_LAYER @@ -1441,14 +1444,49 @@ describe("DialCache Redis shadow confirmation", () => { } }); - it("fills a typed shadow miss when the final candidate clears the observed watermark", async () => { + it("records value_absent for a valid-watermark dark miss even when its refill is fenced", async () => { + const nowMs = 1_700_000_000_000; + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(nowMs); + try { + const redis = new ScriptedRedis([ + (): RedisWatermarkMiss => ({ + kind: "watermark_miss", + reason: "value_absent", + observedWatermarkMs: nowMs, + }), + ]); + const metrics = new RecordingMetrics(); + const dialcache = createCache(redis, metrics); + const getUser = dialcache.cached(async () => ({ id: "123" }), { + ...trackedOptions("ShadowDarkValueAbsentFillFenced", remoteConfig(0)), + cacheKey: () => "123", + }); + + await expect(dialcache.enable(async () => await getUser())).resolves.toEqual({ id: "123" }); + await waitForShadowEvents(metrics, 1); + + expect(metrics.shadowEvents.map(({ outcome }) => outcome)).toEqual(["fill_fenced"]); + expect(redis.write).not.toHaveBeenCalled(); + expectTrackedReads(redis, 1); + expect(metrics.ordinaryEvents + .filter(({ name, labels }) => name === "miss" && labels.layer === REMOTE_SHADOW_CACHE_LAYER) + .map(({ labels }) => labels.reason)).toEqual(["value_absent"]); + } finally { + nowSpy.mockRestore(); + } + }); + + it("records watermark_fenced for a fenced dark frame that admits a later refill", async () => { const nowMs = 1_700_000_000_000; const nowSpy = vi.spyOn(Date, "now").mockReturnValue(nowMs); try { - const redis = new ScriptedRedis([() => ({ - kind: "watermark_miss", - observedWatermarkMs: nowMs - 1, - })]); + const redis = new ScriptedRedis([ + (): RedisWatermarkMiss => ({ + kind: "watermark_miss", + reason: "watermark_fenced", + observedWatermarkMs: nowMs - 1, + }), + ]); const metrics = new RecordingMetrics(); const serializer: Serializer<{ readonly id: string }> = { dump: vi.fn((value) => JSON.stringify(value)), @@ -1459,7 +1497,7 @@ describe("DialCache Redis shadow confirmation", () => { const source = vi.fn(async () => ({ id: "123" })); const dialcache = createCache(redis, metrics); const getUser = dialcache.cached(source, { - ...trackedOptions("ShadowDarkFillAboveWatermark", remoteConfig(0)), + ...trackedOptions("ShadowDarkWatermarkFencedFill", remoteConfig(0)), cacheKey: () => "123", serializer, }); @@ -1477,6 +1515,9 @@ describe("DialCache Redis shadow confirmation", () => { createdAtMs: nowMs, })); expectTrackedReads(redis, 1); + expect(metrics.ordinaryEvents + .filter(({ name, labels }) => name === "miss" && labels.layer === REMOTE_SHADOW_CACHE_LAYER) + .map(({ labels }) => labels.reason)).toEqual(["watermark_fenced"]); } finally { nowSpy.mockRestore(); } diff --git a/test/dialcache-shadow-validation.test.ts b/test/dialcache-shadow-validation.test.ts index 57e5751..93e344d 100644 --- a/test/dialcache-shadow-validation.test.ts +++ b/test/dialcache-shadow-validation.test.ts @@ -8,11 +8,13 @@ import { DialCacheKey, DialCacheKeyConfig, type CacheMetricLabels, + type DecodedRedisFrame, type DialCacheConfig, type DialCacheMetricsAdapter, type DisabledMetricLabels, type ErrorMetricLabels, type InvalidationMetricLabels, + type RedisReadResult, type SerializationMetricLabels, type Serializer, type ShadowValidationMetricLabels, @@ -30,6 +32,10 @@ interface FutureTimestampEvent { readonly seconds: number; } +function isDecodedRedisFrame(result: RedisReadResult): result is DecodedRedisFrame { + return result !== null && "payload" in result && "createdAtMs" in result; +} + class RecordingMetrics implements DialCacheMetricsAdapter { readonly shadowEvents: ShadowValidationMetricLabels[] = []; readonly shadowAgeEvents: ShadowAgeEvent[] = []; @@ -405,7 +411,9 @@ describe("DialCache Redis shadow validation", () => { const originalRead = redis.read.bind(redis); vi.spyOn(redis, "read").mockImplementation(async (request) => { const frame = await originalRead(request); - return frame === null ? null : { ...frame, createdAtMs: Number.POSITIVE_INFINITY }; + return !isDecodedRedisFrame(frame) + ? frame + : { ...frame, createdAtMs: Number.POSITIVE_INFINITY }; }); const dialcache = createShadowCache(redis, metrics); const source = vi.fn(async () => cachedValue); diff --git a/test/dialcache-stale-on-error.test.ts b/test/dialcache-stale-on-error.test.ts index 3e11045..b888c79 100644 --- a/test/dialcache-stale-on-error.test.ts +++ b/test/dialcache-stale-on-error.test.ts @@ -330,6 +330,65 @@ describe("DialCache stale-on-error recovery", () => { expect(staleRecovery).not.toHaveBeenCalled(); }); + it.each([ + { candidateOffsetMs: 0, expectedWrite: false }, + { candidateOffsetMs: 1, expectedWrite: true }, + ])( + "keeps a retained miss reason independent when its refill candidate is watermark + $candidateOffsetMs ms", + async ({ candidateOffsetMs, expectedWrite }) => { + const useCase = `StaleRecoveryRetainedRefillFence${candidateOffsetMs}`; + const initialNowMs = Date.now(); + const observedWatermarkMs = initialNowMs - 3_000; + const candidateAtMs = observedWatermarkMs + candidateOffsetMs; + const sourceValue = { id: "123", version: 2 }; + const source = vi.fn(async () => { + vi.setSystemTime(candidateAtMs); + return sourceValue; + }); + const redis = new RecordingRedis(); + const { metrics } = recordingMetrics(); + const serializer: Serializer = { + dump: vi.fn(async (value) => JSON.stringify(value)), + load: vi.fn(async (value) => JSON.parse(String(value)) as typeof sourceValue), + }; + const dialcache = new DialCache({ + redis: { client: redis, readTimeoutMs: 1_000 }, + metrics, + shouldAttemptStaleRecovery: allowStaleRecovery, + }); + const getUser = dialcache.cached(source, { + keyType: "user_id", + useCase, + cacheKey: () => "123", + trackForInvalidation: true, + defaultConfig: staleConfig(), + serializer, + }); + redis.setRaw( + redisValueKey(useCase, "123", true), + encodeFrame({ id: "123", version: 1 }, initialNowMs - 2_000), + MAX_AGE_SEC * 1_000, + ); + redis.setRaw(watermarkKey(), String(observedWatermarkMs), MAX_AGE_SEC * 1_000); + + await expect(dialcache.enable(async () => await getUser())).resolves.toBe(sourceValue); + + expect(metrics.miss).toHaveBeenCalledWith({ + cacheNamespace: "urn", + useCase, + keyType: "user_id", + layer: CacheLayer.REMOTE, + reason: "unclassified", + }); + expect(serializer.load).not.toHaveBeenCalled(); + expect(serializer.dump).toHaveBeenCalledTimes(expectedWrite ? 1 : 0); + expect(redis.setCalls).toBe(expectedWrite ? 1 : 0); + if (expectedWrite) { + expect(decodeFrame(redis.raw(redisValueKey(useCase, "123", true))).createdAtMs).toBe(candidateAtMs); + } + }, + ); + it("does not deserialize or record recovery when the classifier denies a retained candidate", async () => { const useCase = "StaleRecoveryClassifierDenied"; const retained = { id: "123", version: 1 }; diff --git a/test/fake-redis.ts b/test/fake-redis.ts index 6e51240..4bf7ca7 100644 --- a/test/fake-redis.ts +++ b/test/fake-redis.ts @@ -4,12 +4,15 @@ import type { RedisReadResult, RedisReadRequest, RedisWriteRequest, - RedisWatermarkMiss, } from "../src/index.js"; import { MAX_TRACKED_REDIS_VALUE_TTL_MS } from "../src/internal/duration.js"; import { MIN_WATERMARK_TTL_MS } from "../src/internal/redis-scripts.js"; -import { DialCacheRedisPayloadEncodingError } from "../src/redis-client.js"; -import { ceilSupportedCacheTtlMs, encodeRedisFrame } from "../src/redis-protocol.js"; +import { + ceilSupportedCacheTtlMs, + decodeRedisReadResult, + decodeTrackedRedisReadResult, + encodeRedisFrame, +} from "../src/redis-protocol.js"; const FRAME_VERSION = 1; const ENCODING_OFFSET = 9; @@ -121,37 +124,13 @@ export class FakeRedis implements DialCacheRedisClient { } private readPayload(valueKey: string, watermarkKey: string | null): RedisReadResult { - let watermark: number | null = null; - let watermarkMiss: RedisWatermarkMiss | null = null; - if (watermarkKey !== null) { - try { - watermark = this.readWatermark(watermarkKey); - } catch { - return null; - } - if (watermark !== null) { - watermarkMiss = { kind: "watermark_miss", observedWatermarkMs: watermark }; - } - } - - const raw = this.readRaw(valueKey); - if (raw === null || raw.length < PAYLOAD_OFFSET || raw[0] !== FRAME_VERSION) { - return watermarkMiss; - } - - const createdAtMs = Number(readTimestamp(raw)); - if (createdAtMs <= (watermark ?? 0)) { - return watermarkMiss; - } - - const encoding = raw[ENCODING_OFFSET]; - if (encoding === 0) { - return { payload: raw.subarray(PAYLOAD_OFFSET).toString("utf8"), createdAtMs }; - } - if (encoding === 1) { - return { payload: Buffer.from(raw.subarray(PAYLOAD_OFFSET)), createdAtMs }; - } - throw new DialCacheRedisPayloadEncodingError("Invalid DialCache Redis payload encoding"); + const stored = this.readRaw(valueKey); + // Real clients transfer a reply buffer; do not let a test caller mutate the + // fake's retained frame through the decoder's zero-copy binary payload. + const raw = stored === null ? null : Buffer.from(stored); + return watermarkKey === null + ? decodeRedisReadResult(raw) + : decodeTrackedRedisReadResult(raw, this.readRaw(watermarkKey)); } private storeWatermark(key: string, watermark: number, ttlMs: number): void { diff --git a/test/node-redis.test.ts b/test/node-redis.test.ts index 24c1113..a900a11 100644 --- a/test/node-redis.test.ts +++ b/test/node-redis.test.ts @@ -112,7 +112,11 @@ describe("node-redis adapter", () => { }); await expect( adapter.read({ valueKey: "tracked:{id}:value", watermarkKey: "tracked:{id}:watermark" }), - ).resolves.toEqual({ payload: Buffer.from([0, 0xff]), createdAtMs: 2 }); + ).resolves.toEqual({ + payload: Buffer.from([0, 0xff]), + createdAtMs: 2, + observedWatermarkMs: 1, + }); await expect( adapter.write({ valueKey: "plain:value", cacheTtlMs: 1_000, value: "plain" }), ).resolves.toBeUndefined(); @@ -128,14 +132,18 @@ describe("node-redis adapter", () => { ).resolves.toBeUndefined(); }); - it("returns an observed watermark for tracked semantic misses", async () => { + it("classifies an absent tracked value while preserving its observed watermark", async () => { const client = fakeClient({ mGet: [null, Buffer.from("1234")] }); const adapter = createNodeRedisDialCacheClient(client as never); await expect(adapter.read({ valueKey: "tracked:{id}:value", watermarkKey: "tracked:{id}:watermark", - })).resolves.toEqual({ kind: "watermark_miss", observedWatermarkMs: 1_234 }); + })).resolves.toEqual({ + kind: "watermark_miss", + reason: "value_absent", + observedWatermarkMs: 1_234, + }); expect(client.sendCommand).toHaveBeenCalledTimes(1); }); @@ -329,7 +337,7 @@ describe("node-redis adapter", () => { await expect(adapter.read( { valueKey: "tracked:{id}:value", watermarkKey: "tracked:{id}:watermark" }, { timeoutMs: 25, signal: controller.signal }, - )).resolves.toEqual({ payload: "tracked", createdAtMs: 2 }); + )).resolves.toEqual({ payload: "tracked", createdAtMs: 2, observedWatermarkMs: 1 }); expect(client.sendCommand).toHaveBeenCalledWith( "tracked:{id}:value", @@ -351,7 +359,7 @@ describe("node-redis adapter", () => { await expect(adapter.read({ valueKey: "tracked:{id}:value", watermarkKey: "tracked:{id}:watermark", - })).resolves.toEqual({ payload: "tracked", createdAtMs: 2 }); + })).resolves.toEqual({ payload: "tracked", createdAtMs: 2, observedWatermarkMs: 1 }); expect(client.sendCommand).toHaveBeenCalledWith( ["MGET", "tracked:{id}:value", "tracked:{id}:watermark"], diff --git a/test/prometheus.test.ts b/test/prometheus.test.ts index ceff5d6..bbd999f 100644 --- a/test/prometheus.test.ts +++ b/test/prometheus.test.ts @@ -12,6 +12,7 @@ import { CacheLayer, DialCache, DialCacheKeyConfig, + type CacheMissReason, type CompressionOutcome, type DisabledReason, type MetricErrorKind, @@ -60,6 +61,11 @@ const DISABLED_REASONS: Readonly> = { ramped_down: true, config_error: true, }; +const MISS_REASONS: Readonly> = { + value_absent: true, + watermark_fenced: true, + unclassified: true, +}; const COMPRESSION_OUTCOMES: Readonly> = { compressed: true, below_threshold: true, @@ -173,7 +179,7 @@ describe("Prometheus metrics adapter", () => { } as const; metrics.request(labels); - metrics.miss(labels); + metrics.miss({ ...labels, reason: "value_absent" }); metrics.disabled({ ...labels, reason: "context" }); metrics.error({ ...labels, error: "cache_read", inFallback: false }); metrics.invalidation({ cacheNamespace: labels.cacheNamespace, keyType: labels.keyType, layer: labels.layer }); @@ -263,7 +269,7 @@ describe("Prometheus metrics adapter", () => { ), histogramSchema("schema_dialcache_get_timer", ["cache_namespace", "use_case", "key_type", "layer"], TIMER_BUCKETS), counterSchema("schema_dialcache_invalidation_counter", ["cache_namespace", "key_type", "layer"]), - counterSchema("schema_dialcache_miss_counter", ["cache_namespace", "use_case", "key_type", "layer"]), + counterSchema("schema_dialcache_miss_counter", ["cache_namespace", "use_case", "key_type", "layer", "reason"]), counterSchema("schema_dialcache_request_counter", ["cache_namespace", "use_case", "key_type", "layer"]), histogramSchema( "schema_dialcache_serialization_timer", @@ -440,6 +446,34 @@ describe("Prometheus metrics adapter", () => { } }); + it("exports every bounded miss reason without rewriting labels", async () => { + const registry = new Registry(); + const metrics = new PrometheusDialCacheMetrics({ registry, prefix: "miss_reason_" }); + const labels = { + cacheNamespace: "users", + useCase: "PrometheusMissReasons", + keyType: "user_id", + layer: CacheLayer.REMOTE, + } as const; + const missReasons = Object.keys(MISS_REASONS) as CacheMissReason[]; + + for (const reason of missReasons) { + metrics.miss({ ...labels, reason }); + } + + for (const reason of missReasons) { + await expect( + sumMetric(registry, "miss_reason_dialcache_miss_counter", { + cache_namespace: labels.cacheNamespace, + use_case: labels.useCase, + key_type: labels.keyType, + layer: labels.layer, + reason, + }), + ).resolves.toBe(1); + } + }); + it("exports every bounded shadow-validation outcome without adding cache identity or layer labels", async () => { const registry = new Registry(); const metrics = new PrometheusDialCacheMetrics({ registry, prefix: "shadow_" }); @@ -545,7 +579,7 @@ describe("Prometheus metrics adapter", () => { } as const; metrics.request(labels); - metrics.miss(labels); + metrics.miss({ ...labels, reason: "unclassified" }); metrics.observeGet(labels, 0.01); await expect( @@ -562,6 +596,7 @@ describe("Prometheus metrics adapter", () => { use_case: labels.useCase, key_type: labels.keyType, layer: labels.layer, + reason: "unclassified", }), ).resolves.toBe(1); }); @@ -592,6 +627,55 @@ describe("Prometheus metrics adapter", () => { ); }); + it("rejects a pre-registered legacy miss collector before registering anything", () => { + const registry = new Registry(); + const prefix = "legacy_miss_"; + const metricName = `${prefix}dialcache_miss_counter`; + new Counter({ + name: metricName, + help: "DialCache cache misses.", + labelNames: ["cache_namespace", "use_case", "key_type", "layer"], + registers: [registry], + }); + + expect(() => new PrometheusDialCacheMetrics({ registry, prefix })).toThrowError( + `Prometheus collector "${metricName}" already exists with an incompatible schema. ` + + "Use a unique prefix or a separate Registry.", + ); + expect(registry.getMetricsAsArray().map(({ name }) => name)).toEqual([metricName]); + }); + + it("reuses a pre-registered current-schema miss collector", async () => { + const registry = new Registry(); + const prefix = "current_miss_"; + const metricName = `${prefix}dialcache_miss_counter`; + new Counter({ + name: metricName, + help: "DialCache cache misses.", + labelNames: ["cache_namespace", "use_case", "key_type", "layer", "reason"], + registers: [registry], + }); + const metrics = new PrometheusDialCacheMetrics({ registry, prefix }); + + metrics.miss({ + cacheNamespace: "users", + useCase: "CurrentMissCollector", + keyType: "user_id", + layer: CacheLayer.REMOTE, + reason: "watermark_fenced", + }); + + await expect( + sumMetric(registry, metricName, { + cache_namespace: "users", + use_case: "CurrentMissCollector", + key_type: "user_id", + layer: "remote", + reason: "watermark_fenced", + }), + ).resolves.toBe(1); + }); + for (const { schemaPart, register } of incompatibleCollectorCases) { it(`rejects an existing collector with incompatible ${schemaPart} before registering anything`, () => { const registry = new Registry(); @@ -674,7 +758,7 @@ describe("Prometheus metrics adapter", () => { expect(second).toEqual({ userId: "123", calls: 1 }); const labels = { cache_namespace: "metrics-cache", use_case: "PrometheusMetricExport", layer: "remote" }; await expect(sumMetric(registry, "test_dialcache_request_counter", labels)).resolves.toBe(2); - await expect(sumMetric(registry, "test_dialcache_miss_counter", labels)).resolves.toBe(1); + await expect(sumMetric(registry, "test_dialcache_miss_counter", { ...labels, reason: "value_absent" })).resolves.toBe(1); await expect(sumMetric(registry, "test_dialcache_get_timer", labels)).resolves.toBeGreaterThan(0); await expect(sumMetric(registry, "test_dialcache_fallback_timer", labels)).resolves.toBeGreaterThan(0); await expect( diff --git a/test/redis-cluster.integration.test.ts b/test/redis-cluster.integration.test.ts index fd3732d..4272285 100644 --- a/test/redis-cluster.integration.test.ts +++ b/test/redis-cluster.integration.test.ts @@ -296,6 +296,7 @@ describe("DialCache Redis protocol on Redis Cluster", () => { expect(observedWatermark).not.toBeNull(); expect(await adapter.read({ valueKey, watermarkKey })).toEqual({ kind: "watermark_miss", + reason: "watermark_fenced", observedWatermarkMs: Number(observedWatermark), }); await expect( @@ -340,6 +341,7 @@ describe("DialCache Redis protocol on Redis Cluster", () => { expect(observedWatermark).not.toBeNull(); expect(await adapter.read({ valueKey, watermarkKey })).toEqual({ kind: "watermark_miss", + reason: "watermark_fenced", observedWatermarkMs: Number(observedWatermark), }); }); diff --git a/test/redis-payload.test.ts b/test/redis-payload.test.ts index 0b91eb8..a246744 100644 --- a/test/redis-payload.test.ts +++ b/test/redis-payload.test.ts @@ -1,5 +1,6 @@ import { decodeRedisFrame, + decodeRedisReadResult, decodeTrackedRedisFrame, decodeTrackedRedisReadResult, encodeRedisFrame, @@ -48,6 +49,14 @@ describe("Redis frame decoding", () => { expect(decodeRedisFrame(encodeFrame("cached", 0, 1_000, 2))).toBeNull(); }); + it("classifies untracked absence separately from malformed frames", () => { + expect(decodeRedisReadResult(null)).toEqual({ reason: "value_absent" }); + expect(decodeRedisReadResult(Buffer.alloc(9))).toEqual({ reason: "unclassified" }); + expect(decodeRedisReadResult(encodeFrame("cached", 0, 1_000, 2))).toEqual({ + reason: "unclassified", + }); + }); + it("rejects unsupported payload encodings after validating the frame", () => { expect(() => decodeRedisFrame(encodeFrame("cached", 2))).toThrow( DialCacheRedisPayloadEncodingError, @@ -86,31 +95,33 @@ describe("Redis frame decoding", () => { ).toBeNull(); }); - it("preserves valid observed watermarks for every tracked semantic miss", () => { + it("classifies tracked misses while preserving a valid observed watermark independently", () => { const watermark = Buffer.from("1000"); - for (const frame of [ - null, - Buffer.alloc(9), - encodeFrame("unsupported", 0, 2_000, 2), - encodeFrame("fenced", 0, 1_000), - ]) { + const cases: ReadonlyArray = [ + [null, "value_absent"], + [Buffer.alloc(9), "unclassified"], + [encodeFrame("unsupported", 0, 2_000, 2), "unclassified"], + [encodeFrame("fenced", 0, 1_000), "watermark_fenced"], + ]; + for (const [frame, reason] of cases) { expect(decodeTrackedRedisReadResult(frame, watermark)).toEqual({ kind: "watermark_miss", + reason, observedWatermarkMs: 1_000, }); expect(decodeTrackedRedisFrame(frame, watermark)).toBeNull(); } }); - it("retains generic misses when no trustworthy watermark was observed", () => { + it("classifies nil before malformed or absent watermark metadata", () => { for (const watermark of [null, Buffer.from("invalid")]) { - expect(decodeTrackedRedisReadResult(null, watermark)).toBeNull(); - expect(decodeTrackedRedisReadResult(Buffer.alloc(9), watermark)).toBeNull(); + expect(decodeTrackedRedisReadResult(null, watermark)).toEqual({ reason: "value_absent" }); + expect(decodeTrackedRedisReadResult(Buffer.alloc(9), watermark)).toEqual({ reason: "unclassified" }); expect(decodeTrackedRedisReadResult( encodeFrame("unsupported", 0, 2_000, 2), watermark, - )).toBeNull(); + )).toEqual({ reason: "unclassified" }); } }); @@ -120,7 +131,7 @@ describe("Redis frame decoding", () => { encodeFrame("cached", 0, 1_001), Buffer.from("1000"), ), - ).toEqual({ payload: "cached", createdAtMs: 1_001 }); + ).toEqual({ payload: "cached", createdAtMs: 1_001, observedWatermarkMs: 1_000 }); }); it("treats a missing watermark as the zero baseline", () => { @@ -132,7 +143,7 @@ describe("Redis frame decoding", () => { }); }); - it("treats malformed and non-finite watermarks as misses", () => { + it("treats malformed and non-finite watermarks as unclassified tracked misses", () => { const frame = encodeFrame("cached", 0, 1_000); for (const watermark of [ @@ -147,6 +158,8 @@ describe("Redis frame decoding", () => { Buffer.from("9".repeat(400)), ]) { expect(decodeTrackedRedisFrame(frame, watermark)).toBeNull(); + expect(decodeTrackedRedisReadResult(frame, watermark)).toEqual({ reason: "unclassified" }); + expect(decodeTrackedRedisReadResult(null, watermark)).toEqual({ reason: "value_absent" }); } }); @@ -189,6 +202,7 @@ describe("Redis frame decoding", () => { expect(decodeTrackedRedisFrame(zeroStamped, null)).toBeNull(); expect(decodeTrackedRedisFrame(zeroStamped, Buffer.from("0"))).toBeNull(); expect(decodeTrackedRedisFrame(zeroStamped, Buffer.from("1"))).toBeNull(); + expect(decodeTrackedRedisReadResult(zeroStamped, null)).toEqual({ reason: "unclassified" }); expect(decodeRedisFrame(zeroStamped)).toEqual({ payload: "pending", createdAtMs: 0 }); }); diff --git a/test/redis-real.integration.test.ts b/test/redis-real.integration.test.ts index 028b788..49486f0 100644 --- a/test/redis-real.integration.test.ts +++ b/test/redis-real.integration.test.ts @@ -1091,6 +1091,7 @@ describe.each(engines)("DialCache Redis protocol on $name", ({ image }) => { useCase, keyType: "item_id", layer: "remote_shadow", + reason: "value_absent", }); expect(metrics.observeSerialization).toHaveBeenCalledWith({ cacheNamespace: namespace, @@ -1185,6 +1186,7 @@ describe.each(engines)("DialCache Redis protocol on $name", ({ image }) => { expect(await admin.exists(valueKey)).toBe(0); expect(await client.adapter.read({ valueKey, watermarkKey })).toEqual({ kind: "watermark_miss", + reason: "value_absent", observedWatermarkMs: candidateAtMs, }); expect(await admin.get(watermarkKey)).toBe(String(candidateAtMs)); @@ -1232,11 +1234,12 @@ describe.each(engines)("DialCache Redis protocol on $name", ({ image }) => { const watermark = Number(await admin.get(watermarkKey)); expect(await scriptClient.read({ valueKey: trackedValueKey, watermarkKey })).toEqual({ kind: "watermark_miss", + reason: "watermark_fenced", observedWatermarkMs: watermark, }); }); - it("uses zero for a missing watermark and misses on malformed or fenced state", async () => { + it("classifies native absent, malformed, and watermark-fenced read states", async () => { if (client === undefined || admin === undefined) { throw new Error("Redis test clients did not start"); } @@ -1244,34 +1247,44 @@ describe.each(engines)("DialCache Redis protocol on $name", ({ image }) => { const valueKey = "read-paths:{item:read}:value"; const watermarkKey = "read-paths:{item:read}:watermark"; - expect(await scriptClient.read({ valueKey })).toBeNull(); + expect(await scriptClient.read({ valueKey })).toEqual({ reason: "value_absent" }); await admin.set(valueKey, Buffer.alloc(9)); - expect(await scriptClient.read({ valueKey })).toBeNull(); + expect(await scriptClient.read({ valueKey })).toEqual({ reason: "unclassified" }); await admin.set(valueKey, encodeFrame("wrong-version", 0, 1_000, 2)); - expect(await scriptClient.read({ valueKey })).toBeNull(); + expect(await scriptClient.read({ valueKey })).toEqual({ reason: "unclassified" }); + await admin.del(valueKey); + await admin.set(watermarkKey, "1000"); + expect(await scriptClient.read({ valueKey, watermarkKey })).toEqual({ + kind: "watermark_miss", + reason: "value_absent", + observedWatermarkMs: 1_000, + }); + + await admin.del(watermarkKey); await admin.set(valueKey, encodeFrame("tracked", 0, 1_000)); expect((await scriptClient.read({ valueKey, watermarkKey }))?.payload).toBe("tracked"); await admin.set(watermarkKey, "not-a-watermark"); - expect(await scriptClient.read({ valueKey, watermarkKey })).toBeNull(); + expect(await scriptClient.read({ valueKey, watermarkKey })).toEqual({ reason: "unclassified" }); await admin.set(watermarkKey, "9".repeat(400)); - expect(await scriptClient.read({ valueKey, watermarkKey })).toBeNull(); + expect(await scriptClient.read({ valueKey, watermarkKey })).toEqual({ reason: "unclassified" }); await admin.set(watermarkKey, "1000"); expect(await scriptClient.read({ valueKey, watermarkKey })).toEqual({ kind: "watermark_miss", + reason: "watermark_fenced", observedWatermarkMs: 1_000, }); await admin.set(watermarkKey, "999.5"); - expect(await scriptClient.read({ valueKey, watermarkKey })).toBeNull(); + expect(await scriptClient.read({ valueKey, watermarkKey })).toEqual({ reason: "unclassified" }); await admin.set(watermarkKey, String(Number.MAX_SAFE_INTEGER + 1)); - expect(await scriptClient.read({ valueKey, watermarkKey })).toBeNull(); + expect(await scriptClient.read({ valueKey, watermarkKey })).toEqual({ reason: "unclassified" }); }); it("records a stale tracked frame as a remote miss without a read error", async () => { @@ -1325,7 +1338,7 @@ describe.each(engines)("DialCache Redis protocol on $name", ({ image }) => { expect(metrics.request).toHaveBeenCalledOnce(); expect(metrics.request).toHaveBeenCalledWith(labels); expect(metrics.miss).toHaveBeenCalledOnce(); - expect(metrics.miss).toHaveBeenCalledWith(labels); + expect(metrics.miss).toHaveBeenCalledWith({ ...labels, reason: "watermark_fenced" }); expect(metrics.observeGet).toHaveBeenCalledOnce(); expect(metrics.observeGet).toHaveBeenCalledWith(labels, expect.any(Number)); expect(metrics.observeFallback).toHaveBeenCalledOnce(); @@ -1346,6 +1359,7 @@ describe.each(engines)("DialCache Redis protocol on $name", ({ image }) => { await expect(scriptClient.read({ valueKey })).rejects.toThrow(/WRONGTYPE/); await expect(scriptClient.read({ valueKey, watermarkKey })).resolves.toEqual({ kind: "watermark_miss", + reason: "value_absent", observedWatermarkMs: 0, }); @@ -1372,6 +1386,7 @@ describe.each(engines)("DialCache Redis protocol on $name", ({ image }) => { expect(await admin.pTTL(watermarkKey)).toBeLessThanOrEqual(MIN_WATERMARK_TTL_MS); expect(await scriptClient.read({ valueKey, watermarkKey })).toEqual({ kind: "watermark_miss", + reason: "watermark_fenced", observedWatermarkMs: invalidatedAtMs + 100, }); @@ -1655,7 +1670,7 @@ describe.each(engines)("DialCache Redis protocol on $name", ({ image }) => { cacheTtlMs: 60_000, value: "replacement", })).resolves.toBeUndefined(); - expect(await scriptClient.read({ valueKey, watermarkKey })).toBeNull(); + expect(await scriptClient.read({ valueKey, watermarkKey })).toEqual({ reason: "unclassified" }); expect((await scriptClient.read({ valueKey }))?.payload).toBe("replacement"); const stored = await admin.get(commandOptions({ returnBuffers: true }), valueKey); expect(stored?.[0]).toBe(1); @@ -1816,6 +1831,7 @@ describe.each(engines)("DialCache Redis protocol on $name", ({ image }) => { await scriptClient.invalidate({ watermarkKey, futureBufferMs: 100 }); expect(await scriptClient.read({ valueKey, watermarkKey })).toEqual({ kind: "watermark_miss", + reason: "watermark_fenced", observedWatermarkMs: invalidatedAtMs + 100, }); const watermarkBeforeWrite = await admin.get(watermarkKey); @@ -1824,6 +1840,7 @@ describe.each(engines)("DialCache Redis protocol on $name", ({ image }) => { expect((await scriptClient.read({ valueKey }))?.payload).toBe("behind-watermark"); expect(await scriptClient.read({ valueKey, watermarkKey })).toEqual({ kind: "watermark_miss", + reason: "watermark_fenced", observedWatermarkMs: invalidatedAtMs + 100, }); expect(await admin.get(watermarkKey)).toBe(watermarkBeforeWrite); @@ -1855,6 +1872,7 @@ describe.each(engines)("DialCache Redis protocol on $name", ({ image }) => { const watermark = Number(await admin.get(watermarkKey)); expect(await scriptClient.read({ valueKey, watermarkKey })).toEqual({ kind: "watermark_miss", + reason: "watermark_fenced", observedWatermarkMs: watermark, }); diff --git a/test/valkey-glide.test.ts b/test/valkey-glide.test.ts index 8951f1e..a40cd17 100644 --- a/test/valkey-glide.test.ts +++ b/test/valkey-glide.test.ts @@ -148,8 +148,14 @@ describe("Valkey GLIDE adapter", () => { }); await expect( adapter.read({ valueKey: "tracked:{id}:value", watermarkKey: "tracked:{id}:watermark" }), - ).resolves.toEqual({ payload: Buffer.from([0, 0xff]), createdAtMs: 1_000 }); - await expect(adapter.read({ valueKey: "missing:value" })).resolves.toBeNull(); + ).resolves.toEqual({ + payload: Buffer.from([0, 0xff]), + createdAtMs: 1_000, + observedWatermarkMs: 0, + }); + await expect(adapter.read({ valueKey: "missing:value" })).resolves.toEqual({ + reason: "value_absent", + }); expect(client.get).toHaveBeenNthCalledWith( 1, @@ -175,14 +181,18 @@ describe("Valkey GLIDE adapter", () => { expect(client.customCommand).not.toHaveBeenCalled(); }); - it("returns an observed watermark for tracked semantic misses", async () => { + it("classifies an absent tracked value while preserving its observed watermark", async () => { const client = fakeClient([[null, Buffer.from("1234")]]); const adapter = createValkeyGlideDialCacheClient(client, mockGlide); await expect(adapter.read({ valueKey: "tracked:{id}:value", watermarkKey: "tracked:{id}:watermark", - })).resolves.toEqual({ kind: "watermark_miss", observedWatermarkMs: 1_234 }); + })).resolves.toEqual({ + kind: "watermark_miss", + reason: "value_absent", + observedWatermarkMs: 1_234, + }); expect(client.exec).toHaveBeenCalledTimes(1); }); @@ -199,7 +209,11 @@ describe("Valkey GLIDE adapter", () => { valueKey: "cluster:{id}:value", watermarkKey: "cluster:{id}:watermark", }), - ).resolves.toEqual({ payload: "tracked-cluster", createdAtMs: 1_000 }); + ).resolves.toEqual({ + payload: "tracked-cluster", + createdAtMs: 1_000, + observedWatermarkMs: 0, + }); expect(client.customCommand).toHaveBeenCalledWith( ["MGET", "cluster:{id}:value", "cluster:{id}:watermark"], From dc6199d670cdc03fb610004ce1f53cb398134096 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sun, 30 Aug 2026 13:32:15 -0700 Subject: [PATCH 2/2] fix(metrics): preserve existing refill and decoder behavior --- README.md | 8 +++-- scripts/test-package.mjs | 15 ++++++++ src/dialcache.ts | 4 +-- src/internal/cache-result.ts | 1 - src/internal/redis-cache.ts | 41 +++++---------------- src/internal/redis-payload.ts | 21 ++++------- src/redis-client.ts | 15 +++----- test/dialcache-metrics.test.ts | 37 +++++++++++++++++++ test/dialcache-redis.test.ts | 25 +++++++++---- test/dialcache-shadow-validation.test.ts | 44 +++++++++++++++++++++++ test/dialcache-stale-on-error.test.ts | 21 +++++------ test/node-redis.test.ts | 5 ++- test/redis-payload.test.ts | 45 ++++++++++++++++++++---- test/valkey-glide.test.ts | 2 -- 14 files changed, 190 insertions(+), 94 deletions(-) diff --git a/README.md b/README.md index 87e047e..78e5e85 100644 --- a/README.md +++ b/README.md @@ -486,6 +486,8 @@ The core Redis boundary is the client-agnostic `DialCacheRedisClient` interface. The shared `encodeRedisFrame`, `decodeRedisFrame`, `decodeRedisReadResult`, `decodeTrackedRedisFrame`, and `decodeTrackedRedisReadResult` helpers, the `validateRedisSetReply` and `validateRedisScriptInvalidationReply` reply helpers, the `ceilSupportedCacheTtlMs` TTL guard, and the invalidation Lua source are available from `dialcache/redis-protocol`. A custom write chooses `const createdAtMs = request.createdAtMs === undefined ? Date.now() : request.createdAtMs`, calls `encodeRedisFrame(request.value, createdAtMs)`, and sends one `SET valueKey frame PX cacheTtlMs` after validating the TTL. The fallback clock covers ordinary core writes and direct callers that omit the optional field; supplied values must not be resampled or replaced, and invalid runtime values must still be rejected by the frame encoder. A custom untracked read can pass its native reply to `decodeRedisReadResult`; a custom tracked read atomically obtains `[value, watermark]` from the primary and passes both replies to `decodeTrackedRedisReadResult`. Those typed helpers preserve the exact bounded reason and carry any valid observed fence on the discriminated variant, while the backward-compatible frame helpers collapse misses to `null` and preserve the established `DecodedRedisFrame | null` surface. A missing watermark is treated as zero for valid frames; malformed numeric watermark metadata is `unclassified`, except that a native `nil` value remains `value_absent`. Invalidation passes `KEYS = [watermarkKey]` and `ARGV = [futureBufferMs, invalidatedAtMs]`, with one stable client timestamp reused across retries. Custom adapters can throw the root-exported `DialCacheRedisPayloadError`, `DialCacheRedisPayloadEncodingError`, and `DialCacheRedisProtocolError` classes to distinguish malformed replies, unsupported encodings, and reply-domain violations. DialCache records bounded `cache_read`, `cache_write`, or `invalidation` metrics by failure site. +Decoded hits keep the existing `{ payload, createdAtMs }` shape. Caller-serving age, future-time, and deserialization rejections emit `unclassified` without acquiring a refill fence; conditional refills remain limited to adapter-level misses carrying a valid observed watermark. + Redis values use a compact binary frame: ```text @@ -721,7 +723,7 @@ Invalidation writes a Redis watermark at `{encodedNamespace:encodedKeyType:encod The internal `:dialcache-frame-v1` suffix identifies values written with DialCache's binary protocol. Watermarks are stored as decimal timestamps. -A cached Redis value whose writer-provided `createdAtMs` is older than or equal to the watermark is a `watermark_fenced` tracked miss. `invalidateRemote(keyType, id, futureBufferMs)` proposes the invalidating process's `Date.now()` plus the buffer, and Lua keeps the greater of that proposal and the existing watermark. A tracked read obtains value and watermark in one primary-routed `MGET`; a missing watermark is the zero baseline, malformed or out-of-range decimal state is `unclassified`, and `createdAtMs <= watermark` is `watermark_fenced`. Redis also returns `nil` for a wrong-type member of `MGET`, so a wrong-type watermark has the same zero-baseline behavior as an absent one until the next explicit invalidation repairs it. When the same snapshot contains a valid numeric watermark and an adapter-level semantic miss, the bundled adapter returns `RedisWatermarkMiss { kind: "watermark_miss", reason, observedWatermarkMs }`. The reason remains independent: a missing value is `value_absent` while carrying the same refill fence, and only a complete supported frame actually rejected by the watermark is `watermark_fenced`. After the fallback succeeds, DialCache samples a preflight timestamp before serialization. If it is at or before `observedWatermarkMs`, fallback still returns normally but the replacement is skipped before serializer/compression/frame work and `SET`. Otherwise DialCache prepares the payload, samples a final dispatch-adjacent timestamp, and rechecks the same watermark. A final timestamp at or before the watermark suppresses `SET`; an admitted write encodes that exact final timestamp so serialization time does not consume the stored value's logical TTL. Classified misses without a valid observed fence and legacy custom adapters that return `null` retain normal refill behavior. Native `MGET` must still transfer the full stored frame before Node can apply the fence verdict, so a future window can repeatedly transfer a large fenced payload even when replacement work is suppressed. A tracked invocation that reaches the Redis read/write path does not publish its fallback directly to process-local cache; a later validated Redis hit may warm it. Local-only, remote-policy-disabled, and ramped-down paths remain governed by local policy, while request-local memoization remains unconditional. A ramped-out invocation without shadow work does not consult Redis; selected tracked shadow reads remain watermark-aware. +A complete supported Redis frame with a positive safe-integer `createdAtMs` at or below a valid observed watermark is a `watermark_fenced` tracked miss. `invalidateRemote(keyType, id, futureBufferMs)` proposes the invalidating process's `Date.now()` plus the buffer, and Lua keeps the greater of that proposal and the existing watermark. A tracked read obtains value and watermark in one primary-routed `MGET`; a missing watermark is the zero baseline, malformed or out-of-range decimal state is `unclassified`. A zero-stamped tracked frame is also `unclassified`, not evidence of watermark invalidation. Redis also returns `nil` for a wrong-type member of `MGET`, so a wrong-type watermark has the same zero-baseline behavior as an absent one until the next explicit invalidation repairs it. When the same snapshot contains a valid numeric watermark and an adapter-level semantic miss, the bundled adapter returns `RedisWatermarkMiss { kind: "watermark_miss", reason, observedWatermarkMs }`. The reason remains independent: a missing value is `value_absent` while carrying the same refill fence, and only a complete supported frame actually rejected by the watermark is `watermark_fenced`. After the fallback succeeds, DialCache samples a preflight timestamp before serialization. If it is at or before `observedWatermarkMs`, fallback still returns normally but the replacement is skipped before serializer/compression/frame work and `SET`. Otherwise DialCache prepares the payload, samples a final dispatch-adjacent timestamp, and rechecks the same watermark. A final timestamp at or before the watermark suppresses `SET`; an admitted write encodes that exact final timestamp so serialization time does not consume the stored value's logical TTL. Classified misses without a valid observed fence and legacy custom adapters that return `null` retain normal refill behavior. Native `MGET` must still transfer the full stored frame before Node can apply the fence verdict, so a future window can repeatedly transfer a large fenced payload even when replacement work is suppressed. A tracked invocation that reaches the Redis read/write path does not publish its fallback directly to process-local cache; a later validated Redis hit may warm it. Local-only, remote-policy-disabled, and ramped-down paths remain governed by local policy, while request-local memoization remains unconditional. A ramped-out invocation without shadow work does not consult Redis; selected tracked shadow reads remain watermark-aware. All serving timestamps come from application-process epoch clocks; DialCache does not call Redis `TIME`, estimate an offset, or compensate for skew. Participating application nodes therefore need external clock synchronization and monitoring. Healthy managed node pools commonly stay close, but Kubernetes does not guarantee a maximum offset, and pauses or NTP faults can be much larger than normal millisecond-scale skew. Relative clock differences shift logical expiry early or late, while frames dated after a reader clock fail closed until that clock catches up. Operation durations and deadlines continue to use the monotonic `performance.now()` clock. @@ -879,7 +881,7 @@ The Prometheus adapter emits: | `reason` | Meaning | | --- | --- | | `value_absent` | The layer had no retrievable value, including never-populated, expired, evicted, Redis `nil`, and tracked-`MGET` wrong-type-as-null states. | -| `watermark_fenced` | A complete supported tracked frame was rejected because its timestamp was at or before a valid observed invalidation watermark; this happens before deserialization and does not assert that the payload would deserialize. | +| `watermark_fenced` | A complete supported tracked frame with a positive safe-integer timestamp was rejected because it was at or before a valid observed invalidation watermark; this happens before deserialization and does not assert that the payload would deserialize. | | `unclassified` | The miss was real but cannot be attributed to either prior category, including legacy/custom Redis `null`, short or unsupported frames, malformed metadata, logically expired frames (including frames retained only as stale-on-error candidates), invalid or future timestamps, and caller-side deserialization failures. | This is an intentional Prometheus schema migration. During a mixed-fleet rollout, old scraped miss series lack `reason` while new ones carry it. For existing total-miss queries and miss/request ratios, aggregate away `reason` and scrape labels on both sides—for example, `sum by (cache_namespace, use_case, key_type, layer) (rate(dialcache_miss_counter[5m])) / sum by (cache_namespace, use_case, key_type, layer) (rate(dialcache_request_counter[5m]))`. Reason-aware dashboards should group explicitly by `reason`. @@ -978,7 +980,7 @@ These values are defined by the backend-neutral core and are identical for every ### Custom adapters -For other telemetry backends, implement `DialCacheMetricsAdapter` and pass the adapter through `new DialCache({ metrics })`. Every backend-neutral label object exposes the logical namespace as camel-case `cacheNamespace`; adapters should map it to their backend's `cache_namespace` label/tag. This field is present even when no key or cache layer was reached. `miss()` now receives `MissMetricLabels`, which extends unchanged `CacheMetricLabels` with the required bounded `reason`; every other callback keeps its existing label shape. Custom adapters whose `miss` parameter is typed as the broader `CacheMetricLabels` can ignore the new property, but direct callers, exact label snapshots, exhaustive reason handling, and adapters that reject or forward unknown fields must migrate to accept or map it. Implement the optional `shadowValidation` method to enable shadow work as well as record its outcomes; omitting it leaves all shadow work disabled even when `shadow.ramp` is nonzero or mismatch logging is enabled. The optional `observeShadowValueAge` method records the validated value's age in seconds for `match` and `mismatch` outcomes; omitting it skips only that observation without affecting shadow eligibility. The optional `observeFutureTimestampOffset` method receives existing bounded cache labels plus the exact positive offset in seconds; omitting it does not change read decisions: serving and initial-shadow reads still miss, while confirmation reads still retain the frame for payload comparison. The optional `staleRecovery` method records one bounded terminal outcome for each classifier-authorized recovery check; omitting it disables only that observation, not recovery itself. The optional `observeStaleRecoveryValueAge` method records the actual return-time age in seconds only when recovery serves; omitting it skips only that observation. Every metrics callback is fire-and-forget: DialCache isolates synchronous throws and consumes rejections from returned promises or thenables, but never awaits or drains observer work. This metric schema/API migration does not change cache serving, refill, invalidation, stale recovery, or shadow-verdict behavior. Omit `metrics` to disable metrics. +For other telemetry backends, implement `DialCacheMetricsAdapter` and pass the adapter through `new DialCache({ metrics })`. Every backend-neutral label object exposes the logical namespace as camel-case `cacheNamespace`; adapters should map it to their backend's `cache_namespace` label/tag. This field is present even when no key or cache layer was reached. `miss()` now receives `MissMetricLabels`, which extends unchanged `CacheMetricLabels` with the required bounded `reason`; every other callback keeps its existing label shape. Custom adapters whose `miss` parameter is typed as the broader `CacheMetricLabels` can ignore the new property, but direct callers, exact label snapshots, exhaustive reason handling, and adapters that reject or forward unknown fields must migrate to accept or map it. Implement the optional `shadowValidation` method to enable shadow work as well as record its outcomes; omitting it leaves all shadow work disabled even when `shadow.ramp` is nonzero or mismatch logging is enabled. The optional `observeShadowValueAge` method records the validated value's age in seconds for `match` and `mismatch` outcomes; omitting it skips only that observation without affecting shadow eligibility. The optional `observeFutureTimestampOffset` method receives existing bounded cache labels plus the exact positive offset in seconds; omitting it does not change read decisions: serving and initial-shadow reads still miss, while confirmation reads still retain the frame for payload comparison. The optional `staleRecovery` method records one bounded terminal outcome for each classifier-authorized recovery check; omitting it disables only that observation, not recovery itself. The optional `observeStaleRecoveryValueAge` method records the actual return-time age in seconds only when recovery serves; omitting it skips only that observation. Every metrics callback is fire-and-forget: DialCache isolates synchronous throws and consumes rejections from returned promises or thenables, but never awaits or drains observer work. Miss classification adds no new refill-fencing paths and preserves serving eligibility, invalidation, stale-recovery policy, and shadow outcomes. Omit `metrics` to disable metrics. ## Maintainers diff --git a/scripts/test-package.mjs b/scripts/test-package.mjs index 2f06b51..b524104 100644 --- a/scripts/test-package.mjs +++ b/scripts/test-package.mjs @@ -347,6 +347,20 @@ emptyRedisFrame[0] = 1; emptyRedisFrame.writeBigUInt64BE(1n, 1); const decodedEmptyRedisFrame: DecodedRedisFrame | null = decodeRedisFrame(emptyRedisFrame); const decodedRedisReadResult: RedisReadResult = decodeRedisReadResult(null); +const explicitlyUndefinedReadMiss: RedisReadMiss = { + reason: "value_absent", + payload: undefined, + createdAtMs: undefined, +}; +const explicitlyUndefinedWatermarkMiss: RedisWatermarkMiss = { + kind: "watermark_miss", + reason: "watermark_fenced", + observedWatermarkMs: 1, + payload: undefined, + createdAtMs: undefined, +}; +void explicitlyUndefinedReadMiss; +void explicitlyUndefinedWatermarkMiss; if ( decodedRedisReadResult !== null && "reason" in decodedRedisReadResult @@ -1851,6 +1865,7 @@ function typescriptConfig(include) { moduleResolution: "Node16", noEmit: true, strict: true, + exactOptionalPropertyTypes: false, }, include, }, diff --git a/src/dialcache.ts b/src/dialcache.ts index 1ac3c96..f2df9a3 100644 --- a/src/dialcache.ts +++ b/src/dialcache.ts @@ -877,9 +877,7 @@ export class DialCache { key, value, remoteWriteConfig, - remote.status === "miss" || remote.status === "retained" - ? remote.watermarkMiss - : undefined, + remote.status === "miss" ? remote.watermarkMiss : undefined, ); } catch (error) { this.logger.warn("Error putting value in Redis cache", error); diff --git a/src/internal/cache-result.ts b/src/internal/cache-result.ts index 0bff158..9b987f5 100644 --- a/src/internal/cache-result.ts +++ b/src/internal/cache-result.ts @@ -20,7 +20,6 @@ export type RedisCacheGetResult = readonly status: "retained"; readonly frame: DecodedRedisFrame; readonly config: ResolvedRemoteLayerConfig; - readonly watermarkMiss?: RedisWatermarkMiss; } | { readonly status: "miss"; diff --git a/src/internal/redis-cache.ts b/src/internal/redis-cache.ts index 4696fe0..ecba854 100644 --- a/src/internal/redis-cache.ts +++ b/src/internal/redis-cache.ts @@ -163,29 +163,20 @@ export class RedisCache { // remains the ordinary serving boundary. const frameAge = this.frameAge(key, result, metricLayer); if (frameAge.status !== "valid" || frameAge.ageMs >= maximumAgeMs) { - const miss = unclassifiedFrameMiss(key, result); - this.recordMiss(key, metricLayer, missReason(miss)); - return isRedisWatermarkMiss(miss) - ? { status: "miss", config: layerConfig, reason: "cache_miss", watermarkMiss: miss } - : { status: "miss", config: layerConfig, reason: "cache_miss" }; + this.recordMiss(key, metricLayer, "unclassified"); + return { status: "miss", config: layerConfig, reason: "cache_miss" }; } if (frameAge.ageMs >= freshAgeMs) { - const miss = unclassifiedFrameMiss(key, result); - this.recordMiss(key, metricLayer, missReason(miss)); - return isRedisWatermarkMiss(miss) - ? { status: "retained", config: layerConfig, frame: result, watermarkMiss: miss } - : { status: "retained", config: layerConfig, frame: result }; + this.recordMiss(key, metricLayer, "unclassified"); + return { status: "retained", config: layerConfig, frame: result }; } try { const value = await this.deserializePayload(key, result.payload, metricLayer); return { status: "hit", value, frame: result }; } catch { - const miss = unclassifiedFrameMiss(key, result); - this.recordMiss(key, metricLayer, missReason(miss)); - return isRedisWatermarkMiss(miss) - ? { status: "miss", config: layerConfig, reason: "deserialization_error", watermarkMiss: miss } - : { status: "miss", config: layerConfig, reason: "deserialization_error" }; + this.recordMiss(key, metricLayer, "unclassified"); + return { status: "miss", config: layerConfig, reason: "deserialization_error" }; } } finally { // Preserve the caller-serving boundary: Redis read plus any ordinary @@ -503,16 +494,12 @@ export class RedisCache { } const age = this.frameAge(key, result, metricLayer); if (age.status === "future") { - return futureFramePolicy === "retain" - ? result - : unclassifiedFrameMiss(key, result); + return futureFramePolicy === "reject" ? null : result; } if (age.status === "invalid") { - return unclassifiedFrameMiss(key, result); + return null; } - return maxAgeMs === null || age.ageMs < maxAgeMs - ? result - : unclassifiedFrameMiss(key, result); + return maxAgeMs === null || age.ageMs < maxAgeMs ? result : null; } private validateReadResult(key: DialCacheKey, result: RedisReadResult): RedisReadResult { @@ -667,16 +654,6 @@ function classifiedRedisReadMiss( : { kind: "watermark_miss", reason, observedWatermarkMs: observed }; } -function unclassifiedFrameMiss( - key: DialCacheKey, - frame: DecodedRedisFrame, -): RedisReadMiss | RedisWatermarkMiss { - return classifiedRedisReadMiss( - "unclassified", - key.trackForInvalidation ? frame.observedWatermarkMs : undefined, - ); -} - function validObservedWatermarkMs(value: unknown): number | undefined { return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value diff --git a/src/internal/redis-payload.ts b/src/internal/redis-payload.ts index 4f240e1..91f1598 100644 --- a/src/internal/redis-payload.ts +++ b/src/internal/redis-payload.ts @@ -169,11 +169,7 @@ export function decodeTrackedRedisFrame( rawWatermark: unknown, ): DecodedRedisFrame | null { const result = decodeTrackedRedisReadResult(raw, rawWatermark); - if (result === null || isRedisReadMiss(result)) { - return null; - } - // Do not widen the established compatibility helper's served-frame shape. - return { payload: result.payload, createdAtMs: result.createdAtMs }; + return isRedisReadMiss(result) ? null : result; } function decodeTrackedFrame( @@ -181,25 +177,22 @@ function decodeTrackedFrame( observedWatermarkMs?: number, ): RedisReadResult { const createdAtMs = readFrameCreatedAtMs(frame); - // Tracked protocol frames require a positive safe-integer timestamp. Keep - // malformed/future decisions out of the watermark-fenced category. - if (!isValidRedisTimestampMs(createdAtMs) || createdAtMs === 0) { + // Preserve zero-baseline misses. Core validates all other timestamps after + // payload decoding so corrupt encodings retain their existing error path. + if (createdAtMs === 0) { return redisReadMiss("unclassified", observedWatermarkMs); } if (observedWatermarkMs !== undefined && createdAtMs <= observedWatermarkMs) { return redisReadMiss("watermark_fenced", observedWatermarkMs); } - return decodedRedisFrame(frame, observedWatermarkMs); + return decodedRedisFrame(frame); } -function decodedRedisFrame(frame: Buffer, observedWatermarkMs?: number): DecodedRedisFrame { - const decoded = { +function decodedRedisFrame(frame: Buffer): DecodedRedisFrame { + return { payload: decodeRedisPayload(frame.subarray(REDIS_FRAME_HEADER_BYTES)), createdAtMs: readFrameCreatedAtMs(frame), }; - return observedWatermarkMs === undefined - ? decoded - : { ...decoded, observedWatermarkMs }; } function redisReadMiss( diff --git a/src/redis-client.ts b/src/redis-client.ts index 216cfe9..6278931 100644 --- a/src/redis-client.ts +++ b/src/redis-client.ts @@ -79,13 +79,6 @@ export interface DecodedRedisFrame { readonly payload: RedisCachePayload; /** Epoch milliseconds copied from the frame header. */ readonly createdAtMs: number; - /** - * A trustworthy watermark observed alongside this eligible tracked frame. - * It is retained so a later core-side rejection can carry the independent - * refill fence forward without relabeling that rejection as a watermark - * fence. Untracked and legacy decoder results omit it. - */ - readonly observedWatermarkMs?: number; } /** A classified semantic Redis miss without a trustworthy refill fence. */ @@ -125,8 +118,8 @@ export function isRedisReadMiss(result: unknown): result is RedisReadMiss | Redi typeof result === "object" && result !== null && "reason" in result - && !("payload" in result) - && !("createdAtMs" in result) + && (!("payload" in result) || result.payload === undefined) + && (!("createdAtMs" in result) || result.createdAtMs === undefined) ); } @@ -136,8 +129,8 @@ export function isRedisWatermarkMiss(result: unknown): result is RedisWatermarkM && result !== null && "kind" in result && result.kind === "watermark_miss" - && !("payload" in result) - && !("createdAtMs" in result); + && (!("payload" in result) || result.payload === undefined) + && (!("createdAtMs" in result) || result.createdAtMs === undefined); } interface RedisValueRequest { diff --git a/test/dialcache-metrics.test.ts b/test/dialcache-metrics.test.ts index 3eea35a..b649536 100644 --- a/test/dialcache-metrics.test.ts +++ b/test/dialcache-metrics.test.ts @@ -427,6 +427,43 @@ describe("DialCache observability metrics", () => { } }); + it.each(["value_absent", "watermark_fenced"] as const)( + "recognizes a custom %s miss with explicitly undefined hit fields", + async (reason) => { + const trackForInvalidation = reason === "watermark_fenced"; + const result = { + reason, + ...(trackForInvalidation ? { + kind: "watermark_miss", + observedWatermarkMs: Date.now() + 60_000, + } : {}), + payload: undefined, + createdAtMs: undefined, + } as unknown as RedisReadResult; + const metrics = new RecordingMetrics(); + const redis: DialCacheRedisClient = { + read: vi.fn(async () => result), + write: vi.fn(async () => undefined), + invalidate: vi.fn(async () => undefined), + }; + const useCase = "ExplicitlyUndefinedMissFields"; + const dialcache = new DialCache({ metrics, redis: { client: redis, readTimeoutMs: 1_000 } }); + const getUser = dialcache.cached(async () => "fallback", { + keyType: "user_id", + useCase, + cacheKey: () => "123", + trackForInvalidation, + defaultConfig: remoteOnly(), + }); + + await expect(dialcache.enable(async () => await getUser())).resolves.toBe("fallback"); + + expect(events(metrics, "miss", { useCase, layer: CacheLayer.REMOTE, reason })).toHaveLength(1); + expect(events(metrics, "error", { useCase })).toHaveLength(0); + expect(redis.write).toHaveBeenCalledTimes(trackForInvalidation ? 0 : 1); + }, + ); + it("classifies a tracked FakeRedis frame fenced by its observed watermark", async () => { const metrics = new RecordingMetrics(); const redis = new FakeRedis(); diff --git a/test/dialcache-redis.test.ts b/test/dialcache-redis.test.ts index 69ce98e..64b618b 100644 --- a/test/dialcache-redis.test.ts +++ b/test/dialcache-redis.test.ts @@ -184,7 +184,7 @@ describe("DialCache Redis TTL layer", () => { `${key.urn}:dialcache-frame-v1`, encodeFrame(JSON.stringify({ source: "redis" }), nowMs + 1_250), ); - redis.setRaw(`${key.prefix}#watermark`, "0"); + redis.setRaw(`${key.prefix}#watermark`, String(nowMs + 500)); const observeFutureTimestampOffset = vi.fn(); const metrics = metricsWithFutureTimestampObserver(observeFutureTimestampOffset); const serializer: Serializer<{ readonly source: string }> = { @@ -214,7 +214,8 @@ describe("DialCache Redis TTL layer", () => { expect(serializer.load).not.toHaveBeenCalled(); expect(fallback).toHaveBeenCalledOnce(); - expect(write).toHaveBeenCalledWith(expect.objectContaining({ createdAtMs: nowMs })); + expect(write).toHaveBeenCalledOnce(); + expect(write.mock.calls[0]?.[0]).not.toHaveProperty("createdAtMs"); expect(observeFutureTimestampOffset).toHaveBeenCalledOnce(); expect(observeFutureTimestampOffset).toHaveBeenCalledWith( { @@ -859,10 +860,19 @@ describe("DialCache Redis TTL layer", () => { ); }); - it("records the same payload encoding label for malformed FakeRedis frames", async () => { + it.each([ + { trackForInvalidation: false, unsafeTimestamp: false }, + { trackForInvalidation: false, unsafeTimestamp: true }, + { trackForInvalidation: true, unsafeTimestamp: false }, + { trackForInvalidation: true, unsafeTimestamp: true }, + ])("preserves encoding errors without refilling (tracked=$trackForInvalidation, unsafe=$unsafeTimestamp)", async ({ trackForInvalidation, unsafeTimestamp }) => { const redis = new FakeRedis(); - const redisKey = redisKeyFor("123", "RedisFakeBadPayloadEncoding"); - redis.setRaw(redisKey, encodeFrame({ userId: "123", source: "stale" }, Date.now(), 2)); + const key = keyFor("123", "RedisFakeBadPayloadEncoding", trackForInvalidation); + const createdAtMs = unsafeTimestamp ? Number.MAX_SAFE_INTEGER + 1 : Date.now(); + redis.setRaw(`${key.urn}:dialcache-frame-v1`, encodeFrame({ userId: "123", source: "stale" }, createdAtMs, 2)); + if (trackForInvalidation) { + redis.setRaw(`${key.prefix}#watermark`, "0"); + } const logger = { debug: vi.fn(), warn: vi.fn(), error: vi.fn() }; const metrics = { request: vi.fn(), @@ -881,6 +891,7 @@ describe("DialCache Redis TTL layer", () => { keyType: "user_id", useCase: "RedisFakeBadPayloadEncoding", cacheKey: (userId) => userId, + trackForInvalidation, defaultConfig: new DialCacheKeyConfig({ ttlSec: { [CacheLayer.REMOTE]: 60 }, ramp: { [CacheLayer.REMOTE]: 100 }, @@ -891,7 +902,9 @@ describe("DialCache Redis TTL layer", () => { expect(value).toEqual({ userId: "123", calls: 1 }); expect(calls).toBe(1); - expect(redis.getCalls).toBe(1); + expect(redis.getCalls + redis.mGetCalls).toBe(1); + expect(redis.setCalls).toBe(0); + expect(metrics.miss).not.toHaveBeenCalled(); expect(metrics.error).toHaveBeenCalledWith({ cacheNamespace: "urn", useCase: "RedisFakeBadPayloadEncoding", diff --git a/test/dialcache-shadow-validation.test.ts b/test/dialcache-shadow-validation.test.ts index 93e344d..6f261a1 100644 --- a/test/dialcache-shadow-validation.test.ts +++ b/test/dialcache-shadow-validation.test.ts @@ -397,6 +397,50 @@ describe("DialCache Redis shadow validation", () => { } }); + it("preserves ordinary shadow fills after a tracked frame is rejected as future-dated", async () => { + const nowMs = 1_700_000_000_000; + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(nowMs); + try { + const redis = new FakeRedis(); + const write = vi.spyOn(redis, "write"); + const metrics = new RecordingMetrics(); + const miss = vi.spyOn(metrics, "miss"); + const useCase = "ShadowFutureFrameOrdinaryRefill"; + const key = seedRedis(redis, { + id: "123", + useCase, + payload: JSON.stringify({ source: "redis" }), + createdAtMs: nowMs + 2_000, + }); + redis.setRaw(`${key.prefix}#watermark`, String(nowMs + 1_000)); + const dialcache = createShadowCache(redis, metrics); + const getUser = dialcache.cached(async () => ({ source: "fallback" }), { + keyType: "user_id", + useCase, + cacheKey: () => "123", + trackForInvalidation: true, + defaultConfig: new DialCacheKeyConfig({ + ttlSec: { [CacheLayer.REMOTE]: 60 }, + ramp: { [CacheLayer.REMOTE]: 0 }, + shadow: { ramp: 100 }, + }), + }); + + await expect(dialcache.enable(async () => await getUser())).resolves.toEqual({ source: "fallback" }); + await waitForShadowEvents(metrics, 1); + + expect(metrics.shadowEvents.map(({ outcome }) => outcome)).toEqual(["filled"]); + expect(miss).toHaveBeenCalledWith(expect.objectContaining({ + layer: "remote_shadow", + reason: "unclassified", + })); + expect(write).toHaveBeenCalledOnce(); + expect(write.mock.calls[0]?.[0]).not.toHaveProperty("createdAtMs"); + } finally { + nowSpy.mockRestore(); + } + }); + it("rejects a non-finite untracked timestamp before shadow validation", async () => { const redis = new FakeRedis(); const metrics = new RecordingMetrics(); diff --git a/test/dialcache-stale-on-error.test.ts b/test/dialcache-stale-on-error.test.ts index b888c79..f9f08c1 100644 --- a/test/dialcache-stale-on-error.test.ts +++ b/test/dialcache-stale-on-error.test.ts @@ -330,13 +330,10 @@ describe("DialCache stale-on-error recovery", () => { expect(staleRecovery).not.toHaveBeenCalled(); }); - it.each([ - { candidateOffsetMs: 0, expectedWrite: false }, - { candidateOffsetMs: 1, expectedWrite: true }, - ])( - "keeps a retained miss reason independent when its refill candidate is watermark + $candidateOffsetMs ms", - async ({ candidateOffsetMs, expectedWrite }) => { - const useCase = `StaleRecoveryRetainedRefillFence${candidateOffsetMs}`; + it.each([0, 1])( + "preserves ordinary retained-frame refills at watermark + %s ms", + async (candidateOffsetMs) => { + const useCase = `StaleRecoveryRetainedRefill${candidateOffsetMs}`; const initialNowMs = Date.now(); const observedWatermarkMs = initialNowMs - 3_000; const candidateAtMs = observedWatermarkMs + candidateOffsetMs; @@ -346,6 +343,7 @@ describe("DialCache stale-on-error recovery", () => { return sourceValue; }); const redis = new RecordingRedis(); + const write = vi.spyOn(redis, "write"); const { metrics } = recordingMetrics(); const serializer: Serializer = { dump: vi.fn(async (value) => JSON.stringify(value)), @@ -381,11 +379,10 @@ describe("DialCache stale-on-error recovery", () => { reason: "unclassified", }); expect(serializer.load).not.toHaveBeenCalled(); - expect(serializer.dump).toHaveBeenCalledTimes(expectedWrite ? 1 : 0); - expect(redis.setCalls).toBe(expectedWrite ? 1 : 0); - if (expectedWrite) { - expect(decodeFrame(redis.raw(redisValueKey(useCase, "123", true))).createdAtMs).toBe(candidateAtMs); - } + expect(serializer.dump).toHaveBeenCalledOnce(); + expect(redis.setCalls).toBe(1); + expect(write.mock.calls[0]?.[0]).not.toHaveProperty("createdAtMs"); + expect(decodeFrame(redis.raw(redisValueKey(useCase, "123", true))).createdAtMs).toBe(candidateAtMs); }, ); diff --git a/test/node-redis.test.ts b/test/node-redis.test.ts index a900a11..17af311 100644 --- a/test/node-redis.test.ts +++ b/test/node-redis.test.ts @@ -115,7 +115,6 @@ describe("node-redis adapter", () => { ).resolves.toEqual({ payload: Buffer.from([0, 0xff]), createdAtMs: 2, - observedWatermarkMs: 1, }); await expect( adapter.write({ valueKey: "plain:value", cacheTtlMs: 1_000, value: "plain" }), @@ -337,7 +336,7 @@ describe("node-redis adapter", () => { await expect(adapter.read( { valueKey: "tracked:{id}:value", watermarkKey: "tracked:{id}:watermark" }, { timeoutMs: 25, signal: controller.signal }, - )).resolves.toEqual({ payload: "tracked", createdAtMs: 2, observedWatermarkMs: 1 }); + )).resolves.toEqual({ payload: "tracked", createdAtMs: 2 }); expect(client.sendCommand).toHaveBeenCalledWith( "tracked:{id}:value", @@ -359,7 +358,7 @@ describe("node-redis adapter", () => { await expect(adapter.read({ valueKey: "tracked:{id}:value", watermarkKey: "tracked:{id}:watermark", - })).resolves.toEqual({ payload: "tracked", createdAtMs: 2, observedWatermarkMs: 1 }); + })).resolves.toEqual({ payload: "tracked", createdAtMs: 2 }); expect(client.sendCommand).toHaveBeenCalledWith( ["MGET", "tracked:{id}:value", "tracked:{id}:watermark"], diff --git a/test/redis-payload.test.ts b/test/redis-payload.test.ts index a246744..e95e797 100644 --- a/test/redis-payload.test.ts +++ b/test/redis-payload.test.ts @@ -125,13 +125,44 @@ describe("Redis frame decoding", () => { } }); - it("returns eligible tracked frames through the typed decoder", () => { - expect( - decodeTrackedRedisReadResult( - encodeFrame("cached", 0, 1_001), - Buffer.from("1000"), - ), - ).toEqual({ payload: "cached", createdAtMs: 1_001, observedWatermarkMs: 1_000 }); + it("keeps hit shapes identical through classified and legacy decoders", () => { + const frame = encodeFrame("cached", 0, 1_001); + const decoded = { payload: "cached", createdAtMs: 1_001 }; + + expect(decodeRedisReadResult(frame)).toEqual(decoded); + expect(decodeRedisFrame(frame)).toEqual(decoded); + for (const watermark of [null, Buffer.from("1000")]) { + expect(decodeTrackedRedisReadResult(frame, watermark)).toEqual(decoded); + expect(decodeTrackedRedisFrame(frame, watermark)).toEqual(decoded); + } + }); + + it("preserves unsafe timestamps for core validation after decoding the payload", () => { + const createdAtMs = Number.MAX_SAFE_INTEGER + 1; + const frame = encodeFrame("cached", 0, createdAtMs); + const decoded = { payload: "cached", createdAtMs }; + + expect(decodeRedisReadResult(frame)).toEqual(decoded); + expect(decodeRedisFrame(frame)).toEqual(decoded); + for (const watermark of [null, Buffer.from("1000")]) { + expect(decodeTrackedRedisReadResult(frame, watermark)).toEqual(decoded); + expect(decodeTrackedRedisFrame(frame, watermark)).toEqual(decoded); + } + }); + + it("preserves payload encoding errors for frames with unsafe timestamps", () => { + const frame = encodeFrame("cached", 2, Number.MAX_SAFE_INTEGER + 1); + + expect(() => decodeRedisReadResult(frame)).toThrow(DialCacheRedisPayloadEncodingError); + expect(() => decodeRedisFrame(frame)).toThrow(DialCacheRedisPayloadEncodingError); + for (const watermark of [null, Buffer.from("1000")]) { + expect(() => decodeTrackedRedisReadResult(frame, watermark)).toThrow( + DialCacheRedisPayloadEncodingError, + ); + expect(() => decodeTrackedRedisFrame(frame, watermark)).toThrow( + DialCacheRedisPayloadEncodingError, + ); + } }); it("treats a missing watermark as the zero baseline", () => { diff --git a/test/valkey-glide.test.ts b/test/valkey-glide.test.ts index a40cd17..91f9ab4 100644 --- a/test/valkey-glide.test.ts +++ b/test/valkey-glide.test.ts @@ -151,7 +151,6 @@ describe("Valkey GLIDE adapter", () => { ).resolves.toEqual({ payload: Buffer.from([0, 0xff]), createdAtMs: 1_000, - observedWatermarkMs: 0, }); await expect(adapter.read({ valueKey: "missing:value" })).resolves.toEqual({ reason: "value_absent", @@ -212,7 +211,6 @@ describe("Valkey GLIDE adapter", () => { ).resolves.toEqual({ payload: "tracked-cluster", createdAtMs: 1_000, - observedWatermarkMs: 0, }); expect(client.customCommand).toHaveBeenCalledWith(