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
100 changes: 68 additions & 32 deletions README.md

Large diffs are not rendered by default.

46 changes: 45 additions & 1 deletion scripts/test-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,21 @@ const workspace = await mkdtemp(join(tmpdir(), "dialcache-package-"));
const rootConsumer = `import {
CacheLayer,
DialCache,
DialCacheKey,
DialCacheKeyConfig,
JsonSerializer,
type CacheMetricLabels,
type CacheConfigProvider,
type CachedOptions,
type CoalescedMetricLabels,
type CoalescingScope,
type DialCacheConfig,
type DialCacheKeyInit,
type DialCacheMetricsAdapter,
type DialCacheRedisClient,
type InvalidationMetricLabels,
type MetricErrorKind,
type RedisConfig,
type Serializer,
} from "dialcache";
import { createNodeRedisDialCacheClient } from "dialcache/node-redis";
Expand Down Expand Up @@ -63,7 +68,7 @@ const datadogMetrics = createDatadogDialCacheMetrics(datadogOptions);
const datadogClassAdapter = new DatadogDialCacheMetrics(datadogOptions);
// @ts-expect-error The observation type is an explicit, required choice.
const missingObservationType: DatadogMetricsOptions = { client: dogStatsDClient };
const cache = new DialCache({ metrics });
const cache = new DialCache({ namespace: "consumer-cache", metrics });
const load = cache.cached(async (id: string) => id, {
keyType: "id",
useCase: "Load",
Expand Down Expand Up @@ -143,10 +148,32 @@ const structuralConfigProvider: CacheConfigProvider = () => ({
ramp: { [CacheLayer.LOCAL]: 100 },
});
const requestLocalCoalescingLabels: CoalescedMetricLabels = {
cacheNamespace: "consumer-cache",
useCase: "Load",
keyType: "id",
scope: "request_local",
};
const cacheMetricLabels: CacheMetricLabels = {
cacheNamespace: "consumer-cache",
useCase: "Load",
keyType: "id",
layer: CacheLayer.LOCAL,
};
const invalidationMetricLabels: InvalidationMetricLabels = {
cacheNamespace: "consumer-cache",
keyType: "id",
layer: CacheLayer.REMOTE,
};
const keyInit: DialCacheKeyInit = {
namespace: "consumer-cache",
keyType: "id",
id: "123",
useCase: "Load",
};
const keyInitHasNoUrnPrefix: "urnPrefix" extends keyof DialCacheKeyInit ? false : true = true;
// @ts-expect-error DialCacheKeyInit.urnPrefix was renamed to namespace.
const legacyKeyInit: DialCacheKeyInit = { keyType: "id", id: "123", useCase: "Load", urnPrefix: "consumer-cache" };
const namespacedKey = new DialCacheKey(keyInit);
const requestLocalCoalescingScope: CoalescingScope = "request_local";
const boundedErrorKind: MetricErrorKind = "cache_read";
const metricErrorKinds: Readonly<Record<MetricErrorKind, true>> = {
Expand All @@ -173,6 +200,13 @@ const clientHasNoFlushAll: "flushAll" extends keyof DialCacheRedisClient ? false
const configHasNoMetricsRegistry: "metricsRegistry" extends keyof DialCacheConfig ? false : true = true;
const configHasNoMetricsPrefix: "metricsPrefix" extends keyof DialCacheConfig ? false : true = true;
const configRejectsFalseMetrics: false extends NonNullable<DialCacheConfig["metrics"]> ? false : true = true;
const configHasNamespace: "namespace" extends keyof DialCacheConfig ? true : false = true;
const configHasNoUrnPrefix: "urnPrefix" extends keyof DialCacheConfig ? false : true = true;
// @ts-expect-error urnPrefix was renamed to namespace.
const legacyNamespaceConfig: DialCacheConfig = { urnPrefix: "consumer-cache" };
const redisConfigHasNoKeyPrefix: "keyPrefix" extends keyof RedisConfig ? false : true = true;
// @ts-expect-error keyPrefix was removed in favor of DialCacheConfig.namespace.
const legacyKeyPrefixConfig: RedisConfig = { client: customRedisClient, keyPrefix: "legacy:" };
type DialCacheRoot = typeof import("dialcache");
const rootHasNoPrometheusFactory: "createPrometheusDialCacheMetrics" extends keyof DialCacheRoot ? false : true = true;
const rootHasNoDatadogFactory: "createDatadogDialCacheMetrics" extends keyof DialCacheRoot ? false : true = true;
Expand All @@ -189,6 +223,11 @@ void missingDateSerializer;
void requestLocalConfig;
void structuralConfigProvider;
void requestLocalCoalescingLabels;
void cacheMetricLabels;
void invalidationMetricLabels;
void keyInitHasNoUrnPrefix;
void legacyKeyInit;
void namespacedKey.namespace;
void requestLocalCoalescingScope;
void boundedErrorKind;
void metricErrorKinds;
Expand All @@ -210,6 +249,11 @@ void clientHasNoFlushAll;
void configHasNoMetricsRegistry;
void configHasNoMetricsPrefix;
void configRejectsFalseMetrics;
void configHasNamespace;
void configHasNoUrnPrefix;
void legacyNamespaceConfig;
void redisConfigHasNoKeyPrefix;
void legacyKeyPrefixConfig;
void rootHasNoPrometheusFactory;
void rootHasNoDatadogFactory;
void datadogMetrics;
Expand Down
6 changes: 5 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,11 @@ export type Logger = Pick<Console, "debug" | "error" | "warn">;

export interface DialCacheConfig {
readonly cacheConfigProvider?: CacheConfigProvider;
readonly urnPrefix?: string;
/**
* Logical namespace used in cache keys, invalidation identity, ramp sampling,
* and metrics. Defaults to "urn". May not contain `{` or `}`.
*/
readonly namespace?: string;
readonly logger?: Logger;
/**
* Maximum local entries across every use case in this DialCache instance.
Expand Down
6 changes: 5 additions & 1 deletion src/datadog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,11 @@ export interface DatadogDogStatsDClient {
export interface DatadogMetricsOptions {
readonly client: DatadogDogStatsDClient;
readonly observationMetricType: DatadogObservationMetricType;
/** Datadog metric-name namespace; unrelated to DialCacheConfig.namespace. */
readonly namespace?: string;
}

type CacheTag = "use_case" | "key_type" | "layer";
type CacheTag = "cache_namespace" | "use_case" | "key_type" | "layer";
type DatadogTags = Record<string, string>;
type Observation = (name: string, value: number, tags: DatadogTags) => void;

Expand Down Expand Up @@ -95,13 +96,15 @@ export class DatadogDialCacheMetrics implements DialCacheMetricsAdapter {

invalidation(labels: InvalidationMetricLabels): void {
this.increment(this.metricNames.invalidation, {
cache_namespace: labels.cacheNamespace,
key_type: labels.keyType,
layer: labels.layer,
});
}

coalesced(labels: CoalescedMetricLabels): void {
this.increment(this.metricNames.coalesced, {
cache_namespace: labels.cacheNamespace,
use_case: labels.useCase,
key_type: labels.keyType,
scope: labels.scope,
Expand Down Expand Up @@ -138,6 +141,7 @@ export function createDatadogDialCacheMetrics(options: DatadogMetricsOptions): D

function cacheTags(labels: CacheMetricLabels): Record<CacheTag, string> {
return {
cache_namespace: labels.cacheNamespace,
use_case: labels.useCase,
key_type: labels.keyType,
layer: labels.layer,
Expand Down
34 changes: 26 additions & 8 deletions src/dialcache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
} from "./config.js";
import { DialCacheContext, getOrCreateRequestLocalCache, type RequestLocalCache } from "./context.js";
import { UseCaseIsAlreadyRegisteredError, UseCaseNameIsReservedError } from "./errors.js";
import { DialCacheKey, normalizeArgs } from "./key.js";
import { DialCacheKey, assertValidNamespace, normalizeArgs } from "./key.js";
import {
NO_CACHE_LAYER,
REQUEST_LOCAL_CACHE_LAYER,
Expand Down Expand Up @@ -139,21 +139,28 @@ export class DialCache {
private readonly localCache: LocalCache;
private readonly useCases = new Set<string>();
private readonly configProvider: CacheConfigProvider;
private readonly urnPrefix: string;
private readonly namespace: string;
private readonly logger: Logger;
private readonly rampSampler: CacheRampSampler;
private readonly redisCache: RedisCache | null;
private readonly metrics: DialCacheMetricsAdapter | null;
private readonly inFlight = new Map<string, Promise<unknown>>();

constructor(config: DialCacheConfig = {}) {
if (Object.hasOwn(config, "urnPrefix")) {
throw new TypeError('DialCacheConfig.urnPrefix was renamed to "namespace"');
}

const namespace = config.namespace ?? "urn";
assertValidNamespace(namespace);

const localMaxSize = config.localMaxSize ?? DEFAULT_LOCAL_MAX_SIZE;
if (!Number.isSafeInteger(localMaxSize) || localMaxSize < 0) {
throw new RangeError("DialCache localMaxSize must be a nonnegative safe integer");
}

this.configProvider = config.cacheConfigProvider ?? defaultConfigProvider;
this.urnPrefix = config.urnPrefix ?? "urn";
this.namespace = namespace;
this.logger = safeLogger(config.logger ?? defaultLogger);
this.rampSampler = config.rampSampler ?? deterministicRampSampler;
this.metrics = safeMetrics(config.metrics ?? null);
Expand Down Expand Up @@ -199,7 +206,12 @@ export class DialCache {
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 fallback = async (): Promise<CachedValue<Fn>> => (await fn(...args)) as CachedValue<Fn>;
const noLayerLabels = { useCase: options.useCase, keyType: options.keyType, layer: NO_CACHE_LAYER } as const;
const noLayerLabels = {
cacheNamespace: this.namespace,
useCase: options.useCase,
keyType: options.keyType,
layer: NO_CACHE_LAYER,
} as const;

if (!this.isEnabled()) {
this.metrics?.disabled({ ...noLayerLabels, reason: "context" });
Expand Down Expand Up @@ -282,12 +294,13 @@ export class DialCache {
return;
}

this.metrics?.invalidation({ keyType, layer: CacheLayer.REMOTE });
this.metrics?.invalidation({ cacheNamespace: this.namespace, keyType, layer: CacheLayer.REMOTE });
try {
await this.redisCache.invalidate(keyType, String(id), futureBufferMs, this.urnPrefix);
await this.redisCache.invalidate(keyType, String(id), futureBufferMs, this.namespace);
} catch (error) {
this.logger.warn("Error writing DialCache invalidation watermark", error);
this.metrics?.error({
cacheNamespace: this.namespace,
useCase: "watermark",
keyType,
layer: CacheLayer.REMOTE,
Expand Down Expand Up @@ -536,7 +549,7 @@ export class DialCache {
id: String(spec.id),
useCase: options.useCase,
args: normalizeArgs(spec.args ?? {}),
urnPrefix: this.urnPrefix,
namespace: this.namespace,
defaultConfig: options.defaultConfig ?? null,
serializer: (options.serializer as Serializer<unknown> | null | undefined) ?? null,
trackForInvalidation: options.trackForInvalidation ?? false,
Expand All @@ -551,7 +564,12 @@ export class DialCache {
): Promise<T> {
const existing = inFlight.get(key.urn);
if (existing !== undefined) {
this.metrics?.coalesced?.({ useCase: key.useCase, keyType: key.keyType, scope });
this.metrics?.coalesced?.({
cacheNamespace: key.namespace,
useCase: key.useCase,
keyType: key.keyType,
scope,
});
return existing as Promise<T>;
}

Expand Down
19 changes: 10 additions & 9 deletions src/internal/redis-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import { fetchKeyConfig, resolveLayerConfigResult, type ResolvedLayerConfig } fr
export interface RedisConfig {
readonly client?: DialCacheRedisClient;
readonly createClient?: RedisClientFactory;
readonly keyPrefix?: string;
readonly serializer?: Serializer<unknown>;
readonly watermarkTtlSec?: number;
}
Expand All @@ -29,17 +28,19 @@ const REDIS_FRAME_KEY_SUFFIX = ":dialcache-frame-v1";
export class RedisCache {
private readonly configProvider: CacheConfigProvider;
private readonly rampSampler: CacheRampSampler;
private readonly keyPrefix: string;
private readonly defaultSerializer: Serializer<unknown>;
private readonly watermarkTtlMs: number;
private readonly createClient: RedisClientFactory | null;
private readonly metrics: DialCacheMetricsAdapter | null;
private clientPromise: Promise<DialCacheRedisClient> | null;

constructor(options: RedisCacheOptions) {
if (Object.hasOwn(options.redis, "keyPrefix")) {
throw new TypeError("RedisConfig.keyPrefix was removed; use DialCacheConfig.namespace for cache identity");
}

this.configProvider = options.configProvider;
this.rampSampler = options.rampSampler;
this.keyPrefix = options.redis.keyPrefix ?? "";
this.defaultSerializer = options.redis.serializer ?? defaultSerializer;
const watermarkTtlSec = options.redis.watermarkTtlSec ?? DEFAULT_WATERMARK_TTL_SEC;
if (!Number.isSafeInteger(watermarkTtlSec) || watermarkTtlSec <= 0) {
Expand Down Expand Up @@ -144,25 +145,25 @@ export class RedisCache {
}
}

async invalidate(keyType: string, id: string, futureBufferMs = 0, urnPrefix = "urn"): Promise<void> {
async invalidate(keyType: string, id: string, futureBufferMs = 0, namespace = "urn"): Promise<void> {
const client = await this.resolveClient();
await client.invalidate({
watermarkKey: this.redisWatermarkKey(urnPrefix, keyType, id),
watermarkKey: this.redisWatermarkKey(namespace, keyType, id),
futureBufferMs,
watermarkTtlFloorMs: this.watermarkTtlMs,
});
}

redisKey(key: DialCacheKey): string {
return `${this.keyPrefix}${key.urn}${REDIS_FRAME_KEY_SUFFIX}`;
return `${key.urn}${REDIS_FRAME_KEY_SUFFIX}`;
}

redisWatermarkKey(urnPrefix: string, keyType: string, id: string): string {
return `${this.keyPrefix}${redisClusterHashTag(invalidationPrefix(urnPrefix, keyType, id))}#watermark`;
redisWatermarkKey(namespace: string, keyType: string, id: string): string {
return `${redisClusterHashTag(invalidationPrefix(namespace, keyType, id))}#watermark`;
}

private redisWatermarkKeyFromKey(key: DialCacheKey): string {
return this.redisWatermarkKey(key.urnPrefix, key.keyType, key.id);
return this.redisWatermarkKey(key.namespace, key.keyType, key.id);
}

private async resolveClient(): Promise<DialCacheRedisClient> {
Expand Down
27 changes: 19 additions & 8 deletions src/key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export interface DialCacheKeyInit {
readonly id: string;
readonly useCase: string;
readonly args?: ReadonlyArray<readonly [string, string]>;
readonly urnPrefix?: string;
readonly namespace?: string;
readonly defaultConfig?: DialCacheKeyConfig | null;
readonly serializer?: Serializer<unknown> | null;
readonly trackForInvalidation?: boolean;
Expand All @@ -17,25 +17,30 @@ export class DialCacheKey {
readonly id: string;
readonly useCase: string;
readonly args: ReadonlyArray<readonly [string, string]>;
readonly urnPrefix: string;
readonly namespace: string;
readonly prefix: string;
readonly urn: string;
readonly defaultConfig: DialCacheKeyConfig | null;
readonly serializer: Serializer<unknown> | null;
readonly trackForInvalidation: boolean;

constructor(init: DialCacheKeyInit) {
if (Object.hasOwn(init, "urnPrefix")) {
throw new TypeError('DialCacheKeyInit.urnPrefix was renamed to "namespace"');
}

this.keyType = init.keyType;
this.id = init.id;
this.useCase = init.useCase;
this.args = init.args ?? [];
this.defaultConfig = init.defaultConfig ?? null;
this.serializer = init.serializer ?? null;
this.trackForInvalidation = init.trackForInvalidation ?? false;
this.urnPrefix = init.urnPrefix ?? "urn";
this.namespace = init.namespace ?? "urn";
assertValidNamespace(this.namespace);

const rawPrefix = joinUrnComponents(this.urnPrefix, this.keyType, this.id);
this.prefix = this.trackForInvalidation ? redisClusterHashTag(invalidationPrefix(this.urnPrefix, this.keyType, this.id)) : rawPrefix;
const rawPrefix = joinUrnComponents(this.namespace, this.keyType, this.id);
this.prefix = this.trackForInvalidation ? redisClusterHashTag(invalidationPrefix(this.namespace, this.keyType, this.id)) : rawPrefix;
const args = this.args.length > 0 ? `?${this.args.map(([name, value]) => `${encodeComponent(name)}=${encodeComponent(value)}`).join("&")}` : "";
this.urn = `${this.prefix}${args}#${encodeComponent(this.useCase)}`;
}
Expand All @@ -52,11 +57,17 @@ export function normalizeArgs(args: Record<string, string | number | boolean | b
.sort(([left], [right]) => compareCodePoints(left, right));
}

export function invalidationPrefix(urnPrefix: string, keyType: string, id: string): string {
assertRedisHashTagComponent("urnPrefix", urnPrefix);
export function invalidationPrefix(namespace: string, keyType: string, id: string): string {
assertValidNamespace(namespace);
assertRedisHashTagComponent("keyType", keyType);
assertRedisHashTagComponent("id", id);
return joinUrnComponents(urnPrefix, keyType, id);
return joinUrnComponents(namespace, keyType, id);
}

export function assertValidNamespace(namespace: string): void {
if (namespace.includes("{") || namespace.includes("}")) {
throw new TypeError('DialCache namespace must not contain "{" or "}"');
}
}

export function redisClusterHashTag(value: string): string {
Expand Down
Loading
Loading