feat(metrics): classify cache misses by reason - #146
Conversation
86caf56 to
a0150bdCompare4cf8d28 to
068c0b5CompareAdd 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.
068c0b5 to
83e8a7cCompare
lan17
left a comment
There was a problem hiding this comment.
Verdict: request changes(recorded as a comment — GitHub refuses a requested-changes review on the author's own PR)
Reviewed at max effort (10 finder angles, adversarial verification, differential decode matrices up to 280 cells against main, plus a gap sweep). Typecheck and all 645 unit tests pass at 83e8a7c; the classification logic itself held up everywhere it was attacked: fence boundaries exact, zero-watermark/zero-timestamp edges probe-verified, no unbounded label can reach a metrics adapter, Prometheus re-registration fails loudly rather than mislabeling.
Requesting changes on three items. Each is cheap now and compounds after merge, because this PR freezes both the migration documentation and the public custom-adapter contract.
1. Align the docs with the fence extension (README.md:981, README.md:724, README.md:~485)
Threading observedWatermarkMs through decoded hit frames makes this more than a metrics migration: refills after core-side rejections (retained stale-on-error candidates, logical-age expiry, future-dated frames, deserialization failures) are now watermark-fenced — your own tests pin write suppression at candidate = watermark+0 on the retained path, fill_fenced where filled was emitted, and core-supplied createdAtMs on writes that never carried it. The behavior is good (verification showed every suppressed write would have been permanently unreadable, and the retained-path skip preserves the stale-on-error candidate the old refill destroyed) — but README line 981 still says this migration "does not change cache serving, refill, invalidation, stale recovery, or shadow-verdict behavior", which is now false, and issue #145 scoped behavior changes out. The PR body has been updated with a "Behavior changes" section; the README needs the matching fix:
- Rewrite the line-981 claim to scope it to what actually holds (serving verdicts, invalidation, stale-recovery classification, shadow match/mismatch/superseded verdicts).
- Line 724: "
createdAtMs <= watermarkiswatermark_fenced" needs the zero-timestamp carve-out (a complete frame stamped 0 under a valid watermark classifiesunclassified, probe-verified).
2. Give RedisReadMiss a discriminant (src/redis-client.ts:92)
RedisReadMiss has no kind, so isRedisReadMiss falls back to in-presence checks — reintroducing the exact hazard PR 143 added kind: "watermark_miss" to kill. Probe-verified: { reason: "value_absent", payload: undefined, createdAtMs: undefined } type-checks for strict consumers without exactOptionalPropertyTypes (tsc exit 0) and is misrouted as a frame at runtime, silently degrading every one of that adapter's declared reasons to unclassified — the feature defeated with no signal. Adding kind: "read_miss" pre-merge is free; after adapters adopt, it's another breaking change.
Stronger option worth 30 minutes while you're here: one discriminated miss type { kind; reason; observedWatermarkMs?: number }. That single change also collapses the twin constructors (redisReadMiss in redis-payload.ts:205 vs classifiedRedisReadMiss in redis-cache.ts:660), deletes the never-released-compat reason?: optionality (redis-client.ts:108 — no tag contains PR 143, so the optionality serves zero consumers) and its missReason() ceremony, and halves the 4× fence ternaries in getWithResolvedConfig.
3. Document the hit-shape widening (README Serialization section + migration list)
Bundled tracked hits now return { payload, createdAtMs, observedWatermarkMs } whenever a valid watermark exists (including 0). Your own exact-shape tests had to change (test/node-redis.test.ts, test/valkey-glide.test.ts) — consumers' will too. The README still describes DecodedRedisFrame as payload + createdAtMs only, and the migration list omits it. One sentence plus one bullet. (The PR body and BREAKING CHANGE footer now carry it.)
Non-blocking, comment-tier (take or leave; several vanish if you take the unified miss type):
- Corrupt-timestamp tracked frames are now silently reclassified at decode (redis-payload.ts:186): the legacy
decodeTrackedRedisFramereturnsnullwhere main returned a frame or threwDialCacheRedisPayloadEncodingError(18 differential cells), the doubly-corrupt class loses its alertable error signal, and tracked vs untracked decoders now disagree on the same corrupt frame. Document as intended hardening or align the two. unclassifiedFrameMiss(redis-cache.ts:676) trusts an adapter-attached hit fence with envelope-only validation; a one-lineobservedWatermarkMs < frame.createdAtMscross-check restores the pre-PR blast radius for contract-violating adapters.- Taxonomy coherence: local TTL expiry reports
value_absentwhile the same logical condition remotely reportsunclassified; and the internalRedisCacheMissReasonvs publicCacheMissReasonenums collide undocumented at the deser site (redis-cache.ts:184). Evidence-based and defensible — record the rationale in a comment/README clause so it reads as deliberate. validObservedWatermarkMs(redis-cache.ts:680) re-duplicates the safe-int≥0 envelope ofisValidRedisTimestampMs(same finding as PR 143'sisValidRedisWatermarkMiss, reincarnated) and runs twice per miss.isCacheMissReason(redis-cache.ts:686) has no exhaustiveness tie to the union — a future fourth reason compiles everywhere and silently demotes tounclassifiedat runtime.const CACHE_MISS_REASONS satisfies Record<CacheMissReason, true>beside the type fixes it.CacheMissReasonisn't exported from thedialcache/redis-protocolsubpath that exportsRedisReadMiss— even scripts/test-package.mjs has to root-import it. One re-export line.- Decode micro:
readFrameCreatedAtMsruns twice per served tracked frame;decodedRedisFramebuilds-then-spreads; the watermark-parse expression is duplicated in two branches (redis-payload.ts:139-158). - Both legacy decoders are now zero-consumer wrappers, and this diff grows
decodeTrackedRedisFramewith a strip projection. They were released in 0.22.0, so deleting is your policy call — thisfeat!is the natural moment.
Credit where due: the FakeRedis delegation to the real decoders kills the untracked-fence divergence from the PR 143 review, the fence-carry closes the core-side-rejection gap that PR 143 deliberately deferred, and the invalid-watermark normalization matrix and boundary tests are exactly the coverage this layer needs.
One revision pass and this is an easy approve.
lan17
left a comment
There was a problem hiding this comment.
Verdict: approve(recorded as a comment — GitHub refuses a formal review state on the author's own PR)
Re-reviewed at dc6199d (fix(metrics): preserve existing refill and decoder behavior). All three requested changes from the previous review are resolved; typecheck is clean, 653/653 unit tests pass, and a fresh sweep over the fix commit found nothing new.
Requested changes — all addressed
- Scope / README behavior claim — fixed by reversion. The fence-carry is fully removed (
DecodedRedisFrame.observedWatermarkMs,unclassifiedFrameMiss, the retained-path carry, and thevalidateFrameAgemiss objects are gone). A 216-cell decoder differential againstmainshows byte-parity on the legacydecodeRedisFrame/decodeTrackedRedisFramesurfaces and zero hit/fence/throw drift on the classified decoders (only the addedreasonlabels differ). The rewritten README claim — "Miss classification adds no new refill-fencing paths and preserves serving eligibility, invalidation, stale-recovery policy, and shadow outcomes" — is verified true: fencing occurs solely via PR 143's adapter-level watermark-miss path. This also moots the adapter-fence-trust hardening item, since frames no longer carry fences at all. RedisReadMissguard hazard — fixed via value-based guards. BothisRedisReadMissandisRedisWatermarkMissnow treat explicitly-undefinedpayload/createdAtMsas absent, closing the misclassification for the new miss type and the PR-143-era residual on the kinded miss; probe coverage landed in the unit tests and the packed-package script (including the@ts-expect-errorunbounded-reason negative). Guard value-reads are short-circuited behind thereason/kindpresence checks and every classified miss is canonicalized insidevalidateReadResult, so no new throw path escapes unrecorded.- Hit-shape documentation — moot and documented. The widened hit shape is deleted; the README now states explicitly that decoded hits keep
{ payload, createdAtMs }.
Also verified: the unsafe-timestamp reclassification is fully reverted (doubly-corrupt frames throw DialCacheRedisPayloadEncodingError again; only the zero-baseline check remains, matching main), and the zero-timestamp carve-out landed in both the invalidation section and the metrics-table watermark_fenced row. The PR body has been updated to match the reverted scope (behavior-parity statement, corrected test count, footer back to the two real breaks).
Deferred (non-blocking, unchanged from the previous review)
Twin miss constructors (redisReadMiss / classifiedRedisReadMiss), the duplicated safe-int≥0 timestamp envelope in validObservedWatermarkMs, reason?: optionality serving a never-released shape, the non-exhaustive isCacheMissReason (a future fourth reason silently demotes to unclassified), CacheMissReason missing from the dialcache/redis-protocol exports, the local-vs-remote expired-entry taxonomy note, decode micro-items (double readFrameCreatedAtMs, duplicated watermark-parse expression), and the legacy-decoder deletion policy call. Several of these collapse together if the miss union is ever unified into one kinded type.
Good ship. The guard fix in particular is stronger than what was asked — it hardens both miss types instead of one.
lan17
left a comment
There was a problem hiding this comment.
Follow-up guidance: post-merge cleanup plan(non-blocking — the approve verdict above stands; this consolidates the deferred findings from both review rounds into an actionable plan for a single refactor: PR before 0.23 ships)
Two review rounds across #143/#146 kept finding one failure pattern: a correctness-critical invariant hand-written in more than one module, already divergent at birth (the three isRedisWatermarkMiss copies in #143 differed within one commit; the safe-int≥0 timestamp envelope has now been written three times). The cleanup principle is therefore one owner per invariant — not "no repeated lines". Some repetition here is deliberate and load-bearing; the last section lists what to leave alone.
DRY batch (descending leverage)
- Unify the miss union into one kinded type —
{ kind, reason, observedWatermarkMs?: number }. Retires three deferred findings at once: the twin constructors (redisReadMissininternal/redis-payload.tsvsclassifiedRedisReadMissininternal/redis-cache.ts— every bundled miss is currently built twice per read), thereason?:optionality that exists only for source compatibility with a shape that never shipped (no release contains #143's reason-lessRedisWatermarkMiss, somissReason()'s undefined-handling at five sites serves nobody), and the compositeisRedisReadMissguard, which becomes a singlekindcheck. Free now — none of this surface is released; expensive after adapter adoption. ~1 hour. - Exhaustiveness-tie the reason guard —
isCacheMissReason(internal/redis-cache.ts) hand-lists the three strings with no compiler link toCacheMissReason. A future fourth reason compiles everywhere whilevalidateReadResultsilently demotes every adapter-supplied instance tounclassified— discovered in dashboards, not at build time. Fix:const CACHE_MISS_REASONS = { value_absent: true, watermark_fenced: true, unclassified: true } satisfies Record<CacheMissReason, true>beside the type, guard viaObject.hasOwn(the exact pattern the tests and test-package.mjs already use). - One owner for the timestamp envelope — export boolean
isValidRedisTimestampMsfrominternal/redis-payload.ts(widened tounknown) and definevalidObservedWatermarkMson top of it. Prevents the envelope forking between bundled decoders and core validation of custom-adapter fences (e.g., if an upper bound is ever added so a garbage far-future watermark can't fence writes for years). - Move
createdAtMsdefaulting into the protocol —encodeRedisFrame(payload, createdAtMs?)defaulting toDate.now()internally (or one exported resolver). Today the "honor supplied value exactly, else sample" rule is a ternary copy-pasted intonode-redis.tsandvalkey-glide.tsand prescribed as README prose every custom adapter must transcribe; the natural mis-spellingrequest.createdAtMs || Date.now()silently breaks the fence-decision/stored-frame coupling atcreatedAtMs: 0. Make the correct behavior the only expressible one. - Small fry, batch opportunistically: hoist the duplicated
watermarkFrame === null ? undefined : parseRedisWatermark(watermarkFrame) ?? undefinedexpression indecodeTrackedRedisReadResult; pass the already-readcreatedAtMsintodecodedRedisFrame(removes the doublereadBigUInt64BEper served tracked hit); merge the two adjacent miss literals ingetWithResolvedConfig; dedupe the README's twice-pasted "Conditional refill suppression reuses the existing trackedMGETresult..." sentence (network-shape §, keep; ACL §, drop). Tests: fold the ~52-line ESM/CJS classified-decode paste inscripts/test-package.mjsinto its existing shared-check mechanism (verifyPackedInvalidationprecedent), and extract the thrice-copied throwing-serializer fixture.
Not DRY, but higher value than most of the above
- Meter the caller-path fence skip —
putWithLayerstill has three unmeteredreturn falsepaths, so an active fence window is indistinguishable from a broken write path on dashboards (the same telemetry-gap shape as #141, which #143 fixed for the shadow side only). One newMetricErrorKind(fenced_refill_skipped— precedent: the advisorytracked_ttl_clamped) plus a typed'dispatched' | 'fenced' | 'abandoned'return also removes the hidden invariant the shadowfill_fencedternary depends on (sticky abandonment re-checked before the ternary). ExtendingMetricErrorKindis a type-surface change for exhaustive consumers — cheapest before 0.23, compounding after. - Decide the legacy-decoder question —
decodeRedisFrame/decodeTrackedRedisFrameare null-collapsing wrappers with zero in-repo consumers, kept for hypothetical external adapters; they drifted twice within this PR's own history before being re-pinned. Either delete them in thisfeat!window (they shipped in 0.22.0 but are unadopted; house policy is delete-don't-deprecate) or explicitly commit to the four-decoders-in-lockstep burden. Default-by-inaction is the worst option. - Two one-liners: re-export
CacheMissReasonfromdialcache/redis-protocol(it typesRedisReadMiss.reason, which IS exported there; test-package.mjs currently has to root-import it), and a cross-reference comment at the deser site + one README table clause recording that local TTL expiry →value_absentvs remote logical expiry →unclassifiedis a deliberate evidence-based choice (and that internalRedisCacheMissReasonis a disposition enum, not the metric reason) — so nobody "fixes" it later with another breaking metrics change. - Optional hardening: make
put's fence parameter a requiredRedisWatermarkMiss | undefinedso any future fill path must decide explicitly instead of silently bypassing the fence.
Deliberate repetition — do NOT DRY these
validateReadResult's reconstruction of adapter results is the single untrusted-input choke point, not redundant validation — keep it (with the union unification it gets simpler, not removed).- The per-adapter label spreads (
{...cacheLabels(labels), reason}in prometheus.ts/datadog.ts) are deliberate per-metric cardinality allowlists, consistent withdisabled/error; prom-client rejects unknown labels, so wholesale pass-through isn't viable. - FakeRedis's hand-rolled
encodeFrame/decodeFrametest fixtures exist to construct malformed frames the real encoder refuses — that independence is the point (its read path already delegates to the real decoders, which is the right split).
Suggested packaging: items 1–4 + the one-liners as one refactor(redis): single-owner invariants for miss classification PR, the fence-skip metric as its own small feat(metrics) (it extends a public union), and the legacy-decoder deletion as a standalone commit gated on the adoption decision — all before the 0.23 release so every type-shape change rides the same pre-adoption window. Total estimated effort excluding the decoder decision: about half a day.
Add `expired` to CacheMissReason. A complete, valid frame whose logical age against the reader clock reaches the effective remote TTL now reports `expired` on both the caller path (age >= M miss and the F..M stale-on-error retained candidate) and the shadow C0 path, instead of `unclassified`. `unclassified` is left for causes with no decisive evidence: legacy null, malformed frames or metadata, invalid or future timestamps, and deserialization failures. Without this, every expiry-driven remote miss under stale-on-error landed in `unclassified`, making it the routine steady-state reason rather than the anomaly signal the taxonomy documents. Shadow age rejections now return classified misses rather than reusing the legacy null. Only remote and remote_shadow tuples can carry the new value; request-local and local misses remain `value_absent`.
…misses
RedisReadMiss now declares `kind?: never` and `observedWatermarkMs?: never`.
Without them, `{ reason, observedWatermarkMs }` typechecked against the
RedisReadResult union (excess-property checks only reject fields absent from
every member) while core silently dropped the fence, so an adapter author who
omitted the discriminant got unfenced refills with no signal. main rejected
that literal; this restores the rejection and adds a packed-consumer
@ts-expect-error probe.
Restore the exactly-once miss assertions that the reason pins had replaced
in the future-dated and invalid-timestamp tests, and pin count and full
labels on the malformed-frame, untracked future-frame, and shadow
future-frame tests.
README: state that bundled adapters now return typed misses instead of
null, and scope "malformed watermark metadata is unclassified" to a present
frame (nil stays value_absent).Declaring `kind?: never` on RedisReadMiss made `"kind" in result` narrow a RedisReadResult to `RedisReadMiss | RedisWatermarkMiss` instead of RedisWatermarkMiss alone, because an optional property counts as possibly present. `result.kind === "watermark_miss"` is not an alternative on the full union since DecodedRedisFrame has no `kind`, so the `in` idiom is the one consumers rely on. `observedWatermarkMs?: never` alone already rejects a kind-less miss that carries a fence, so drop the `kind` declaration and pin the narrowing in the packed-consumer type check.
…contracts Assert exact labels, including reason, for the legacy-null dark miss (tracked and untracked) and the non-finite tracked frame on remote_shadow; both were count-only pins that let a reason drift survive the suite. README: scope the "expired never carries a fence" statement to bundled adapters and core-side rejections (a custom adapter may pair any reason with a valid fence, which DialCache honors); say that initial shadow reads emit `expired` too; document the RedisReadResult narrowing idioms now that both miss shapes declare `payload`, `createdAtMs`, and `observedWatermarkMs` as `never`; and record that the runtime miss guards treat explicitly undefined hit fields as absent.
RedisReadResult is now DecodedRedisFrame | RedisReadMiss, where
RedisReadMiss is { kind: "miss"; reason; observedWatermarkMs? }. Removed:
null as a legal result, RedisWatermarkMiss, the payload/createdAtMs/
observedWatermarkMs `never` sentinels, the composite runtime guards, the
twin miss constructors, missReason(), and the legacy decodeRedisFrame and
decodeTrackedRedisFrame wrappers. isRedisReadMiss is exported from the
root and dialcache/redis-protocol; redisReadMiss is the one constructor.
validateReadResult is the single trust boundary: a kind:"miss" object is
normalized (unknown reason -> unclassified; watermark_fenced without a
valid tracked fence -> unclassified; invalid or untracked fences dropped);
null, undefined, or any other non-object is an unclassified miss with a
normal refill; other objects remain frame candidates for the existing
timestamp and serializer checks. CACHE_MISS_REASONS is the single source
for the reason union and isValidRedisTimestampMs owns the timestamp
envelope.
Bundled decoders and core behave exactly as before; only the miss object
shape changed. Custom adapters return the typed miss; an un-migrated
adapter still fails open at runtime but loses reason precision and
fenced-refill suppression.
BREAKING CHANGE: RedisReadResult is DecodedRedisFrame | RedisReadMiss;
null, RedisWatermarkMiss, decodeRedisFrame, and decodeTrackedRedisFrame
are removed.Uh oh!
There was an error while loading. Please reload this page.
## Summary Small, behavior-preserving cleanup following the reviews on #146. Rebuilt on `main` after #146 collapsed the miss union, which already absorbed most of the original scope (one miss shape, one constructor, `CACHE_MISS_REASONS` driving the type, `isValidRedisTimestampMs` owning the timestamp envelope). What remains: - Move `isCacheMissReason` next to `CACHE_MISS_REASONS` in `src/metrics.ts` so the reason list has one owner; `RedisCache` imports it. - Use `isValidRedisTimestampMs` in `frameAge` instead of a hand-written copy of the safe-integer envelope. - Re-export `CacheMissReason` from `dialcache/redis-protocol`, where `RedisReadMiss` already lives; the packed consumer check asserts it is the root type. - Comment the internal `RedisCacheMissReason` disposition type so it is not mistaken for the metric reason. - Drop the README sentence duplicated between the network-shape and ACL paragraphs. - Unit tests pin the guard against prototype keys and non-strings, and the timestamp predicate against coercion. ## Scope and compatibility No behavior change: validation sites and their order are unchanged, and the miss taxonomy, read-result shape, refill fencing, metrics, and Redis commands are untouched. The only public addition is the type-only re-export. ## Validation - `pnpm typecheck` - 658 unit tests with coverage thresholds - `pnpm build` and `pnpm test:package`
Summary
Implements #145 (replaces the stale attempt in #130).
Every DialCache miss event now says why it missed. Until now, a miss storm during a future-buffer invalidation window was indistinguishable from a cold cache or an eviction problem: the miss counter only told you that a layer missed, never whether the value was truly absent or a stored frame was being rejected by an invalidation watermark. This PR adds one required, bounded
reasonto the existing miss event — no new metric instrument, no new Redis command, no extra round trip, and no change to serving, refill, invalidation, stale-recovery, or shadow behavior.What changes in your metrics
missevents now carryreasonalongside the existingcacheNamespace/useCase/keyType/layerlabels, on every layer (request_local,local,remote,remote_shadow):reasonvalue_absentnil, or a tracked-MGETwrong-type member (which Redis reports asnil). Request-local and process-local misses are always this.expiredF. Coversage >= Mmisses andF <= age < Mframes retained as stale-on-error candidates, on both caller and shadow reads.staleOnErrorMaxAgeSecis configured.watermark_fencedcreatedAtMswas at or below a valid observed invalidation watermark. Decided before deserialization.futureBufferMsinvalidation window; writer/invalidator clock skew.unclassifiednull), short/unsupported frames, zero-stamped tracked frames, malformed watermark metadata paired with a present frame, future or invalid timestamps, and deserialization failures.The operational win: during an invalidation window you can now separate fence churn from genuine absence at a glance; routine TTL churn (including the stale-on-error
F..Mwindow, where every read of a retained frame is a miss) isexpiredrather than noise in the other buckets; and because expiry no longer lands there, a suddenunclassifiedplateau genuinely points at protocol-edge states (skewed clocks, corrupt frames, deserialization failures, legacy adapters) instead of hiding inside the aggregate.Deviation from #145: the issue specified a three-value union and named
unclassifiedas the safe default for stale-on-error retained frames "unless a separately justified reason is added". Review found that withstaleOnErrorMaxAgeSecconfigured every read of a retainedF..Mframe is a miss, sounclassifiedwould have been the routine steady-state reason for exactly the use cases that enable stale-on-error, and the plateau signal above would not hold.expiredis that separately justified reason; the union is four values and the cardinality note below is 4x accordingly.Prometheus —
dialcache_miss_counterkeeps its name and gainsreasonas a fifth label:Datadog —
dialcache.miss.countkeeps its name and gains the samereasontag, e.g.sum:dialcache.miss.count{reason:watermark_fenced} by {use_case}. Expect up to 4× the miss-series cardinality (four bounded reasons; onlyremoteandremote_shadowtuples can carry every reason, since request-local and local misses are alwaysvalue_absent).Migration
dialcache_miss_counter(e.g. a not-yet-upgraded sidecar library) fails loudly at construction — upgrade producers together per registry. Nothing silently mislabels.reason. Total-miss and miss/request-ratio queries mustsum by (...)the shared labels (example above); reason-aware dashboards should group byreasonexplicitly.DialCacheMetricsAdapter.missnow receivesMissMetricLabels(extends the unchangedCacheMetricLabelswith requiredreason: CacheMissReason). Adapters whosemissparameter is typed as the broaderCacheMetricLabelscompile unchanged and may ignore the field; exact label snapshots, exhaustiveRecords over reasons, and adapters that reject or forward unknown fields must add it.RedisReadResultis nowDecodedRedisFrame | RedisReadMiss { kind: "miss", reason, observedWatermarkMs? }.null,RedisWatermarkMiss,decodeRedisFrame, anddecodeTrackedRedisFrameare removed; usedecodeRedisReadResult(untracked) /decodeTrackedRedisReadResult(tracked) and the exportedisRedisReadMissguard. An un-migrated adapter still fails open at runtime (nullor any unrecognized result is anunclassifiedmiss with a normal refill) but loses reason precision and fenced-refill suppression until it returns the typed miss.How classification works
Bundled decoders return one miss shape,
RedisReadMiss { kind: "miss", reason, observedWatermarkMs? }, attachingobservedWatermarkMsonly when the same atomic tracked snapshot carried a valid numeric watermark. Cause and fence are deliberately independent: Redisnilis decisive evidence of absence, so an absent value reportsvalue_absentwhile still carrying the observed watermark that lets PR 143's two-sample admission skip a known-fenced refill — only a complete supported frame actually rejected by the watermark reportswatermark_fenced.Core treats the adapter boundary as untrusted and normalizes every result once, at one choke point:
kind: "miss"is the only discriminator; invalid or missing reasons becomeunclassified, awatermark_fencedclaim without a valid tracked fence is demoted tounclassified, invalid fences (NaN, negatives, non-integers, untracked keys) are dropped, andnull,undefined, or any other unrecognized value is anunclassifiedmiss with a normal refill. Objects withoutkind: "miss"are frame candidates and go through the existing timestamp and serializer validation.Decoded hits keep the existing
{ payload, createdAtMs }shape. Core-side rejections never acquire a refill fence: logical-age rejections emitexpired, future timestamps and deserialization failures emitunclassified; conditional refill suppression remains exactly PR 143's adapter-level watermark-miss path, byte-compatible withmain(differential-verified across the decode input matrix).Breaking change
DialCacheMetricsAdapter.missnow receivesMissMetricLabels, and first-party miss metrics require areasonlabel/tag.RedisReadResultis nowDecodedRedisFrame | RedisReadMiss;null,RedisWatermarkMiss,decodeRedisFrame, anddecodeTrackedRedisFrameare removed, and bundled Redis adapters returnRedisReadMissfor every semantic miss.Under the pre-1.0 release policy, this is a minor release.
Validation
corepack pnpm checkcorepack pnpm test:integrationfix(metrics): preserve existing refill and decoder behavior) removed the fence-carry scope creep and restored decoder/refill parity withmain, differential-verified across the decode input matrixBREAKING CHANGE: DialCacheMetricsAdapter.miss now receives MissMetricLabels and first-party miss metrics require a reason label/tag; RedisReadResult is now DecodedRedisFrame | RedisReadMiss ({ kind: "miss", reason, observedWatermarkMs? }); null, RedisWatermarkMiss, decodeRedisFrame, and decodeTrackedRedisFrame are removed, and bundled Redis adapters return RedisReadMiss for every semantic miss.