Skip to content

Fail remote invalidation when Redis is not configured #98

Description

@lan17

Priority

P1 — High — explicit invalidation must not report success without writing a watermark.

v0.14.0 grooming verification (2026-08-01)

Verified on main@34bd022: explicit invalidateRemote() still fulfills without a Redis client. This is promoted to P1 because a successful maintenance call can falsely signal that the source-of-truth mutation is protected. Ready PR #114 carries the same silent no-op into proposed invalidateRemoteMany(); if that PR merges first, the fix and tests must cover both methods. The smallest fix remains rejection with an existing error shape—no new public class or configuration flag.

Historical reproduction

At v0.11.0 / e06d833ba245706a499d66056fdad15dc1210b68, invalidateRemote() validates futureBufferMs and then returns a fulfilled promise when the DialCache instance has no Redis client.

That no-op is covered by a local-cache unit test, but it is not part of the public invalidation documentation. The README instead states that invalidation failures are logged, counted, and rethrown so callers do not assume invalidation succeeded.

Problem

invalidateRemote() is an explicit maintenance operation whose successful settlement tells the mutation path that the Redis watermark was advanced. If a writer instance is accidentally or temporarily constructed without Redis, the method currently reports success without writing anything. In a heterogeneous or misconfigured fleet, other Redis-enabled instances can continue serving tracked values that the caller believed it invalidated.

The current behavior also emits no invalidation attempt, error metric, or warning, making the missing configuration difficult to detect. A local-only deployment does not benefit from calling invalidateRemote() because the method never invalidates request-local or process-local entries.

Pinned evidence

  • The method silently returns when Redis is absent:

    DialCache/src/dialcache.ts

    Lines 404 to 409 in e06d833

    async invalidateRemote(keyType: string, id: Id, futureBufferMs = 0): Promise<void> {
    assertValidFutureBufferMs(futureBufferMs);
    if (this.redisCache === null) {
    return;
    }
  • Redis-backed invalidation otherwise logs/counts and rethrows failures:

    DialCache/src/dialcache.ts

    Lines 411 to 425 in e06d833

    this.metrics?.invalidation({ cacheNamespace: this.namespace, keyType, layer: CacheLayer.REMOTE });
    try {
    await this.redisCache.invalidate(keyType, String(id), futureBufferMs, this.namespace);
    } catch (error) {
    this.logger.warn("Error writing DialCache invalidation watermark", error);
    this.metrics?.error({
    cacheNamespace: this.namespace,
    useCase: "watermark",
    keyType,
    layer: CacheLayer.REMOTE,
    error: "invalidation",
    inFallback: false,
    });
    throw error;
    }
  • The README says callers should not be allowed to assume failed invalidation succeeded:

    DialCache/README.md

    Lines 79 to 85 in e06d833

    - Request-local hits return the value memoized in the current outermost `enable()` scope.
    - Results from the lower chain are memoized request-locally when that layer is enabled.
    - Process-local hits return immediately.
    - Process-local misses try Redis and populate the process-local cache on a Redis hit.
    - Redis misses call the fallback and attempt to populate Redis and, when active, the process-local cache. Tracked invalidation may suppress both publications.
    - Redis read failures and timeouts are logged, counted in metrics, and fail open without attempting a second Redis operation. Redis write failures also fail open. `invalidateRemote` logs/counts Redis failures and rethrows them so callers do not assume invalidation succeeded.
    - Cache-key construction and config-provider failures also fail open and run the fallback uncached.
  • The current no-op is explicitly tested:
    it("supports local caching with metrics omitted and no-op invalidation without Redis", async () => {
    // Given metrics and Redis are both absent.
    const dialcache = new DialCache();
    let calls = 0;
    const getUser = dialcache.cached(async (userId: string) => ({ userId, calls: ++calls }), {
    keyType: "user_id",
    useCase: "MetricsDisabledNoRedis",
    cacheKey: (userId) => userId,
    defaultConfig: DialCacheKeyConfig.enabled(60),
    });
    // When local caching is used and targeted invalidation is requested without a Redis layer.
    const first = await dialcache.enable(async () => await getUser("123"));
    await dialcache.invalidateRemote("user_id", "123");
    const second = await dialcache.enable(async () => await getUser("123"));
    // Then no metrics adapter or Redis layer is required for the local path to work.
    expect(first).toEqual({ userId: "123", calls: 1 });
    expect(second).toEqual({ userId: "123", calls: 1 });

Simplest scope

  • Keep redis optional for normal local-only caching.
  • Make invalidateRemote() reject with a focused configuration error when Redis is absent.
  • Use an existing error shape such as TypeError; do not add a public error class solely for this case.
  • Record the attempted invalidation and the existing bounded error="invalidation" classification when metrics are configured.
  • Log through the existing safe logger boundary.
  • Preserve futureBufferMs validation and every configured-Redis behavior.
  • Do not add an allowNoopInvalidation flag, a second invalidation method, local invalidation, or automatic Redis construction.

Acceptance criteria

  • new DialCache().invalidateRemote(...) rejects and cannot be mistaken for a written watermark.
  • The rejection message clearly identifies missing Redis configuration.
  • The path emits one bounded invalidation error and cannot be disrupted by an injected logger or metrics adapter.
  • Invalid futureBufferMs values remain rejected before any Redis mutation.
  • With Redis configured, successful invalidations are unchanged and client failures are still logged, counted, and rethrown.
  • Local-only caching remains fully supported; only the explicit remote maintenance call changes.
  • README and public TSDoc state that invalidateRemote() requires a configured Redis client.
  • The former no-op test is replaced with focused rejection and observability coverage.
  • Typecheck, unit tests, build/declarations, packed consumers, and Redis integration tests remain green.

Compatibility

This is a pre-1.0 behavior change for applications that intentionally call invalidateRemote() on local-only instances. Those callers should skip the remote operation when their deployment intentionally has no Redis. No valid Redis-backed invalidation behavior or stored data changes.

Related issues

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