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:
|
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:
|
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:
|
- 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
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: explicitinvalidateRemote()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 proposedinvalidateRemoteMany(); 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()validatesfutureBufferMsand then returns a fulfilled promise when theDialCacheinstance 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
DialCache/src/dialcache.ts
Lines 404 to 409 in e06d833
DialCache/src/dialcache.ts
Lines 411 to 425 in e06d833
DialCache/README.md
Lines 79 to 85 in e06d833
DialCache/test/dialcache-local.test.ts
Lines 82 to 100 in e06d833
Simplest scope
redisoptional for normal local-only caching.invalidateRemote()reject with a focused configuration error when Redis is absent.TypeError; do not add a public error class solely for this case.error="invalidation"classification when metrics are configured.futureBufferMsvalidation and every configured-Redis behavior.allowNoopInvalidationflag, a second invalidation method, local invalidation, or automatic Redis construction.Acceptance criteria
new DialCache().invalidateRemote(...)rejects and cannot be mistaken for a written watermark.futureBufferMsvalues remain rejected before any Redis mutation.invalidateRemote()requires a configured Redis client.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