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

Large diffs are not rendered by default.

62 changes: 62 additions & 0 deletions scripts/test-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const rootConsumer = `import {
type DialCacheMetricsAdapter,
type DialCacheRedisClient,
type DisabledReason,
type GetOrLoadOptions,
type InvalidationMetricLabels,
type MetricErrorKind,
type ProcessCoalescingState,
Expand All @@ -51,6 +52,11 @@ const optionsFor = (useCase: string) => ({
useCase,
cacheKey: (id: string) => id,
});
const inlineOptionsFor = (useCase: string, key = "1") => ({
keyType: "id",
useCase,
key,
});
const metrics: DialCacheMetricsAdapter = {
request: () => undefined,
miss: () => undefined,
Expand Down Expand Up @@ -95,6 +101,14 @@ const loadWithoutFallbackDeadline = cache.cached(async (id: string) => id, {
cacheKey: (id) => id,
fallbackTimeoutMs: null,
});
const inlineSync: Promise<{ readonly id: string }> = cache.getOrLoad(
() => ({ id: "sync" as const }),
inlineOptionsFor("InlineSync"),
);
const inlineAsync: Promise<{ readonly id: string }> = cache.getOrLoad(
async () => ({ id: "async" as const }),
inlineOptionsFor("InlineAsync"),
);

interface JsonCompatibleRecord {
readonly id: string;
Expand Down Expand Up @@ -127,6 +141,11 @@ const dateOptions: CachedOptions<DateLoader> = {
...optionsFor("TypedDateOptions"),
serializer: dateSerializer,
};
const inlineDateOptions: GetOrLoadOptions<Date> = {
...inlineOptionsFor("InlineDateWithSerializer"),
serializer: dateSerializer,
};
const inlineDate: Promise<Date> = cache.getOrLoad(async () => new Date(0), inlineDateOptions);

class MethodBearingValue {
constructor(readonly id: string) {}
Expand Down Expand Up @@ -161,6 +180,10 @@ cache.cached(async (_id: string) => new Uint8Array([1, 2]), optionsFor("TypedArr
cache.cached(async (id: string) => new MethodBearingValue(id), optionsFor("ClassWithoutSerializer"));
// @ts-expect-error CachedOptions itself requires a serializer for Date values.
const missingDateSerializer: CachedOptions<DateLoader> = optionsFor("TypedDateOptionsWithoutSerializer");
// @ts-expect-error getOrLoad requires an explicit serializer for an inferred Date value.
cache.getOrLoad(async () => new Date(0), inlineOptionsFor("InlineDateWithoutSerializer"));
// @ts-expect-error GetOrLoadOptions itself requires a serializer for Date values.
const missingInlineDateSerializer: GetOrLoadOptions<Date> = inlineOptionsFor("TypedInlineDateWithoutSerializer");

const requestLocalConfig = new DialCacheKeyConfig({ requestLocal: true });
const structuralConfigProvider: CacheConfigProvider = () => ({
Expand Down Expand Up @@ -252,14 +275,19 @@ const rootHasNoDatadogFactory: "createDatadogDialCacheMetrics" extends keyof Dia

void load;
void loadWithoutFallbackDeadline;
void inlineSync;
void inlineAsync;
void loadJsonRecord;
void loadEmptyObject;
void loadUndefined;
void loadVoid;
void loadDate;
void loadDateWithTrustedJsonAssertion;
void dateOptions;
void inlineDateOptions;
void inlineDate;
void missingDateSerializer;
void missingInlineDateSerializer;
void requestLocalConfig;
void structuralConfigProvider;
void requestLocalCoalescingLabels;
Expand Down Expand Up @@ -292,6 +320,8 @@ const cacheWithGlobalSerializer = new DialCache({
});
// @ts-expect-error A global serializer cannot establish per-function Date compatibility.
cacheWithGlobalSerializer.cached(async (_id: string) => new Date(0), optionsFor("GlobalSerializerNeedsTypedOverride"));
// @ts-expect-error A global serializer cannot establish per-invocation Date compatibility.
cacheWithGlobalSerializer.getOrLoad(async () => new Date(0), inlineOptionsFor("GlobalSerializerNeedsInlineTypedOverride"));
void cacheHasNoFlushAll;
void cacheHasNoClose;
void clientHasNoFlushAll;
Expand Down Expand Up @@ -487,6 +517,22 @@ const first = await overlayCache.enable(() => load("123"));
const second = await overlayCache.enable(() => load("123"));
if (calls !== 1 || second !== first) {
throw new Error("The packed ESM runtime did not inherit the default local TTL through a sparse overlay");
}
let inlineCalls = 0;
const inlineOptions = {
keyType: "id",
useCase: "PackedRuntimeGetOrLoad",
key: "123",
defaultConfig: root.DialCacheKeyConfig.enabled(60),
};
const inlineFirst = await overlayCache.enable(() =>
overlayCache.getOrLoad(() => ({ source: "inline", calls: ++inlineCalls }), inlineOptions),
);
const inlineSecond = await overlayCache.enable(() =>
overlayCache.getOrLoad(() => ({ source: "unexpected", calls: ++inlineCalls }), inlineOptions),
);
if (inlineCalls !== 1 || inlineSecond !== inlineFirst) {
throw new Error("The packed ESM runtime did not execute getOrLoad through the cache chain");
}`,
],
{ cwd: workspace },
Expand Down Expand Up @@ -567,6 +613,22 @@ void (async () => {
if (calls !== 1 || second !== first) {
throw new Error("The packed CommonJS runtime did not inherit the default local TTL through a sparse overlay");
}
let inlineCalls = 0;
const inlineOptions = {
keyType: "id",
useCase: "PackedRuntimeGetOrLoad",
key: "123",
defaultConfig: root.DialCacheKeyConfig.enabled(60),
};
const inlineFirst = await overlayCache.enable(() =>
overlayCache.getOrLoad(() => ({ source: "inline", calls: ++inlineCalls }), inlineOptions),
);
const inlineSecond = await overlayCache.enable(() =>
overlayCache.getOrLoad(() => ({ source: "unexpected", calls: ++inlineCalls }), inlineOptions),
);
if (inlineCalls !== 1 || inlineSecond !== inlineFirst) {
throw new Error("The packed CommonJS runtime did not execute getOrLoad through the cache chain");
}
})().catch((error) => {
console.error(error);
process.exitCode = 1;
Expand Down
173 changes: 113 additions & 60 deletions src/dialcache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ type IsJsonObject<T extends object, Depth extends readonly unknown[]> = [keyof T
: false;
}[keyof T]>;

interface CachedOptionsBase<Fn extends AnyFn> {
interface CacheOperationOptionsBase {
readonly keyType: string;
readonly useCase: string;
readonly defaultConfig?: DialCacheKeyConfig | null;
Expand All @@ -117,6 +117,9 @@ interface CachedOptionsBase<Fn extends AnyFn> {
* published by DialCache, but does not cancel the underlying operation.
*/
readonly fallbackTimeoutMs?: number | null;
}

interface CachedOptionsBase<Fn extends AnyFn> extends CacheOperationOptionsBase {
/**
* Select every input dimension that can affect the returned value. Concurrent
* enabled calls with the same cache key may share one in-flight execution.
Expand All @@ -127,6 +130,9 @@ interface CachedOptionsBase<Fn extends AnyFn> {
type SerializerOption<Value> = IsJsonCompatible<Value> extends true
? { readonly serializer?: Serializer<Value> | null }
: { readonly serializer: Serializer<Value> };
type CacheOperationOptions<Value> = CacheOperationOptionsBase & {
readonly serializer?: Serializer<Value> | null;
};

/**
* Options for a cached function. A serializer is required when the function's
Expand All @@ -136,6 +142,22 @@ type SerializerOption<Value> = IsJsonCompatible<Value> extends true
*/
export type CachedOptions<Fn extends AnyFn> = CachedOptionsBase<Fn> & SerializerOption<CachedValue<Fn>>;

interface GetOrLoadOptionsBase extends CacheOperationOptionsBase {
/**
* Include every captured value that can affect the loaded result. Concurrent
* enabled calls with the same cache key may share one in-flight loader.
*/
readonly key: CacheKeySpec;
}

/**
* Options for one inline cache operation. A serializer is required when the
* loaded value is not statically compatible with the default JSON serializer.
* Supplying one is a trusted assertion; DialCache does not perform runtime
* round-trip validation.
*/
export type GetOrLoadOptions<Value> = GetOrLoadOptionsBase & SerializerOption<Value>;

/**
* A cached function returns references owned by the cache. Treat returned
* values as immutable; callers that need to mutate must copy them explicitly.
Expand Down Expand Up @@ -251,67 +273,94 @@ export class DialCache {
const fallbackTimeoutMs = resolveFallbackTimeoutMs(options.fallbackTimeoutMs);
this.registerUseCase(options.useCase);

const run = async (...args: Parameters<Fn>): Promise<CachedValue<Fn>> => {
// `fn`'s awaited result is the cached value by construction; the generic `Fn` erases it to `unknown`.
const rawFallback = async (): Promise<CachedValue<Fn>> => (await fn(...args)) as CachedValue<Fn>;
const noLayerLabels = {
cacheNamespace: this.namespace,
useCase: options.useCase,
keyType: options.keyType,
layer: NO_CACHE_LAYER,
} as const;
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,
defaultConfig,
fallbackTimeoutMs,
);
}

if (!this.isEnabled()) {
this.metrics?.disabled({ ...noLayerLabels, reason: "context" });
return await rawFallback();
}
/**
* Executes one inline loader through the configured cache chain. Unlike
* `cached()`, this method does not register the use case, so a stable use case
* can be declared repeatedly at the call site. Returned in-memory values are
* shared by reference and must be treated as immutable.
*/
getOrLoad<Value>(load: () => Awaitable<Value>, options: GetOrLoadOptions<Value>): Promise<Value> {
const defaultConfig = snapshotDefaultConfig(options.defaultConfig);
const fallbackTimeoutMs = resolveFallbackTimeoutMs(options.fallbackTimeoutMs);
this.assertUseCaseIsNotReserved(options.useCase);

const fallback = (): Promise<CachedValue<Fn>> =>
withFallbackTimeout(rawFallback, options.useCase, fallbackTimeoutMs);
return this.executeCacheOperation(load, () => options.key, options, defaultConfig, fallbackTimeoutMs);
}

let key: DialCacheKey;
try {
key = this.buildKey(options, options.cacheKey(...args), defaultConfig);
} catch (error) {
this.logger.error("Could not construct DialCache key", error);
this.metrics?.error({
...noLayerLabels,
error: "key_construction",
inFallback: false,
});
return await this.callFallback(noLayerLabels, fallback);
}
private async executeCacheOperation<Value>(
load: () => Awaitable<Value>,
selectKey: () => CacheKeySpec,
options: CacheOperationOptions<Value>,
defaultConfig: DialCacheKeyConfig | null,
fallbackTimeoutMs: number | null,
): Promise<Value> {
const rawFallback = async (): Promise<Value> => await load();
const noLayerLabels = {
cacheNamespace: this.namespace,
useCase: options.useCase,
keyType: options.keyType,
layer: NO_CACHE_LAYER,
} as const;

let keyConfig: DialCacheKeyConfig | null;
try {
keyConfig = await fetchKeyConfig(this.configProvider, key);
} catch (error) {
// Provider failure: fail open and run uncached, mirroring the per-layer config_error path.
this.logger.warn("Could not resolve DialCache key config", error);
this.recordError(key, NO_CACHE_LAYER, "config_resolution");
this.metrics?.disabled({ ...noLayerLabels, reason: "config_error" });
return await this.callFallback(noLayerLabels, fallback);
}
if (!this.isEnabled()) {
this.metrics?.disabled({ ...noLayerLabels, reason: "context" });
return await rawFallback();
}

// An unawaited child can inherit the async store after its outer enable()
// callback settles. The closed holder turns that detached work back into
// pass-through instead of allowing it to repopulate request state.
if (!this.isEnabled()) {
this.metrics?.disabled({ ...noLayerLabels, reason: "context" });
return await this.callFallback(noLayerLabels, fallback);
}
const fallback = (): Promise<Value> => withFallbackTimeout(rawFallback, options.useCase, fallbackTimeoutMs);

if (keyConfig?.requestLocal === true) {
const requestLocalCache = getOrCreateRequestLocalCache(this.context);
if (requestLocalCache !== null) {
return await this.getThroughRequestLocal(requestLocalCache, key, keyConfig, fallback);
}
}
let key: DialCacheKey;
try {
key = this.buildKey(options, selectKey(), defaultConfig);
} catch (error) {
this.logger.error("Could not construct DialCache key", error);
this.metrics?.error({
...noLayerLabels,
error: "key_construction",
inFallback: false,
});
return await this.callFallback(noLayerLabels, fallback);
}

return await this.getThroughSharedLayers(key, keyConfig, fallback, CacheLayer.LOCAL);
};
let keyConfig: DialCacheKeyConfig | null;
try {
keyConfig = await fetchKeyConfig(this.configProvider, key);
} catch (error) {
// Provider failure: fail open and run uncached, mirroring the per-layer config_error path.
this.logger.warn("Could not resolve DialCache key config", error);
this.recordError(key, NO_CACHE_LAYER, "config_resolution");
this.metrics?.disabled({ ...noLayerLabels, reason: "config_error" });
return await this.callFallback(noLayerLabels, fallback);
}

return run;
// An unawaited child can inherit the async store after its outer enable()
// callback settles. The closed holder turns that detached work back into
// pass-through instead of allowing it to repopulate request state.
if (!this.isEnabled()) {
this.metrics?.disabled({ ...noLayerLabels, reason: "context" });
return await this.callFallback(noLayerLabels, fallback);
}

if (keyConfig?.requestLocal === true) {
const requestLocalCache = getOrCreateRequestLocalCache(this.context);
if (requestLocalCache !== null) {
return await this.getThroughRequestLocal(requestLocalCache, key, keyConfig, fallback);
}
}

return await this.getThroughSharedLayers(key, keyConfig, fallback, CacheLayer.LOCAL);
}

/**
Expand Down Expand Up @@ -594,7 +643,7 @@ export class DialCache {

/**
* Invalid runtime TTL/ramp leaves can only come from provider results, since
* static defaults are validated at registration. Count them as
* static defaults are validated before the operation executes. Count them as
* config_resolution errors as well as disabled skips so garbage config is
* alertable separately from intentional ramp-downs and disabled policy.
*/
Expand All @@ -605,17 +654,21 @@ export class DialCache {
}

private registerUseCase(useCase: string): void {
if (useCase === "watermark") {
throw new UseCaseNameIsReservedError(useCase);
}
this.assertUseCaseIsNotReserved(useCase);
if (this.useCases.has(useCase)) {
throw new UseCaseIsAlreadyRegisteredError(useCase);
}
this.useCases.add(useCase);
}

private buildKey<Fn extends AnyFn>(
options: CachedOptions<Fn>,
private assertUseCaseIsNotReserved(useCase: string): void {
if (useCase === "watermark") {
throw new UseCaseNameIsReservedError(useCase);
}
}

private buildKey<Value>(
options: CacheOperationOptions<Value>,
cacheKey: CacheKeySpec,
defaultConfig: DialCacheKeyConfig | null,
): DialCacheKey {
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export type {
CachedOptions,
CachedValue,
CoalescingState,
GetOrLoadOptions,
ProcessCoalescingState,
} from "./dialcache.js";
export { DialCacheKey, invalidationPrefix, normalizeArgs, redisClusterHashTag } from "./key.js";
Expand Down
Loading
Loading