feat: add stale-on-error Redis recovery - #121
Conversation
## Summary Replace read-side Lua with native Redis commands and decode DialCache's frame in TypeScript: - untracked reads use `GET` - tracked reads use one atomic, primary-routed `MGET` for the value and watermark - write and invalidation remain Lua-backed; a watermark-fenced tracked write now atomically unlinks the stale value it rejects - node-redis registers only the three mutation scripts, and GLIDE owns only the three mutation script handles - custom adapters can reuse the public `decodeRedisFrame` and `decodeTrackedRedisFrame` helpers This removes the Redis-to-Lua payload materialization and `string.sub` copy on every hit while preserving the semantic `DialCacheRedisClient.read()` boundary. ## Read architecture | Adapter / mode | Untracked | Tracked | Primary guarantee | | --- | --- | --- | --- | | node-redis standalone | `GET` | `MGET` | standalone connection | | node-redis Cluster | `GET` | raw `MGET` | `sendCommand(..., false, ...)` routes to the slot primary | | GLIDE standalone | `GET` | one-command `Batch(false).mget(...)` | standalone batches execute on the primary even with replica reads configured; `MGET` itself is atomic | | GLIDE Cluster | `GET` | custom-command `MGET` | explicit `primarySlotKey` route | The shared decoder: - validates the frame version and minimum length - preserves missing/short/unsupported frames as clean misses - parses integer and fractional legacy watermarks with the same accepted grammar as Lua - rejects values whose Redis-created timestamp is at or before the watermark - preserves unsupported payload encodings as `DialCacheRedisPayloadEncodingError` - returns binary payloads through a zero-copy `Buffer.subarray()` view Tracked value and watermark reads retain one atomic snapshot, with both values returned by a single `MGET`. Their existing shared Cluster hash tag remains required; mismatched tags still fail with `CROSSSLOT`. ## Breaking change - `READ_CACHE_SCRIPT` and `READ_TRACKED_CACHE_SCRIPT` are removed from `dialcache/redis-protocol`. - `dialcacheRedisScripts.dialcacheRead` and `dialcacheRedisScripts.dialcacheReadTracked` are removed from `dialcache/node-redis`. - Custom node-redis wrappers must expose native `get` / `sendCommand`; `legacyMode` clients are unsupported because neither their callback surface nor `.v4` view exposes the complete native-command-plus-custom-script contract. - The GLIDE helper requires GLIDE 2.x, a direct official `GlideClient` or `GlideClusterClient`, and the same module namespace that created it. Forwarding wrappers should implement `DialCacheRedisClient` directly because their topology cannot be inferred safely. - Official node-redis clients and direct GLIDE 2.x clients passed through the documented helpers keep the same application-facing call shape, so those consumers can bump the package without code changes. - Redis keys, frame format, and invalidation behavior are unchanged. A tracked write rejected by an active future watermark still returns `false`, but now also unlinks the stale value key. No data migration or cache flush is required. - The fenced-write cleanup requires `UNLINK` (Redis 4.0+ or compatible Valkey) and permission for scripts to invoke it. With a command-restricted ACL that denies `UNLINK`, the write fails open as `cache_write` and leaves the stale value for a later cleanup or expiry. `BREAKING CHANGE:` the four deprecated read-Lua exports and registrations above are removed; node-redis adapters require the promise-mode native-command surface; the GLIDE helper requires a direct GLIDE 2.x client from the supplied runtime; and the fenced-write cleanup requires Redis `UNLINK` support plus ACL permission. Under the repository's release configuration, this change should release as `v1.0.0`. ## Adapter behavior changes - The node-redis factory now requires native `get` and `sendCommand` methods in addition to the three registered mutation methods. - The GLIDE factory declares an optional `@valkey/valkey-glide ^2.0.0` peer, validates `Batch` support eagerly, and classifies standalone versus cluster behavior from the supplied runtime's client identities before allocating scripts. Its standalone non-atomic primary batch avoids consuming caller-owned `WATCH` state. - Redis `MGET` returns `null` for wrong-type members. A tracked wrong-type value is therefore a clean miss and may be repaired with a valid DialCache frame after fallback succeeds, while a wrong-type watermark prevents the tracked write from succeeding. An untracked `GET` still surfaces `WRONGTYPE`. Real-engine tests cover both repair and repeated fail-open behavior, including metrics. - The public read contract now specifies frame decoding, miss and watermark rules, atomic authoritative snapshots, and returned-buffer ownership. Shared decoders validate leaf reply types; adapters retain only client-specific envelope validation. ## Benchmark The benchmark harness and JSON results were intentionally kept outside the repository. Methodology: - Redis 6.2.22 and Valkey 8.1.8 - Node 22.22.0, node-redis 4.7.1, GLIDE 2.4.2 - binary payloads of 100 B, 1 KiB, 10 KiB, 100 KiB, and 1 MiB - fresh untracked hit, fresh tracked hit, and invalidated tracked miss - three alternating rounds, one command in flight, loopback Docker - median throughput, latency, Redis `INFO commandstats` execution time, and network bytes At 1 MiB, native fresh-hit throughput improved 15-45% across the two engines and adapters. Server-reported command execution time per logical read fell 95-98%. Small 100 B / 1 KiB end-to-end results were mostly flat/noisy while reported command time still fell about 80-90%; the notable small-case regression was Redis/node-redis's 100 B tracked hit at about -10% throughput. These loopback, one-in-flight results are directional rather than production-capacity measurements. Representative Redis 6.2 + node-redis medians: | 1 MiB scenario | Lua ops/s | Native ops/s | Lua server us/read | Native server us/read | Lua -> native p50 | | --- | ---: | ---: | ---: | ---: | ---: | | untracked hit | 230 | 269 | 719.8 | 32.6 | 3.718 ms -> 2.955 ms | | tracked hit | 217 | 259 | 713.7 | 31.2 | 3.630 ms -> 3.016 ms | | invalidated tracked miss | 1,762 | 284 | 361.6 | 31.0 | 0.566 ms -> 2.949 ms | The invalidated-miss row is the main tradeoff: Lua returns only a null reply, while native `MGET` transfers the stale frame before TypeScript rejects it. At 1 MiB this changes roughly 3-5 response bytes into about 1.05 MB. Across both engines and adapters, invalidated-miss throughput fell 77-84% at 1 MiB (46-58% at 100 KiB), even though server-reported command time still fell 91-94%. The benchmark intentionally measured the read itself and therefore includes that full transfer. In the application path, the first completed fallback that reaches a still-fenced tracked write now atomically unlinks the stale value, bounding subsequent transfers for that entry. This is only a partial mitigation: a read failure or timeout never reaches the write-side cleanup, so the stale payload can continue to transfer or time out until another completed read cleans it up or its TTL expires. ## Scope This branch is updated onto the current `v0.15.0` read contract, including the untracked-cache shadowing changes from #122. It deliberately does not include the server-time / maximum-age behavior proposed in #121. That work can be evaluated separately against this read path and its benchmark tradeoffs. ## Validation - `corepack pnpm typecheck` - `corepack pnpm test` - 424 tests, coverage thresholds passed - `corepack pnpm build` - `corepack pnpm test:package` - including real node-redis and GLIDE standalone and Cluster consumer types, plus packed ESM/CommonJS absence checks for all four removed APIs - `corepack pnpm test:integration` - 113 tests across Redis 6.2, Valkey 8, and Redis Cluster - tracked wrong-type value repair and repeated wrong-type watermark fail-open behavior exercised end to end across both adapters and both standalone engines - stale tracked frames exercise the real decoder and record a remote miss, request/get/fallback timing, and no read error across both adapters and both standalone engines - fenced tracked writes prove stale-value unlinking while preserving the exact watermark and its TTL trajectory - cluster `SCRIPT FLUSH` recovery proves mutation scripts repopulate every master and a subsequent identical read is a cache hit - GLIDE package tests compile against the supported 2.0.0 floor and exercise separate module instances plus packed ESM/CommonJS error identity - focused GLIDE primary/replica probe and three-node Cluster probe - `git diff --check`
a2f55ce to
388027c
Compare
bd122cb to
439335c
Compare
lan17
left a comment
There was a problem hiding this comment.
Deep review: stale-on-error recovery
Verdict: core semantics are sound; nothing here blocks the serving path. The F/M boundary math (strict 0 <= age < limit, shared by serving, recovery, and shadow C0 through the single validateFrameAge), recovery isolation (recovered values provably never reach Redis, the local LRU, or shadow — the writers at dialcache.ts:819/1065/1277 are all unreachable from the recovery return), original-rejection preservation, coalescing of the full read/source/recovery chain, exactly-one-staleRecovery-outcome accounting, and the tracked reread's watermark fencing all held up under adversarial verification. Typecheck and all 588 unit tests pass on the branch.
15 findings as inline comments — 4 medium, 11 low. The mediums cluster on observability and validation depth, not data correctness:
- silent age-gate rejections (
redis-cache.ts:474) — the only frame-rejection class with zero diagnostic; - README:452's "telemetry remains unchanged" vs the intentional double-emission into
layer="remote"; Mnever validated against the tracked 1h cap (runtime-config.ts:137) — per-writetracked_ttl_clampederror noise plus a silently truncated recovery window;- the unreachable pre-metric throw in
recoverWithResolvedConfig(redis-cache.ts:167) — the one zero-outcome recovery exit.
Verified non-issues (they look like bugs but aren't)
- Dropping
finishRedisChain'sresolvedRemoteConfigparameter removed dead code: on main, theremoteErrored ? resolvedRemoteConfigarm could never execute — the call sites that passed the parameter only ever received hit/miss/error results. - Invalid-stamp frames classifying as recovery-eligible
cache_missis consistent with the documented taxonomy (README:612 groups timestamp-domain with watermark/future-time rejections); the excluded "invalid reply/encoding" class is thrown protocol errors, which already bypass recovery via thestatus: "error"arm. - The recovery read's
inFallback: falseon errors is correct per README:950's cache-plumbing-versus-application definition.
Below-the-cut minors (verified, not inlined)
- The three benchmark scripts now share five copy-pasted helpers with no common module:
deferred,readPositiveInteger,noOpMetrics(already drifted — the new copy has five members the old lacks), the cmdstat parse loop, and the connect boilerplate. Ascripts/benchmark-lib.mjsstops the drift. measureScenario'sredisparameter has exactly one possible argument (every call site passes the module-level client), and four of its five per-command counters are stored but never read.safeMetricswrapsstaleRecoveryin the 6-line presence-preserving spread copied fromshadowValidation, but nothing gates on its presence (sole consumer ismetrics.staleRecovery?.(...)); the one-linecallObserver(() => metrics.staleRecovery?.(labels))already used forcompressionin the same literal is behavior-identical.
Methodology: 9 independent finder angles → 20 adversarial verifiers (one per deduped candidate; 3 candidates refuted and withheld) → gap sweep. Two findings were validated by implementation: the reason-enum alternative to skipStaleRecovery compiles clean and passes all stale-on-error tests, flipping that failure mode from fail-open to fail-closed.
|
Follow-up on the below-the-cut I will take the local simplifications, including the unused |
439335c to
9ae561d
Compare
BREAKING CHANGE: Ordinary Redis reads now treat every decoded frame createdAtMs as serving-authoritative. Custom Redis clients must return real epoch-millisecond writer timestamps, and deployments must roll out new readers before enabling physical M retention.
9ae561d to
aa9365f
Compare
Summary
Add opt-in stale-on-error recovery from the existing Redis value, rebased onto the native client-clock Redis design from #140.
F = ttlSec[CacheLayer.REMOTE]remains the logical fresh age.M = staleOnErrorMaxAgeSecis the absolute recovery-age ceiling.0 <= age < F.0 <= age < M.Closes #117
Configuration
F = ttlSec[CacheLayer.REMOTE]is the logical fresh lifetime.M = staleOnErrorMaxAgeSecis the absolute recovery-age ceiling, not an additional duration afterF. Ordinary serving requires0 <= age < F; recovery requires0 <= age < M. A positive policy must satisfy0 < F < M <= 31,536,000seconds.In a non-null runtime provider overlay, omitting
staleOnErrorMaxAgeSecinherits the value fromdefaultConfig; if neither supplies a positive value, recovery is off.0explicitly disables an inherited policy.DialCacheKeyConfig.disabled()supplies that explicit zero as part of its complete kill-switch overlay.Like TTL and ramp leaves, the
DialCacheKeyConfigconstructor preserves this value for later policy resolution rather than range-validating it immediately. StaticdefaultConfigvalidation occurs whencached()is called, before the use case is registered, and on eachgetOrLoad()invocation. Invalid static combinations throw. Runtime overlays are validated per invocation; an invalid runtimeMrecordsconfig_resolution, disables only stale recovery for that invocation, and preserves an otherwise-valid fresh Redis policy.When
Mis positive, subsequent ordinary and shadow-fill Redis writes request physical retention throughM; otherwise they requestF. Untracked values receive that requested TTL. Tracked values retain the existing one-hour physical cap from #140 and emittracked_ttl_clampedwhen the request exceeds it.Mremains an eligibility ceiling rather than a retention guarantee: invalidation, eviction, expiry, and the tracked cap can remove or fence a value sooner.Changing policy does not mutate TTLs already stored in Redis. Enabling
Mlengthens only later successful writes. Disabling it stops recovery for later invocations and makes later writes requestF, but previously retained keys are not proactively shortened; current readers still hide them after logical ageF.Public API changes
DialCacheKeyConfigadds the optional readonlystaleOnErrorMaxAgeSecconstructor field.DialCacheKeyConfig.disabled()now setsstaleOnErrorMaxAgeSec: 0so a runtime kill-switch overlay cannot inherit stale recovery.StaleRecoveryOutcomeandStaleRecoveryMetricLabels.DialCacheMetricsAdapteradds the optionalstaleRecovery(labels)callback. Its bounded outcomes areserved,miss,read_error,read_timeout, anddeserialization_error; its labels arecacheNamespace,useCase,keyType, andoutcome.dialcache_stale_recovery_counterin Prometheus (subject to the configured prefix) anddialcache.stale_recovery.countin Datadog (subject to the configured namespace).DialCachemethod, Redis SPI method/type shape, Redis key, frame version, or wire encoding is added. The deliberate runtime contract change forcreatedAtMsis described under Compatibility and rollout.Execution
A semantic miss is a null result from the semantic Redis client—such as a missing, short, unsupported-version, malformed-watermark, or watermark-fenced frame—or a frame rejected by core because its timestamp is invalid, logically expired, or future-dated. A valid-age frame whose payload cannot be loaded after decompression handling is a distinct deserialization miss. Invalid native reply types, unsupported frame payload encodings, client rejections, and read deadlines are read failures.
Only an initial semantic miss can lead to stale recovery. A successful source call after either kind of miss attempts a normal Redis fill; a successful source call after a read failure does not, because the initial read did not establish a safe Redis state. Every failed recovery path preserves the exact original source rejection, including arbitrary rejection values and
FallbackTimeoutError.The invocation's once-resolved
F,M, and remote-read deadline govern the complete chain. Recovery receives a new independentremoteReadTimeoutMsbudget. Existing request and process coalescing share the complete initial-read/source/recovery chain.Redis and clock behavior
The command and adapter shapes remain native and unchanged:
GET;MGET(value, watermark);SETof a complete frame;DialCacheRedisClient, andRedisReadRequestare unchanged.The bundled adapters stamp each write with the writer process's
Date.now(). After a read settles, core samples the reader process'sDate.now()once and applies the strict logical-age boundary to every serving frame, tracked or untracked. Ordinary and initial-shadow reads useF; recovery usesM. The non-serving shadowC1confirmation intentionally bypasses logical age solely to determine whether the original payload bytes were superseded.Future-dated serving frames fail closed before deserialization. Because logical-age enforcement now covers untracked frames, the existing
observeFutureTimestampOffsethook and built-in future-offset metrics may newly receive untrackedlayer="remote"observations, including from recovery reads. A shadow confirmation may retain a future-dated frame only for payload comparison and can never serve it.DialCache does not call Redis
TIMEor estimate clock offsets. Cross-node clock offset can move logical expiry early or late, so synchronized and monitored application-node clocks remain an operational requirement. Durations and deadlines continue to use the monotonic clock.Failure behavior and observability
staleRecoveryoutcome:served,miss,read_error,read_timeout, ordeserialization_error.layer="remote"telemetry, adding a second request/get observation. A semantic or deserialization miss adds a second miss; a transport/protocol error or timeout adds the corresponding cache-read error. Request-derived hit-rate and Redis-QPS panels should separate this recovery population usingstaleRecoveryoutcomes.Compatibility and rollout
The configuration and metrics additions are optional and additive at the TypeScript surface; existing custom metrics adapters continue to compile. The Redis SPI shape, Redis keys, frame version, and wire format are unchanged.
The runtime serving contract is deliberately breaking even when stale recovery is omitted or set to
0: ordinary reads now treat every decoded frame'screatedAtMsas authoritative and enforce logicalF, including untracked reads. Custom Redis clients that return a constant timestamp must migrate to the frame's real epoch-millisecond writer timestamp before upgrading. A lowered runtimeFtherefore affects existing frames immediately, and reader/writer clock skew can make logical expiry early or late.The stale-retention-specific mixed-version hazard begins once positive
Maffects physical writes. An older reader does not enforce logicalF, so it can serve a physically retained frame as fresh betweenFandM.staleOnErrorMaxAgeSecomitted or0.Monly for selected use cases.Disabling
Mon current readers is safe because they continue to enforceF, but it does not shorten keys already retained under the earlier policy. Do not reintroduce an older reader until the longest physical retention actually written for affected keys has elapsed, or those keys have been isolated or removed.Cost model
GETorMGETonly) and only local age arithmetic. Native reads transfer a logically stale retained frame before core filters it by age.SET; only its requested physical TTL may be longer.GETorMGET, including a second transfer of the retained frame when it is still present.Validation
F/Mand equality boundaries, future frames, runtime overlays, source failures/timeouts, invalidation races, coalescing, request-local behavior, compression, shadow confirmation, metrics, and physical retention.BREAKING CHANGE: Ordinary Redis reads now enforce logical age from each frame's
createdAtMs, including untracked reads. Custom Redis clients must return real epoch-millisecond writer timestamps, and deployments must roll out new readers before enabling physicalMretention.