Skip to content

feat: add stale-on-error Redis recovery - #121

Draft
lan17 wants to merge 1 commit into
mainfrom
agent/stale-on-error
Draft

feat: add stale-on-error Redis recovery#121
lan17 wants to merge 1 commit into
mainfrom
agent/stale-on-error

Conversation

@lan17

@lan17 lan17 commented Aug 2, 2026

Copy link
Copy Markdown
Owner

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 = staleOnErrorMaxAgeSec is the absolute recovery-age ceiling.
  • Ordinary serving reads require 0 <= age < F.
  • After a definitive ordinary Redis miss and source rejection, one bounded reread may serve 0 <= age < M.
  • Recovered data is never republished, stored in process-local cache, or sent through shadow validation.

Closes #117

Configuration

new DialCacheKeyConfig({
  ttlSec: { [CacheLayer.REMOTE]: 300 },
  staleOnErrorMaxAgeSec: 3_600,
});

F = ttlSec[CacheLayer.REMOTE] is the logical fresh lifetime. M = staleOnErrorMaxAgeSec is the absolute recovery-age ceiling, not an additional duration after F. Ordinary serving requires 0 <= age < F; recovery requires 0 <= age < M. A positive policy must satisfy 0 < F < M <= 31,536,000 seconds.

In a non-null runtime provider overlay, omitting staleOnErrorMaxAgeSec inherits the value from defaultConfig; if neither supplies a positive value, recovery is off. 0 explicitly disables an inherited policy. DialCacheKeyConfig.disabled() supplies that explicit zero as part of its complete kill-switch overlay.

Like TTL and ramp leaves, the DialCacheKeyConfig constructor preserves this value for later policy resolution rather than range-validating it immediately. Static defaultConfig validation occurs when cached() is called, before the use case is registered, and on each getOrLoad() invocation. Invalid static combinations throw. Runtime overlays are validated per invocation; an invalid runtime M records config_resolution, disables only stale recovery for that invocation, and preserves an otherwise-valid fresh Redis policy.

When M is positive, subsequent ordinary and shadow-fill Redis writes request physical retention through M; otherwise they request F. Untracked values receive that requested TTL. Tracked values retain the existing one-hour physical cap from #140 and emit tracked_ttl_clamped when the request exceeds it. M remains 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 M lengthens only later successful writes. Disabling it stops recovery for later invocations and makes later writes request F, but previously retained keys are not proactively shortened; current readers still hide them after logical age F.

Public API changes

  • DialCacheKeyConfig adds the optional readonly staleOnErrorMaxAgeSec constructor field.
  • DialCacheKeyConfig.disabled() now sets staleOnErrorMaxAgeSec: 0 so a runtime kill-switch overlay cannot inherit stale recovery.
  • The package root exports StaleRecoveryOutcome and StaleRecoveryMetricLabels.
  • DialCacheMetricsAdapter adds the optional staleRecovery(labels) callback. Its bounded outcomes are served, miss, read_error, read_timeout, and deserialization_error; its labels are cacheNamespace, useCase, keyType, and outcome.
  • The built-in adapters expose dialcache_stale_recovery_counter in Prometheus (subject to the configured prefix) and dialcache.stale_recovery.count in Datadog (subject to the configured namespace).
  • No DialCache method, Redis SPI method/type shape, Redis key, frame version, or wire encoding is added. The deliberate runtime contract change for createdAtMs is described under Compatibility and rollout.

Execution

flowchart TD
  A["Initial native GET or MGET; core validates age against F"] -->|fresh hit| B["Return fresh value"]
  A -->|semantic miss| C["Call source; recovery eligible if it rejects"]
  A -->|deserialization or decompression miss| D["Call source; recovery forbidden"]
  A -->|transport, protocol, or read timeout| E["Call source; recovery forbidden"]

  C -->|fulfills| F["Attempt Redis fill using the resolved retention policy; return source value"]
  D -->|fulfills| F
  E -->|fulfills| G["Return source value; do not write Redis"]

  C -->|rejects| H["One bounded native reread; core validates age against M"]
  D -->|rejects| I["Throw the identical source rejection"]
  E -->|rejects| I

  H -->|eligible and payload loads| J["Return retained value without publication"]
  H -->|semantic miss, load miss, transport or protocol error, or timeout| I
Loading

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 independent remoteReadTimeoutMs budget. 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:

  • untracked reads use one native GET;
  • tracked reads use one primary-routed atomic MGET(value, watermark);
  • writes use one native SET of a complete frame;
  • invalidation remains the only Lua path;
  • frame v1, Redis keys, watermarks, DialCacheRedisClient, and RedisReadRequest are unchanged.

The bundled adapters stamp each write with the writer process's Date.now(). After a read settles, core samples the reader process's Date.now() once and applies the strict logical-age boundary to every serving frame, tracked or untracked. Ordinary and initial-shadow reads use F; recovery uses M. The non-serving shadow C1 confirmation 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 observeFutureTimestampOffset hook and built-in future-offset metrics may newly receive untracked layer="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 TIME or 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

  • Initial Redis read errors, protocol failures, timeouts, and payload-load/deserialization misses never trigger recovery. Missing, short, unsupported-version, logically expired, future-dated, and watermark-fenced frames are semantic misses and may qualify.
  • A recovery failure never replaces the original source rejection.
  • Tracked recovery rereads the value and watermark atomically, so an invalidation during the source attempt can fence the retained frame.
  • Recovery does not refresh Redis, populate process-local cache, or schedule shadow validation. Request-local memoization may retain a recovered result only within the active request scope.
  • Each attempted recovery emits one optional bounded staleRecovery outcome: served, miss, read_error, read_timeout, or deserialization_error.
  • The reread also traverses ordinary 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 using staleRecovery outcomes.
  • Prometheus and Datadog expose the bounded recovery counter. Existing fallback error and duration telemetry continues to record the source rejection even when recovery succeeds.

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's createdAtMs as authoritative and enforce logical F, 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 runtime F therefore 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 M affects physical writes. An older reader does not enforce logical F, so it can serve a physically retained frame as fresh between F and M.

  1. Deploy the new version with staleOnErrorMaxAgeSec omitted or 0.
  2. Upgrade the complete reader fleet.
  3. Enable positive M only for selected use cases.
  4. Monitor source failures, recovery outcomes, Redis memory and evictions, and application-node clock health.

Disabling M on current readers is safe because they continue to enforce F, 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

  • Fresh hit or ordinary miss: unchanged Redis command count (GET or MGET only) and only local age arithmetic. Native reads transfer a logically stale retained frame before core filters it by age.
  • Successful source refresh: unchanged one native SET; only its requested physical TTL may be longer.
  • Qualifying source rejection: at most one additional native GET or MGET, including a second transfer of the retained frame when it is still present.
  • The main operational cost is longer resident payload lifetime and the resulting memory, expiration, and eviction pressure—not Lua or Redis clock calls.

Validation

  • Node 22.22.0: typecheck, 585 unit tests with 98.11% statement coverage, build, and packed ESM/CJS/TypeScript consumers.
  • 135 live Redis integration tests passed; 2 GLIDE Cluster cases skipped after the expected unavailable-cluster connection timeout.
  • Strict F/M and equality boundaries, future frames, runtime overlays, source failures/timeouts, invalidation races, coalescing, request-local behavior, compression, shadow confirmation, metrics, and physical retention.
  • The isolated live stale-on-error benchmark passed its semantic assertions and reported the expected one native read per fresh/retained operation and two reads per recovery operation.

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 physical M retention.

lan17 added a commit that referenced this pull request Aug 7, 2026
## 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`
@lan17
lan17 force-pushed the agent/stale-on-error branch from a2f55ce to 388027c Compare August 19, 2026 23:35
@lan17
lan17 force-pushed the agent/stale-on-error branch from bd122cb to 439335c Compare August 25, 2026 06:27

@lan17 lan17 left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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";
  • M never validated against the tracked 1h cap (runtime-config.ts:137) — per-write tracked_ttl_clamped error 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's resolvedRemoteConfig parameter removed dead code: on main, the remoteErrored ? resolvedRemoteConfig arm 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_miss is 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 the status: "error" arm.
  • The recovery read's inFallback: false on 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. A scripts/benchmark-lib.mjs stops the drift.
  • measureScenario's redis parameter 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.
  • safeMetrics wraps staleRecovery in the 6-line presence-preserving spread copied from shadowValidation, but nothing gates on its presence (sole consumer is metrics.staleRecovery?.(...)); the one-line callObserver(() => metrics.staleRecovery?.(labels)) already used for compression in 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.

Comment thread src/internal/redis-cache.ts
Comment thread README.md Outdated
Comment thread src/internal/runtime-config.ts
Comment thread src/internal/redis-cache.ts Outdated
Comment thread src/internal/redis-cache.ts Outdated
Comment thread README.md Outdated
Comment thread scripts/benchmark-stale-on-error.mjs
Comment thread scripts/benchmark-stale-on-error.mjs Outdated
Comment thread test/dialcache-stale-on-error.test.ts
Comment thread test/dialcache-stale-on-error.test.ts Outdated
@lan17

lan17 commented Aug 25, 2026

Copy link
Copy Markdown
Owner Author

Follow-up on the below-the-cut scripts/benchmark-lib.mjs suggestion: I am not extracting a shared benchmark module in this PR. The overlap is pairwise rather than coherent across all three scripts (deferred and environment parsing in one pair; Redis connection and command-stat parsing in another), and their options and metrics semantics differ. A module now would create an abstraction around coincidences.

I will take the local simplifications, including the unused measureScenario parameter and counters. Cross-script extraction can wait until there is a stable common surface.

@lan17
lan17 force-pushed the agent/stale-on-error branch from 439335c to 9ae561d Compare August 25, 2026 19:51
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.
@lan17
lan17 force-pushed the agent/stale-on-error branch from 9ae561d to aa9365f Compare August 26, 2026 00:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add opt-in stale-on-error recovery from retained Redis values

1 participant