Skip to content
Closed
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
25 changes: 22 additions & 3 deletions src/dialcache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,14 @@ type CacheOperationOptions<Value> = CacheOperationOptionsBase<Value> & {
*/
export type CachedOptions<Fn extends AnyFn> = CachedOptionsBase<Fn> & SerializerOption<CachedValue<Fn>>;

interface CachedDefinition<Fn extends AnyFn> {
readonly keyType: string;
readonly useCase: string;
readonly cacheKey: CacheKeySelector<Fn>;
readonly serializer: Serializer<CachedValue<Fn>> | null;
readonly trackForInvalidation: boolean;
}

interface GetOrLoadOptionsBase<Value> extends CacheOperationOptionsBase<Value> {
/**
* Include every captured value that can affect the loaded result. Concurrent
Expand Down Expand Up @@ -350,15 +358,16 @@ export class DialCache {
const defaultConfig = snapshotDefaultConfig(options.defaultConfig);
const fallbackTimeoutMs = resolveFallbackTimeoutMs(options.fallbackTimeoutMs);
const shadowComparator = resolveShadowComparator(options.shadowComparator);
this.registerUseCase(options.useCase);
const definition = snapshotCachedDefinition(options);
this.registerUseCase(definition.useCase);

return (...args: Parameters<Fn>): Promise<CachedValue<Fn>> =>
this.executeCacheOperation(
// `Fn` preserves the public parameter and return types, but its
// `AnyFn` constraint erases the invocation result to `unknown`.
() => fn(...args) as Awaitable<CachedValue<Fn>>,
() => options.cacheKey(...args),
options,
() => definition.cacheKey(...args),
definition,
defaultConfig,
fallbackTimeoutMs,
shadowComparator,
Expand Down Expand Up @@ -1373,6 +1382,16 @@ function yieldUnreferencedImmediate(): Promise<void> {
});
}

function snapshotCachedDefinition<Fn extends AnyFn>(options: CachedOptions<Fn>): CachedDefinition<Fn> {
return {
useCase: options.useCase,
keyType: options.keyType,
cacheKey: options.cacheKey,
serializer: (options.serializer as Serializer<CachedValue<Fn>> | null | undefined) ?? null,
trackForInvalidation: options.trackForInvalidation ?? false,
};
}

function snapshotDefaultConfig(config: DialCacheKeyConfig | null | undefined): DialCacheKeyConfig | null {
if (config === null || config === undefined) {
return null;
Expand Down
175 changes: 175 additions & 0 deletions test/dialcache-definition.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import { describe, expect, it, vi } from "vitest";

import {
CacheLayer,
DialCache,
DialCacheKeyConfig,
JsonSerializer,
UseCaseIsAlreadyRegisteredError,
type CachedOptions,
type DialCacheKey,
type DialCacheMetricsAdapter,
} from "../src/index.js";

const localOnly = () =>
new DialCacheKeyConfig({
ttlSec: { [CacheLayer.LOCAL]: 60 },
ramp: { [CacheLayer.LOCAL]: 100 },
});

function metricsWithRequest(request: DialCacheMetricsAdapter["request"]): DialCacheMetricsAdapter {
return {
request,
miss: vi.fn(),
disabled: vi.fn(),
error: vi.fn(),
invalidation: vi.fn(),
coalesced: vi.fn(),
observeGet: vi.fn(),
observeFallback: vi.fn(),
observeSerialization: vi.fn(),
observeSize: vi.fn(),
};
}

describe("DialCache cached definition registration", () => {
it("snapshots caller-owned definition options before returning the wrapper", async () => {
type Value = { readonly owner: "first" | "second"; readonly id: string; readonly load: number };

const firstSerializer = new JsonSerializer<Value>();
const secondSerializer = new JsonSerializer<Value>();
const originalCacheKey = vi.fn((id: string) => `registered:${id}`);
const sharedCacheKey = vi.fn((id: string) => id);
const observedKeys: DialCacheKey[] = [];
const request = vi.fn<DialCacheMetricsAdapter["request"]>();
const dialcache = new DialCache({
cacheConfigProvider: (key) => {
observedKeys.push(key);
return null;
},
metrics: metricsWithRequest(request),
});
let firstLoads = 0;
let secondLoads = 0;
const options = {
keyType: "registered_id",
useCase: "RegisteredFirst",
cacheKey: originalCacheKey,
serializer: firstSerializer,
trackForInvalidation: false,
defaultConfig: localOnly(),
};
const first = dialcache.cached(async (id: string): Promise<Value> => ({
owner: "first",
id,
load: ++firstLoads,
}), options);

options.keyType = "shared_id";
options.useCase = "SharedAfterMutation";
options.cacheKey = sharedCacheKey;
options.serializer = secondSerializer;
options.trackForInvalidation = true;
const second = dialcache.cached(async (id: string): Promise<Value> => ({
owner: "second",
id,
load: ++secondLoads,
}), { ...options, defaultConfig: localOnly() });

const values = await dialcache.enable(async () => [await first("123"), await second("123")] as const);

expect(values).toEqual([
{ owner: "first", id: "123", load: 1 },
{ owner: "second", id: "123", load: 1 },
]);
expect(firstLoads).toBe(1);
expect(secondLoads).toBe(1);
expect(originalCacheKey).toHaveBeenCalledOnce();
expect(sharedCacheKey).toHaveBeenCalledOnce();
expect(observedKeys).toHaveLength(2);
expect(observedKeys[0]).toMatchObject({
id: "registered:123",
keyType: "registered_id",
useCase: "RegisteredFirst",
serializer: firstSerializer,
trackForInvalidation: false,
});
expect(observedKeys[1]).toMatchObject({
id: "123",
keyType: "shared_id",
useCase: "SharedAfterMutation",
serializer: secondSerializer,
trackForInvalidation: true,
});
expect(observedKeys[0]?.serializer).toBe(firstSerializer);
expect(observedKeys[1]?.serializer).toBe(secondSerializer);
expect(request).toHaveBeenNthCalledWith(1, {
cacheNamespace: "urn",
keyType: "registered_id",
useCase: "RegisteredFirst",
layer: CacheLayer.LOCAL,
});
expect(request).toHaveBeenNthCalledWith(2, {
cacheNamespace: "urn",
keyType: "shared_id",
useCase: "SharedAfterMutation",
layer: CacheLayer.LOCAL,
});
});

it("uses the exact use case value that was checked and registered", async () => {
let useCaseReads = 0;
const observedKeys: DialCacheKey[] = [];
const dialcache = new DialCache({
cacheConfigProvider: (key) => {
observedKeys.push(key);
return null;
},
});
const options: CachedOptions<() => Promise<string>> = {
keyType: "id",
get useCase(): string {
useCaseReads += 1;
return useCaseReads === 1 ? "RegisteredOnce" : "watermark";
},
cacheKey: () => "123",
defaultConfig: localOnly(),
};
const load = dialcache.cached(async () => "value", options);

await expect(dialcache.enable(async () => await load())).resolves.toBe("value");

expect(useCaseReads).toBe(1);
expect(observedKeys).toHaveLength(1);
expect(observedKeys[0]?.useCase).toBe("RegisteredOnce");
expect(() =>
dialcache.cached(async () => "value", {
keyType: "id",
useCase: "RegisteredOnce",
cacheKey: () => "123",
}),
).toThrow(UseCaseIsAlreadyRegisteredError);
});

it("does not reserve the use case when reading a definition option throws", () => {
const dialcache = new DialCache();
const readError = new Error("could not read invalidation tracking");
const options: CachedOptions<() => Promise<string>> = {
keyType: "id",
useCase: "AtomicDefinitionRegistration",
cacheKey: () => "123",
get trackForInvalidation(): boolean {
throw readError;
},
};

expect(() => dialcache.cached(async () => "value", options)).toThrow(readError);
expect(() =>
dialcache.cached(async () => "value", {
keyType: "id",
useCase: "AtomicDefinitionRegistration",
cacheKey: () => "123",
}),
).not.toThrow();
});
});
Loading