Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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";
Expand Down
12 changes: 8 additions & 4 deletions src/dialcache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -522,12 +526,12 @@ export class DialCache {
async invalidateRemote(keyType: string, id: Id, futureBufferMs = 0): Promise<void> {
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);
Expand Down
76 changes: 75 additions & 1 deletion test/dialcache-invalidation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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 () => {
Expand Down
10 changes: 7 additions & 3 deletions test/dialcache-local.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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.
Expand Down
Loading