diff --git a/README.md b/README.md index 7d6b4c1..2b8ec06 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ request-local cache -> process-local cache -> Redis cache -> fallback function - 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. - Selected tracked Redis keys can execute non-serving [shadow work](#shadow-validation) that validates hits and fills clean misses, even before Redis is allowed to serve callers. -- 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. +- 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` requires a configured Redis client; missing configuration and Redis failures are logged, counted, and rethrown so callers do not assume invalidation succeeded. - Cache-key construction and config-provider failures also fail open and run the fallback uncached. - A missing effective process-local/Redis TTL disables that layer by policy; a configured TTL with no ramp defaults to 100%. Disabled layers record a disabled reason and fall through to the next layer/fallback. @@ -577,7 +577,7 @@ Use a narrower copy when its semantics are sufficient; the ownership boundary is ## Targeted invalidation and watermarks -Mutable Redis-backed use cases can opt into targeted invalidation by setting `trackForInvalidation: true` in the options and calling `dialcache.invalidateRemote(keyType, id, futureBufferMs)` after writes. The buffer is an application-owned safety value; DialCache cannot choose a universally safe nonzero value: +Mutable Redis-backed use cases can opt into targeted invalidation by setting `trackForInvalidation: true` in the options and calling `dialcache.invalidateRemote(keyType, id, futureBufferMs)` after writes. `invalidateRemote` requires `DialCacheConfig.redis`; local-only caching remains supported, but this explicit remote maintenance operation rejects when Redis is absent. The buffer is an application-owned safety value; DialCache cannot choose a universally safe nonzero value: ```ts import { CacheLayer, DialCache, DialCacheKeyConfig } from "dialcache"; diff --git a/src/dialcache.ts b/src/dialcache.ts index 90f3eca..4c670c1 100644 --- a/src/dialcache.ts +++ b/src/dialcache.ts @@ -481,6 +481,10 @@ export class DialCache { /** * Writes a remote invalidation watermark for Redis-tracked entries. * + * Requires a Redis client in the DialCache configuration. If Redis is not + * configured, the call rejects rather than reporting an invalidation that + * did not occur. + * * This does not synchronously evict local cache hits or untracked Redis values. * Call it only after the source mutation commits. * @@ -522,12 +526,12 @@ export class DialCache { async invalidateRemote(keyType: string, id: Id, futureBufferMs = 0): Promise { assertSupportedFutureBufferMs(futureBufferMs); - if (this.redisCache === null) { - return; - } - this.metrics?.invalidation({ cacheNamespace: this.namespace, keyType, layer: CacheLayer.REMOTE }); try { + if (this.redisCache === null) { + throw new TypeError("DialCache invalidateRemote requires a configured Redis client"); + } + await this.redisCache.invalidate(keyType, String(id), futureBufferMs, this.namespace); } catch (error) { this.logger.warn("Error writing DialCache invalidation watermark", error); diff --git a/test/dialcache-invalidation.test.ts b/test/dialcache-invalidation.test.ts index dae7517..9bdb024 100644 --- a/test/dialcache-invalidation.test.ts +++ b/test/dialcache-invalidation.test.ts @@ -403,9 +403,81 @@ describe("DialCache targeted invalidation watermarks", () => { }); }); + it("rejects missing Redis configuration and records the invalidation failure", async () => { + const logger = { debug: vi.fn(), warn: vi.fn(), error: vi.fn() }; + const metrics = new RecordingMetrics(); + const dialcache = new DialCache({ logger, metrics }); + + const rejection = await dialcache.invalidateRemote("user_id", "123").catch((error: unknown) => error); + + expect(rejection).toBeInstanceOf(TypeError); + expect(rejection).toHaveProperty( + "message", + "DialCache invalidateRemote requires a configured Redis client", + ); + expect(logger.warn).toHaveBeenCalledOnce(); + expect(logger.warn).toHaveBeenCalledWith( + "Error writing DialCache invalidation watermark", + rejection, + ); + expect(metrics.events).toEqual([ + { + name: "invalidation", + labels: { cacheNamespace: "urn", keyType: "user_id", layer: CacheLayer.REMOTE }, + }, + { + name: "error", + labels: { + cacheNamespace: "urn", + useCase: "watermark", + keyType: "user_id", + layer: CacheLayer.REMOTE, + error: "invalidation", + inFallback: false, + }, + }, + ]); + }); + + it("preserves the missing Redis error when invalidation observers fail", async () => { + const observerError = new Error("observer failed"); + const logger = { + debug: vi.fn(), + error: vi.fn(), + warn: vi.fn(() => { + throw observerError; + }), + }; + const metrics = new RecordingMetrics(); + const invalidationMetric = vi.spyOn(metrics, "invalidation").mockImplementation(() => { + throw observerError; + }); + const errorMetric = vi.spyOn(metrics, "error").mockImplementation(() => { + throw observerError; + }); + const dialcache = new DialCache({ logger, metrics }); + + const rejection = await dialcache.invalidateRemote("user_id", "123").catch((error: unknown) => error); + + expect(rejection).toBeInstanceOf(TypeError); + expect(rejection).toHaveProperty( + "message", + "DialCache invalidateRemote requires a configured Redis client", + ); + expect(logger.warn).toHaveBeenCalledOnce(); + expect(invalidationMetric).toHaveBeenCalledOnce(); + expect(errorMetric).toHaveBeenCalledOnce(); + }); + it("rejects invalid future buffers before calling Redis", async () => { const redis = new FakeRedis(); - const dialcache = new DialCache({ redis: { client: redis, readTimeoutMs: 1_000 } }); + const logger = { debug: vi.fn(), warn: vi.fn(), error: vi.fn() }; + const metrics = new RecordingMetrics(); + const dialcache = new DialCache({ + redis: { client: redis, readTimeoutMs: 1_000 }, + logger, + metrics, + }); await expect(dialcache.invalidateRemote("user_id", "123", -1)).rejects.toThrow("futureBufferMs"); await expect(dialcache.invalidateRemote("user_id", "123", 1.5)).rejects.toThrow("futureBufferMs"); @@ -418,6 +490,8 @@ describe("DialCache targeted invalidation watermarks", () => { dialcache.invalidateRemote("user_id", "123", Number.MAX_SAFE_INTEGER), ).rejects.toThrow(`no greater than ${MAX_SUPPORTED_DURATION_MS}`); expect(redis.setCalls).toBe(0); + expect(logger.warn).not.toHaveBeenCalled(); + expect(metrics.events).toEqual([]); }); it("accepts the maximum TTL across local, Redis, and tracked-watermark storage", async () => { diff --git a/test/dialcache-local.test.ts b/test/dialcache-local.test.ts index ccf8ce3..0065281 100644 --- a/test/dialcache-local.test.ts +++ b/test/dialcache-local.test.ts @@ -79,9 +79,11 @@ describe("DialCache local-only MVP", () => { expect(calls).toBe(1); }); - it("supports local caching with metrics omitted and no-op invalidation without Redis", async () => { + it("supports local caching with metrics omitted while invalidation without Redis rejects", async () => { // Given metrics and Redis are both absent. - const dialcache = new DialCache(); + const dialcache = new DialCache({ + logger: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }); let calls = 0; const getUser = dialcache.cached(async (userId: string) => ({ userId, calls: ++calls }), { keyType: "user_id", @@ -92,7 +94,9 @@ describe("DialCache local-only MVP", () => { // 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"); + await expect(dialcache.invalidateRemote("user_id", "123")).rejects.toThrow( + "DialCache invalidateRemote requires a configured Redis client", + ); const second = await dialcache.enable(async () => await getUser("123")); // Then no metrics adapter or Redis layer is required for the local path to work.