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
37 changes: 19 additions & 18 deletions README.md

Large diffs are not rendered by default.

13 changes: 5 additions & 8 deletions src/dialcache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -516,9 +516,10 @@ export class DialCache {
* The watermark fences only invocations that reach the tracked Redis write.
* A rejected caller-path write also suppresses the corresponding process-local
* population. Request-local memoization remains unconditional. A ramped-out
* invocation without shadow work does not consult the watermark; a selected
* shadow path consults it for its tracked read and any clean-miss fill, while
* caller-path request-local and process-local publication remains independent.
* invocation without shadow work does not consult the watermark. A selected
* shadow path for a tracked key consults it for Redis reads and any clean-miss
* fill; untracked shadow work does not. Caller-path request-local and
* process-local publication remains independent.
*
* @param futureBufferMs Nonnegative safe integer no greater than
* 31,536,000,000 (365 days); defaults to zero for backward compatibility.
Expand Down Expand Up @@ -799,10 +800,6 @@ export class DialCache {
validation: ShadowValidationPlan<T>,
readTimeoutMs: number,
): void {
if (!key.trackForInvalidation) {
return;
}

const shadowConfig: unknown = keyConfig?.shadow;
if (
shadowConfig === null
Expand Down Expand Up @@ -938,7 +935,7 @@ export class DialCache {
maybeRelease();
};
const readShadowPayload = (): Promise<RedisCachePayload | null> => {
const read = redisCache.startTrackedPayloadReadForShadow(key, readTimeoutMs);
const read = redisCache.startPayloadReadForShadow(key, readTimeoutMs);
pendingRedisReads.add(read.settled);
void read.settled.then(() => {
pendingRedisReads.delete(read.settled);
Expand Down
12 changes: 3 additions & 9 deletions src/internal/redis-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,18 +148,15 @@ export class RedisCache {
}

/**
* Start a measured tracked Redis read for detached shadow work.
* Start a measured Redis read for detached shadow work.
*
* The bounded result may reject before the semantic client operation settles,
* so callers must retain shadow capacity until `settled` fulfills.
*/
startTrackedPayloadReadForShadow(
startPayloadReadForShadow(
key: DialCacheKey,
readTimeoutMs: number,
): StartedRedisRead {
if (!key.trackForInvalidation) {
throw new Error("DialCache shadow Redis reads require tracked keys");
}
return this.startMeasuredPayloadRead(
key,
readTimeoutMs,
Expand All @@ -176,16 +173,13 @@ export class RedisCache {
return await this.putWithLayer(key, value, ttlSec, CacheLayer.REMOTE);
}

/** Populate a definitive detached tracked miss using the caller's resolved policy snapshot. */
/** Populate a clean detached Redis miss using the caller's resolved policy snapshot. */
async putForShadow<T>(
key: DialCacheKey,
value: T,
config: { readonly ttlSec: number },
shouldWrite: () => boolean,
): Promise<boolean | null> {
if (!key.trackForInvalidation) {
throw new Error("DialCache shadow Redis writes require tracked keys");
}
return await this.putWithLayer(
key,
value,
Expand Down
50 changes: 43 additions & 7 deletions test/dialcache-shadow-confirmation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,11 @@ function expectTrackedReads(
}
}

function expectUntrackedReads(redis: ScriptedRedis, count: number): void {
expect(redis.requests).toHaveLength(count);
expect(redis.requests.every((request) => !Object.hasOwn(request, "watermarkKey"))).toBe(true);
}

describe("DialCache Redis shadow confirmation", () => {
it("skips confirmation when the served Redis payload semantically matches SoT", async () => {
const payload = JSON.stringify({ id: "123", version: 1 });
Expand Down Expand Up @@ -916,26 +921,41 @@ describe("DialCache Redis shadow confirmation", () => {
}
});

it("fills a definitive dark Redis miss and attributes the read and write to remote_shadow", async () => {
it.each([
{ name: "tracked", tracked: true },
{ name: "untracked", tracked: false },
])("fills a clean $name dark Redis miss and attributes the read and write to remote_shadow", async ({
name,
tracked,
}) => {
const redis = new ScriptedRedis([() => null]);
const metrics = new RecordingMetrics();
const source = vi.fn(async () => ({ id: "123" }));
const dialcache = createCache(redis, metrics);
const getUser = dialcache.cached(source, {
...trackedOptions("ShadowDarkMissFill", remoteConfig(0)),
keyType: "user_id",
useCase: `ShadowDarkMissFill${name}`,
cacheKey: () => "123",
trackForInvalidation: tracked,
defaultConfig: remoteConfig(0),
});

await expect(dialcache.enable(async () => await getUser())).resolves.toEqual({ id: "123" });
await waitForShadowEvents(metrics, 1);

expect(metrics.shadowEvents.map(({ outcome }) => outcome)).toEqual(["filled"]);
expect(source).toHaveBeenCalledOnce();
if (tracked) {
expectTrackedReads(redis, 1);
} else {
expectUntrackedReads(redis, 1);
}
expect(redis.write).toHaveBeenCalledOnce();
expect(redis.write).toHaveBeenCalledWith(expect.objectContaining({
cacheTtlMs: 60_000,
watermarkKey: expect.any(String),
value: JSON.stringify({ id: "123" }),
}));
expect(Object.hasOwn(redis.write.mock.calls[0]?.[0] ?? {}, "watermarkKey")).toBe(tracked);
expect(metrics.ordinaryEvents.filter(({ name, labels }) =>
name === "request" && labels.layer === REMOTE_SHADOW_CACHE_LAYER
)).toHaveLength(1);
Expand Down Expand Up @@ -1269,7 +1289,7 @@ describe("DialCache Redis shadow confirmation", () => {
)).toHaveLength(0);
});

it("dark-reads only an otherwise valid tracked remote policy with observable shadowing", async () => {
it("dark-reads only an otherwise valid remote policy with observable shadowing", async () => {
const cases = [
{
name: "missing remote policy",
Expand All @@ -1278,10 +1298,19 @@ describe("DialCache Redis shadow confirmation", () => {
config: new DialCacheKeyConfig({ shadow: { ramp: 100 } }),
},
{
name: "untracked",
name: "untracked omitted shadow ramp",
tracked: false,
metrics: new RecordingMetrics(),
config: remoteConfig(0),
config: new DialCacheKeyConfig({
ttlSec: { [CacheLayer.REMOTE]: 60 },
ramp: { [CacheLayer.REMOTE]: 0 },
}),
},
{
name: "untracked zero shadow ramp",
tracked: false,
metrics: new RecordingMetrics(),
config: remoteConfig(0, 0),
},
{
name: "missing shadow hook",
Expand All @@ -1308,8 +1337,9 @@ describe("DialCache Redis shadow confirmation", () => {

for (const testCase of cases) {
const redis = new ScriptedRedis([]);
const source = vi.fn(async () => ({ id: testCase.name }));
const dialcache = createCache(redis, testCase.metrics);
const getUser = dialcache.cached(async () => ({ id: testCase.name }), {
const getUser = dialcache.cached(source, {
keyType: "user_id",
useCase: `ShadowDarkIneligible${testCase.name}`,
cacheKey: () => "123",
Expand All @@ -1319,7 +1349,13 @@ describe("DialCache Redis shadow confirmation", () => {

await dialcache.enable(async () => await getUser());
await nextImmediate();
expect(source, testCase.name).toHaveBeenCalledOnce();
expect(redis.requests, testCase.name).toHaveLength(0);
expect(redis.write, testCase.name).not.toHaveBeenCalled();
expect(redis.invalidate, testCase.name).not.toHaveBeenCalled();
if (testCase.metrics instanceof RecordingMetrics) {
expect(testCase.metrics.shadowEvents, testCase.name).toHaveLength(0);
}
}
});

Expand Down
11 changes: 7 additions & 4 deletions test/dialcache-shadow-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,7 @@ describe("DialCache Redis shadow validation", () => {
expect(metrics.shadowEvents[0]?.outcome).toBe("match");
});

it("does not validate an untracked Redis hit", async () => {
it("validates an untracked Redis hit without consulting a watermark", async () => {
const redis = new FakeRedis();
const metrics = new RecordingMetrics();
const useCase = "ShadowUntracked";
Expand All @@ -314,10 +314,13 @@ describe("DialCache Redis shadow validation", () => {
});

expect(await dialcache.enable(async () => await getUser())).toEqual({ id: "123", source: "cache" });
await nextImmediate();
await waitForShadowEvents(metrics, 1);

expect(source).not.toHaveBeenCalled();
expect(metrics.shadowEvents).toHaveLength(0);
expect(source).toHaveBeenCalledOnce();
expect(metrics.shadowEvents.map(({ outcome }) => outcome)).toEqual(["mismatch"]);
expect(redis.getCalls).toBe(2);
expect(redis.mGetCalls).toBe(0);
expect(redis.setCalls).toBe(0);
});

it("does not validate a tracked Redis miss", async () => {
Expand Down
64 changes: 47 additions & 17 deletions test/redis-real.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -458,19 +458,30 @@ describe.each(engines)("DialCache Lua protocol on $name", ({ image }) => {
expect(await admin.get(commandOptions({ returnBuffers: true }), valueKey)).toBeNull();
});

it("shadow-reads tracked Redis without serving or repairing a warm hit when the remote ramp is zero", async () => {
it.each([
{ name: "tracked", tracked: true },
{ name: "untracked", tracked: false },
])("shadow-reads $name Redis without serving or repairing a warm hit when the remote ramp is zero", async ({
name,
tracked,
}) => {
if (client === undefined || admin === undefined) {
throw new Error("Redis test clients did not start");
}
const namespace = "real-dark-shadow";
const useCase = "RealDarkShadowPayload";
const valueKey = `{${namespace}:item_id:dark}#${useCase}:dialcache-frame-v1`;
const namespace = `real-dark-shadow-${name}`;
const useCase = `RealDarkShadowPayload${name}`;
const rawPrefix = `${namespace}:item_id:dark`;
const valueKey = tracked
? `{${rawPrefix}}#${useCase}:dialcache-frame-v1`
: `${rawPrefix}#${useCase}:dialcache-frame-v1`;
const watermarkKey = `{${namespace}:item_id:dark}#watermark`;
const cachedValue = { id: "dark", version: 1 };
const sourceValue = { id: "dark", version: 2 };
const storedFrame = encodeFrame(JSON.stringify(cachedValue), 0);
await admin.set(valueKey, storedFrame, { PX: 60_000 });
await admin.set(watermarkKey, "0", { PX: 60_000 });
if (tracked) {
await admin.set(watermarkKey, "0", { PX: 60_000 });
}

const read = vi.fn(client.adapter.read);
const write = vi.fn(client.adapter.write);
Expand Down Expand Up @@ -504,7 +515,7 @@ describe.each(engines)("DialCache Lua protocol on $name", ({ image }) => {
keyType: "item_id",
useCase,
cacheKey: () => "dark",
trackForInvalidation: true,
trackForInvalidation: tracked,
defaultConfig: new DialCacheKeyConfig({
ttlSec: { [CacheLayer.REMOTE]: 60 },
ramp: { [CacheLayer.REMOTE]: 0 },
Expand All @@ -519,7 +530,10 @@ describe.each(engines)("DialCache Lua protocol on $name", ({ image }) => {
expect(source).toHaveBeenCalledOnce();
expect(read).toHaveBeenCalledTimes(2);
expect(read.mock.calls.every(([request]) =>
request.valueKey === valueKey && request.watermarkKey === watermarkKey
request.valueKey === valueKey
&& (tracked
? request.watermarkKey === watermarkKey
: !Object.hasOwn(request, "watermarkKey"))
)).toBe(true);
expect(metrics.shadowValidation).toHaveBeenCalledOnce();
expect(metrics.shadowValidation).toHaveBeenCalledWith({
Expand Down Expand Up @@ -577,13 +591,22 @@ describe.each(engines)("DialCache Lua protocol on $name", ({ image }) => {
expect(await admin.get(commandOptions({ returnBuffers: true }), valueKey)).toEqual(storedFrame);
});

it("fills a clean tracked shadow miss asynchronously and serves it after Redis ramps up", async () => {
it.each([
{ name: "tracked", tracked: true },
{ name: "untracked", tracked: false },
])("fills a clean $name shadow miss asynchronously and serves it after Redis ramps up", async ({
name,
tracked,
}) => {
if (client === undefined || admin === undefined) {
throw new Error("Redis test clients did not start");
}
const namespace = "real-dark-shadow-fill";
const useCase = "RealDarkShadowFill";
const valueKey = `{${namespace}:item_id:cold}#${useCase}:dialcache-frame-v1`;
const namespace = `real-dark-shadow-fill-${name}`;
const useCase = `RealDarkShadowFill${name}`;
const rawPrefix = `${namespace}:item_id:cold`;
const valueKey = tracked
? `{${rawPrefix}}#${useCase}:dialcache-frame-v1`
: `${rawPrefix}#${useCase}:dialcache-frame-v1`;
const watermarkKey = `{${namespace}:item_id:cold}#watermark`;
const sourceValue = { id: "cold", version: 1 };
const writeStarted = deferred<void>();
Expand Down Expand Up @@ -631,7 +654,7 @@ describe.each(engines)("DialCache Lua protocol on $name", ({ image }) => {
keyType: "item_id",
useCase,
cacheKey: () => "cold",
trackForInvalidation: true,
trackForInvalidation: tracked,
});

const result = await dialcache.enable(async () => await getPayload());
Expand All @@ -646,16 +669,23 @@ describe.each(engines)("DialCache Lua protocol on $name", ({ image }) => {
expect(write).toHaveBeenCalledOnce();
expect(write).toHaveBeenCalledWith({
valueKey,
watermarkKey,
cacheTtlMs: 60_000,
value: JSON.stringify(sourceValue),
...(tracked ? { watermarkKey } : {}),
});
expect(await client.adapter.read({ valueKey, watermarkKey })).toBe(JSON.stringify(sourceValue));
expect(await admin.get(watermarkKey)).toBe("0");
expect(await client.adapter.read({
valueKey,
...(tracked ? { watermarkKey } : {}),
})).toBe(JSON.stringify(sourceValue));
expect(await admin.pTTL(valueKey)).toBeGreaterThan(55_000);
expect(await admin.pTTL(valueKey)).toBeLessThanOrEqual(60_000);
expect(await admin.pTTL(watermarkKey)).toBeGreaterThan(115_000);
expect(await admin.pTTL(watermarkKey)).toBeLessThanOrEqual(120_000);
if (tracked) {
expect(await admin.get(watermarkKey)).toBe("0");
expect(await admin.pTTL(watermarkKey)).toBeGreaterThan(115_000);
expect(await admin.pTTL(watermarkKey)).toBeLessThanOrEqual(120_000);
} else {
expect(await admin.exists(watermarkKey)).toBe(0);
}
expect(metrics.shadowValidation).toHaveBeenCalledOnce();
expect(metrics.shadowValidation).toHaveBeenCalledWith({
cacheNamespace: namespace,
Expand Down
Loading