Skip to content

Enforce per-use-case Redis read deadlines and fail open without a second Redis operation #38

Description

@lan17

Priority

P1 — High — bound Redis read latency and avoid outage amplification before broad production adoption.

Confirmed decision

As of 2026-07-23, DialCache will enforce a caller-visible Redis read wait deadline in the core library.

  • Every DialCache instance configured with Redis must receive an application-selected finite RedisConfig.readTimeoutMs.
  • Each cached use case may override that default with static CachedOptions.redisReadTimeoutMs.
  • The core deadline bounds how long DialCache waits for DialCacheRedisClient.read() before failing open to the source fallback.
  • The deadline is a wait/publication boundary, not a claim that an already queued or dispatched Redis command was canceled.
  • Redis clients must still use finite native connection, retry, reconnect, offline-queue, dispatch, and response budgets to bound underlying resource lifetime.

This replaces the earlier ownership decision that all Redis deadlines remain entirely caller-client-owned. Client-native budgets remain required, but DialCache now also enforces the per-use-case wait bound visible to its callers.

Problem

DialCache fail-open behavior is currently rejection-driven. A Redis read promise that never settles prevents the source fallback from starting and retains the process flight and its followers.

After an untracked Redis read rejects, DialCache currently runs the fallback and then attempts a Redis write. During an outage, one invocation can therefore pay two failing Redis operations and emit two warnings. A timeout implementation that retained this behavior would immediately expose the request to the same unhealthy client again.

Current v0.8.0 evidence at 7e5ca9a17eaa72adb9d34a1813742928e0d4793d:

  • Caller-owned Redis boundary with no library deadline:
    /**
    * Caller-owned semantic Redis boundary. DialCache borrows this client and does
    * not create, connect, drain, dispose, or close it.
    *
    * Every method must settle within a finite application-defined deadline that
    * includes connection, retry, offline-queue, dispatch, and response time.
    * DialCache does not add Redis deadlines or server-side cancellation. A
    * command that times out after dispatch may still have executed, so adapters
    * must document their queue-removal and ambiguous-write semantics.
    */
    export interface DialCacheRedisClient {
    /** Atomically read and validate a value against its watermark when tracked. */
    read(request: RedisReadRequest): Awaitable<RedisCachePayload | null>;
    /** Atomically write using server time. False means invalidation blocked the write. */
    write(request: RedisWriteRequest): Awaitable<boolean>;
    /** Advance the watermark monotonically after the source mutation commits. */
    invalidate(request: RedisInvalidationRequest): Awaitable<void>;
  • Remote read and fail-open conversion:

    DialCache/src/dialcache.ts

    Lines 550 to 568 in 7e5ca9a

    private async readRemoteWithResolvedConfig<T>(redisCache: RedisCache, key: DialCacheKey, layerConfig: ResolvedLayerConfig) {
    const start = performance.now();
    try {
    const result = await redisCache.getWithResolvedConfig<T>(key, layerConfig);
    this.metrics?.request(labelsFor(key, CacheLayer.REMOTE));
    this.metrics?.observeGet(labelsFor(key, CacheLayer.REMOTE), elapsedSeconds(start));
    if (result.status === "miss") {
    this.metrics?.miss(labelsFor(key, CacheLayer.REMOTE));
    }
    return result;
    } catch (error) {
    this.logger.warn("Error getting value from Redis cache", error);
    return {
    status: "disabled",
    reason: "config_error",
    ...(key.trackForInvalidation ? { skipCacheWrite: true } : {}),
    } as const;
    }
    }
  • Fallback followed by the possible Redis write:

    DialCache/src/dialcache.ts

    Lines 456 to 489 in 7e5ca9a

    private async finishRedisChain<T>(
    redisCache: RedisCache,
    key: DialCacheKey,
    local: CacheGetResult<T>,
    remote: CacheGetResult<T>,
    fallback: () => Promise<T>,
    resolvedRemoteConfig?: ResolvedLayerConfig,
    ): Promise<T> {
    if (remote.status === "hit") {
    if (local.status === "miss") {
    await this.putLocalFailOpen(key, remote.value, local.config);
    }
    return remote.value;
    }
    const remoteErrored = remote.status === "disabled" && remote.reason === "config_error";
    const remoteWriteConfig = remote.status === "miss" ? remote.config : remoteErrored ? resolvedRemoteConfig : undefined;
    const fallbackLayer = remote.status === "miss" || remoteErrored ? CacheLayer.REMOTE : CacheLayer.LOCAL;
    const value = await this.callFallback(labelsFor(key, fallbackLayer), fallback);
    const skipCacheWrite = (remote.status === "miss" || remote.status === "disabled") && remote.skipCacheWrite === true;
    let suppressCacheWrite = skipCacheWrite;
    if (!suppressCacheWrite && remoteWriteConfig !== undefined) {
    try {
    const wroteRemote = await redisCache.put(key, value, remoteWriteConfig);
    suppressCacheWrite = wroteRemote === false;
    } catch (error) {
    this.logger.warn("Error putting value in Redis cache", error);
    suppressCacheWrite = key.trackForInvalidation;
    }
    }
    if (!suppressCacheWrite && local.status === "miss") {
    await this.putLocalFailOpen(key, value, local.config);
    }
    return value;
  • Explicit regression proving fallbackTimeoutMs does not bound a pending Redis read:
    it("does not apply the fallback deadline to a pending Redis read", async () => {
    const readGate = deferred<null>();
    const readStarted = deferred<void>();
    const fallback = vi.fn(async () => "value");
    const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
    const redis: DialCacheRedisClient = {
    read: async () => {
    readStarted.resolve();
    return await readGate.promise;
    },
    write: async () => true,
    invalidate: async () => undefined,
    };
    const dialcache = new DialCache({ redis: { client: redis } });
    const load = dialcache.cached(fallback, {
    keyType: "id",
    useCase: "PendingRedisReadHasCallerOwnedDeadline",
    cacheKey: () => "123",
    fallbackTimeoutMs: 5,
    defaultConfig: remoteConfig,
    });
    const result = dialcache.enable(async () => await load());
    await readStarted.promise;
    await vi.advanceTimersByTimeAsync(100);
    expect(fallback).not.toHaveBeenCalled();
    expect(setTimeoutSpy).not.toHaveBeenCalled();
    expect(dialcache.getCoalescingState().process.activeLeaders).toBe(1);
    readGate.resolve(null);
    await expect(result).resolves.toBe("value");
    expect(fallback).toHaveBeenCalledTimes(1);
    expect(dialcache.getCoalescingState().process.activeLeaders).toBe(0);
    });

Public contract

const dialcache = new DialCache({
  redis: {
    client,
    readTimeoutMs: 200, // required application-selected default
  },
});

const getUser = dialcache.cached(loadUser, {
  keyType: "user_id",
  useCase: "GetUser",
  cacheKey: (id) => id,
  redisReadTimeoutMs: 50, // optional per-use-case override
  fallbackTimeoutMs: 2_000,
});

Contract rules:

  • RedisConfig.readTimeoutMs is required whenever Redis is configured.
  • CachedOptions.redisReadTimeoutMs is optional; omission inherits the instance value.
  • Both values must be positive safe integers no greater than 2,147,483,647.
  • There is no null or unbounded escape hatch. Applications choose the value; DialCache does not invent a universal millisecond default.
  • The per-use-case value is captured when cached() registers the wrapper, like fallbackTimeoutMs. It is not request input, runtime DialCacheKeyConfig, or cache-key identity.
  • Validate the option before reserving the use-case name so a corrected registration can retry.
  • Requiring the instance value is a deliberate pre-1.0 compatibility change and needs an explicit migration note.

Deadline semantics

  • Start the deadline immediately before invoking the semantic DialCacheRedisClient.read() after the remote layer is enabled and the shared leader reaches Redis.
  • The measured boundary includes the semantic client promise, including its queue/reconnect/retry/response wait and adapter-level DialCache payload decoding.
  • It does not include key construction, config-provider resolution, ramp sampling, request/process-local reads, user serializer load, fallback execution, Redis writes, or invalidation.
  • Allocate one timer per actual shared Redis read leader. Cache followers allocate no timer and inherit the leader's remaining read budget.
  • Local hits and disabled/ramped-out remote layers allocate no Redis read timer.
  • Use the existing monotonic deadline behavior: performance.now(), delayed-settlement checks, early-timer re-arming, deterministic cleanup, and a referenced timer so timeout delivery is not silently skipped when it is the only active handle.
  • If the deadline expires, abort the optional cooperative signal first, deliver one stable timeout failure to the cache chain, and attach fulfillment/rejection handlers to the underlying read so late settlement is consumed.
  • A late Redis hit is ignored. It cannot populate local cache or suppress the source fallback.
  • A late Redis rejection produces no unhandled rejection and no second DialCache log/metric event.
  • fallbackTimeoutMs starts separately only if and when the source fallback begins. The total invocation may therefore include the Redis read budget plus the fallback budget.

Fail-open and publication behavior

Represent an operational remote-read failure explicitly instead of overloading status="disabled", reason="config_error".

For a Redis read timeout or any other Redis read exception:

  1. Record/log one remote cache_read failure for the shared leader.
  2. Run the configured source fallback.
  3. Skip the post-fallback Redis write for both tracked and untracked keys.
  4. For an untracked key, allow process-local population when the local layer was an active miss.
  5. For a tracked key, suppress process-local population because the failed/timed-out tracked read did not establish invalidation-watermark safety.
  6. Let a later invocation start a fresh Redis read and recover naturally.

A normal Redis miss still runs the fallback and writes the result according to the existing policy. A serializer-load rejection remains a refreshable miss and may replace the rejected payload; it is not a Redis read timeout.

Adapter and cancellation contract

Add an optional read context to the semantic boundary, tentatively:

export interface RedisReadContext {
  readonly timeoutMs: number;
  readonly signal: AbortSignal;
}

read(
  request: RedisReadRequest,
  context?: RedisReadContext,
): Awaitable<RedisCachePayload | null>;
  • DialCache always enforces its own wait deadline even when a custom adapter ignores the context.
  • The bundled node-redis adapter should pass the signal through its per-command options so queued work can be removed where node-redis supports it.
  • Aborting after dispatch does not unsend the command or prove that the server stopped executing it.
  • The current Valkey GLIDE invokeScript API has no per-invocation timeout/signal option. The adapter preserves the caller-created client's requestTimeout; the core deadline may return earlier while GLIDE continues until its client-level budget settles.
  • Custom adapters may use the context for cooperative cancellation or a resource-native timeout, but must document queued/dispatched-command behavior.

Observability

  • Add and export RedisReadTimeoutError with stable useCase and timeoutMs fields and no raw cache key.
  • Timeouts remain classified by the existing bounded metrics failure site: error="cache_read", in_fallback="false", layer="remote".
  • Do not add timeout values, exception names, messages, IDs, arguments, or Redis keys to metric labels.
  • Emit one warning/error metric per timed-out shared leader; followers and late settlement do not duplicate it.
  • getCoalescingState() continues to describe DialCache's foreground process flights. Client-native telemetry owns visibility into underlying reads that outlive the core wait deadline.

Implementation plan

1. Public API and validation

  • Add required readTimeoutMs to RedisConfig.
  • Add optional redisReadTimeoutMs to CachedOptions and resolve it once at wrapper registration.
  • Add/export RedisReadContext and RedisReadTimeoutError from the root package declarations and ESM/CJS bundles.
  • Add exact runtime validation and compile-time/package-consumer coverage.
  • Update release notes with the required Redis configuration migration.

2. Shared monotonic deadline primitive

  • Extract the existing fallback timer machinery into a small internal deadline helper rather than duplicating timer code.
  • Preserve the tested fallback semantics unchanged.
  • Support an optional timeout callback used to abort a Redis read signal.
  • Consume late fulfillment/rejection and guarantee timer cleanup.

3. Remote-read integration and result modeling

  • Apply the helper directly around DialCacheRedisClient.read() in the remote cache boundary.
  • Keep the deadline outside config/ramp/local/serializer/fallback/write/invalidation work.
  • Introduce an explicit internal remote-read-error result and remove the current config_error overloading.
  • Ensure the timeout continues through the normal source-fallback path rather than escaping to the caller when fallback succeeds.

4. Failure-aware publication

  • Skip every post-fallback Redis write after a read exception or timeout.
  • Preserve process-local population for untracked active local misses.
  • Suppress process-local publication for tracked read failures/timeouts.
  • Preserve normal miss, serializer-refresh, tracked-write fencing, and later recovery behavior.

5. Bundled adapters

  • Pass the cooperative signal to node-redis read and tracked-read script command options.
  • Verify queue-abort and post-dispatch limitations without claiming server-side cancellation.
  • Keep GLIDE's caller-owned requestTimeout and Script lifecycle unchanged; document that its current script invocation cannot consume the per-read signal.
  • Prove custom one-argument DialCacheRedisClient.read implementations remain structurally usable because the context is optional.

6. Documentation and observability

  • Update the README option table, Redis examples, async-liveness contract, coalescing behavior, and metrics/error documentation.
  • Distinguish the core wait deadline from client resource deadlines and server-side cancellation.
  • Explain that the read deadline plus fallbackTimeoutMs still does not create a whole-call or Redis-write deadline.

7. Validation and performance

  • Run typecheck, unit/coverage, build/declarations, packed ESM/CJS consumers, integration tests, and production dependency audit.
  • Benchmark one timer/clear pair per remote read leader and verify followers allocate none.
  • Re-run real standalone Redis, Valkey, and Redis Cluster integration coverage.

Required validation matrix

  • Instance default and per-use-case override resolve correctly.
  • Missing, zero, negative, fractional, nonnumeric, unsafe, and over-limit values fail before use-case reservation.
  • Remote hit and miss before the deadline preserve existing behavior and clean up the timer.
  • A never-settling read times out, starts one fallback, and clears the process flight after the fallback settles.
  • Same-key process and request-local followers share one read deadline and one fallback.
  • A follower joining late receives only the leader's remaining read/fallback lifetime.
  • A late Redis hit is ignored; a late rejection is consumed without duplicate observability.
  • A retry after timeout starts a fresh Redis read and can recover.
  • Timeout/error skips the Redis write for tracked and untracked keys.
  • Untracked local population is allowed; tracked local population is suppressed.
  • Normal misses and serializer-load refreshes still write Redis.
  • Node-redis receives the cooperative signal; abort limitations are documented and tested.
  • GLIDE continues to use its caller-owned request timeout and adapter lifecycle safely.
  • Local hits, disabled context, and disabled/ramped-out remote layers allocate no read timer.
  • Metrics remain bounded and contain no raw keys or timeout values.
  • No timeout timer or late operation creates an unhandled rejection.

Out of scope

  • Redis write, invalidation, serializer, config-provider, ramp-sampler, or whole-call deadlines.
  • Guaranteed cancellation of arbitrary promises or already-dispatched Redis commands.
  • Per-invocation deadlines or caller cancellation that could let one request control a shared leader.
  • Runtime-configured/key-dependent deadline values.
  • Circuit breakers, half-open probes, cooldown state, internal retry/backoff, reconnect policy, or load shedding.
  • Library-owned log sampling/rate limiting.
  • Cross-process coordination from Design optional cross-process cache stampede protection #7.

Related shipped scope

Acceptance criteria

  • Every Redis-enabled instance has an explicit finite core read timeout, with static per-use-case overrides.
  • DialCache starts the source fallback after the configured Redis read wait expires.
  • Timed-out/failed reads never trigger a second Redis operation in the same invocation.
  • Late read settlement cannot affect cache publication or create duplicate logs, metrics, or unhandled rejections.
  • Tracked and untracked local-publication behavior follows the safety rules above.
  • Same-key followers share the leader's deadline and source fallback.
  • Bundled adapters use cooperative cancellation where supported without claiming server-side cancellation.
  • Client-native finite budgets remain an explicit requirement for underlying resource cleanup.
  • Existing fallback, invalidation, serialization-refresh, coalescing, metrics-cardinality, package, and integration contracts remain covered.
  • The compatibility change and migration are documented for the release.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions