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
6 changes: 2 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,6 @@ Instance-wide behavior is set through the `DialCache` constructor:
| `redis` | none | `{ client: DialCacheRedisClient }`; enables the Redis layer (see [Redis-backed TTL cache](#redis-backed-ttl-cache)). |
| `localMaxSize` | `10_000` | Global process-local entry cap; `0` disables process-local storage. Nonnegative safe integer. |
| `cacheConfigProvider` | none | Resolves runtime config per enabled invocation as a sparse overlay on the function's `defaultConfig`; `null` applies no overrides. |
| `rampSampler` | deterministic by key and layer | Percentage sampler for partial ramps; `randomRampSampler` is also exported. |
| `metrics` | disabled | A `DialCacheMetricsAdapter` (see [Metrics](#metrics)). |
| `logger` | `console` | Receives operational cache failures (`debug`, `warn`, `error`). |

Expand Down Expand Up @@ -235,7 +234,6 @@ const dialcache = new DialCache({
}
return null; // apply no overrides; use the cached function's baseline
},
rampSampler: ({ key, layer }) => deterministicPercentFor(`${key.urn}:${layer}`),
});

const getUser = dialcache.cached((userId: string) => db.fetchUser(userId), {
Expand All @@ -249,7 +247,7 @@ const getUser = dialcache.cached((userId: string) => db.fetchUser(userId), {
});
```

`ramp` values are percentages from 0 to 100. `0` disables the layer, `100` enables it, and intermediate values use `rampSampler`; the default sampler is deterministic by cache key and layer, so the same key is consistently sampled in or out of a partial rollout. DialCache fetches and resolves one config snapshot per enabled invocation. Provider errors do not activate defaults: they fail open, record `config_error`, and execute the fallback function uncached.
`ramp` values are percentages from 0 to 100. `0` disables the layer, `100` enables it, and intermediate values are deterministically sampled by cache key and layer, so the same key is consistently sampled in or out of a partial rollout across calls and instances. The assignment algorithm is owned by DialCache and remains stable across releases. Applications that need an externally coordinated cohort can use `cacheConfigProvider` to return a sparse per-key ramp override of `0` or `100`. DialCache fetches and resolves one config snapshot per enabled invocation. Provider errors do not activate defaults: they fail open, record `config_error`, and execute the fallback function uncached.

## Cache layers

Expand Down Expand Up @@ -382,7 +380,7 @@ Valkey GLIDE users should configure [`requestTimeout`](https://glide.valkey.io/l

In node-redis 4.7, `socket.connectTimeout`, `disableOfflineQueue`, and `commandsQueueMaxLength` bound connection or queue behavior but do not impose a response deadline on a dispatched command. A [per-command `AbortSignal`](https://redis.io/docs/latest/develop/clients/nodejs/produsage/) can remove work that is still waiting to be sent, but once dispatched it no longer controls the pending reply. Node-redis 4.7 has no built-in strict dispatched-response deadline, and the bundled adapter does not inject queue cancellation. Applications requiring finite node-redis settlement must supply and document a custom `DialCacheRedisClient` policy for queue removal, hung connections, and ambiguous writes. Do not put Redis writes or invalidations behind a bare `Promise.race`: rejecting the outer promise neither removes queued work nor proves that an already-dispatched command did not execute.

The same finite-settlement responsibility applies to async `cacheConfigProvider`, `rampSampler`, and custom `Serializer` methods. DialCache's [fallback deadline](#fallback-deadlines) below covers only the wrapped application function. Prefer resource-native budgets and cooperative cancellation for every injected async operation. Native budgets can bound client waiting and may prevent queued work; only a cooperating operation can guarantee that underlying work stops.
The same finite-settlement responsibility applies to async `cacheConfigProvider` and custom `Serializer` methods. DialCache's [fallback deadline](#fallback-deadlines) below covers only the wrapped application function. Prefer resource-native budgets and cooperative cancellation for every injected async operation. Native budgets can bound client waiting and may prevent queued work; only a cooperating operation can guarantee that underlying work stops.

#### Serialization

Expand Down
13 changes: 13 additions & 0 deletions scripts/test-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,9 @@ const configHasNamespace: "namespace" extends keyof DialCacheConfig ? true : fal
const configHasNoUrnPrefix: "urnPrefix" extends keyof DialCacheConfig ? false : true = true;
// @ts-expect-error urnPrefix was renamed to namespace.
const legacyNamespaceConfig: DialCacheConfig = { urnPrefix: "consumer-cache" };
const configHasNoRampSampler: "rampSampler" extends keyof DialCacheConfig ? false : true = true;
// @ts-expect-error Ramp assignment is owned internally by DialCache.
const legacyRampSamplerConfig: DialCacheConfig = { rampSampler: () => 0 };
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:" };
Expand All @@ -269,9 +272,15 @@ const missingRedisClientConfig: RedisConfig = {};
const legacyRedisFactoryConfig: RedisConfig = { createClient: () => customRedisClient };
// @ts-expect-error RedisClientFactory was removed with RedisConfig.createClient.
type LegacyRedisClientFactory = import("dialcache").RedisClientFactory;
// @ts-expect-error CacheRampSampler was removed with the public sampler override.
type LegacyCacheRampSampler = import("dialcache").CacheRampSampler;
// @ts-expect-error CacheRampSample was removed with the public sampler override.
type LegacyCacheRampSample = import("dialcache").CacheRampSample;
type DialCacheRoot = typeof import("dialcache");
const rootHasNoPrometheusFactory: "createPrometheusDialCacheMetrics" extends keyof DialCacheRoot ? false : true = true;
const rootHasNoDatadogFactory: "createDatadogDialCacheMetrics" extends keyof DialCacheRoot ? false : true = true;
const rootHasNoDeterministicRampSampler: "deterministicRampSampler" extends keyof DialCacheRoot ? false : true = true;
const rootHasNoRandomRampSampler: "randomRampSampler" extends keyof DialCacheRoot ? false : true = true;

void load;
void loadWithoutFallbackDeadline;
Expand Down Expand Up @@ -331,6 +340,8 @@ void configRejectsFalseMetrics;
void configHasNamespace;
void configHasNoUrnPrefix;
void legacyNamespaceConfig;
void configHasNoRampSampler;
void legacyRampSamplerConfig;
void redisConfigHasNoKeyPrefix;
void legacyKeyPrefixConfig;
void redisConfigRequiresClient;
Expand All @@ -339,6 +350,8 @@ void missingRedisClientConfig;
void legacyRedisFactoryConfig;
void rootHasNoPrometheusFactory;
void rootHasNoDatadogFactory;
void rootHasNoDeterministicRampSampler;
void rootHasNoRandomRampSampler;
void datadogMetrics;
void datadogClassAdapter;
void missingObservationType;
Expand Down
25 changes: 0 additions & 25 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,6 @@ export enum CacheLayer {
export type Awaitable<T> = T | Promise<T>;
export type LayerConfig = Partial<Record<CacheLayer, number>>;

export interface CacheRampSample {
readonly key: DialCacheKey;
readonly layer: CacheLayer;
readonly ramp: number;
}

/**
* Selects a rollout sample. Async implementations must settle within a finite
* application-defined deadline; DialCache does not add one.
*/
export type CacheRampSampler = (sample: CacheRampSample) => Awaitable<number>;

export const deterministicRampSampler: CacheRampSampler = ({ key, layer }) => stablePercent(`${key.urn}:${layer}`);
export const randomRampSampler: CacheRampSampler = () => Math.random() * 100;

// Tracked writes extend this floor when their value TTL is longer.
export const DEFAULT_WATERMARK_TTL_SEC = 3600 * 4;

Expand Down Expand Up @@ -113,15 +98,5 @@ export interface DialCacheConfig {
*/
readonly localMaxSize?: number;
readonly redis?: RedisConfig;
readonly rampSampler?: CacheRampSampler;
readonly metrics?: DialCacheMetricsAdapter;
}

function stablePercent(value: string): number {
let hash = 0x811c9dc5;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 0x01000193);
}
return ((hash >>> 0) / 0x1_0000_0000) * 100;
}
15 changes: 7 additions & 8 deletions src/dialcache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,8 @@ import { performance } from "node:perf_hooks";
import {
CacheLayer,
DialCacheKeyConfig,
deterministicRampSampler,
type Awaitable,
type CacheConfigProvider,
type CacheRampSampler,
type DialCacheConfig,
type Logger,
} from "./config.js";
Expand Down Expand Up @@ -195,7 +193,6 @@ export class DialCache {
private readonly configProvider: CacheConfigProvider;
private readonly namespace: string;
private readonly logger: Logger;
private readonly rampSampler: CacheRampSampler;
private readonly redisCache: RedisCache | null;
private readonly metrics: DialCacheMetricsAdapter | null;
private readonly processFlights = new Map<string, ProcessFlight>();
Expand All @@ -205,6 +202,11 @@ export class DialCache {
if (Object.hasOwn(config, "urnPrefix")) {
throw new TypeError('DialCacheConfig.urnPrefix was renamed to "namespace"');
}
if (Object.hasOwn(config, "rampSampler")) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we just remove these checks? Perhaps its better to check that whatever object is passed in only has fields defined in DialCacheConfig. is that possible in ts?

throw new TypeError(
"DialCacheConfig.rampSampler was removed; partial ramps use DialCache's deterministic key-and-layer assignment",
);
}

const namespace = config.namespace ?? "urn";
assertValidNamespace(namespace);
Expand All @@ -217,15 +219,13 @@ export class DialCache {
this.configProvider = config.cacheConfigProvider ?? defaultConfigProvider;
this.namespace = namespace;
this.logger = safeLogger(config.logger ?? defaultLogger);
this.rampSampler = config.rampSampler ?? deterministicRampSampler;
this.metrics = safeMetrics(config.metrics ?? null);
this.localCache = new LocalCache(this.configProvider, this.rampSampler, localMaxSize);
this.localCache = new LocalCache(this.configProvider, localMaxSize);
this.redisCache =
config.redis === undefined
? null
: new RedisCache({
configProvider: this.configProvider,
rampSampler: this.rampSampler,
redis: config.redis,
metrics: this.metrics,
});
Expand Down Expand Up @@ -577,11 +577,10 @@ export class DialCache {

private async resolveRemoteLayerConfig(key: DialCacheKey, keyConfig: DialCacheKeyConfig | null) {
try {
const result = await resolveLayerConfigResult({
const result = resolveLayerConfigResult({
config: keyConfig,
key,
layer: CacheLayer.REMOTE,
rampSampler: this.rampSampler,
});
if (result.status === "disabled") {
this.metrics?.disabled({ ...labelsFor(key, CacheLayer.REMOTE), reason: result.reason });
Expand Down
4 changes: 2 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export { CacheLayer, DEFAULT_WATERMARK_TTL_SEC, DialCacheKeyConfig, deterministicRampSampler, randomRampSampler } from "./config.js";
export type { CacheConfigProvider, CacheRampSample, CacheRampSampler, DialCacheConfig, LayerConfig, Logger } from "./config.js";
export { CacheLayer, DEFAULT_WATERMARK_TTL_SEC, DialCacheKeyConfig } from "./config.js";
export type { CacheConfigProvider, DialCacheConfig, LayerConfig, Logger } from "./config.js";
export { DialCacheContext } from "./context.js";
export type {
CacheMetricLabels,
Expand Down
6 changes: 2 additions & 4 deletions src/internal/local-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { performance } from "node:perf_hooks";

import { LRUCache } from "lru-cache";

import { CacheLayer, type CacheConfigProvider, type CacheRampSampler, type DialCacheKeyConfig } from "../config.js";
import { CacheLayer, type CacheConfigProvider, type DialCacheKeyConfig } from "../config.js";
import type { DialCacheKey } from "../key.js";
import type { CacheGetResult } from "./cache-result.js";
import {
Expand All @@ -23,7 +23,6 @@ export class LocalCache {

constructor(
private readonly configProvider: CacheConfigProvider,
private readonly rampSampler: CacheRampSampler,
maxSize: number,
) {
this.cache =
Expand Down Expand Up @@ -83,11 +82,10 @@ export class LocalCache {
): Promise<LayerConfigResolution> {
// Chain callers pass the once-resolved config; standalone callers omit it and we fetch.
const config = keyConfig === undefined ? await fetchKeyConfig(this.configProvider, key) : keyConfig;
return await resolveLayerConfigResult({
return resolveLayerConfigResult({
config,
key,
layer: CacheLayer.LOCAL,
rampSampler: this.rampSampler,
});
}

Expand Down
21 changes: 21 additions & 0 deletions src/internal/ramp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { CacheLayer } from "../config.js";
import type { DialCacheKey } from "../key.js";

/**
* Assigns each cache key to a stable per-layer rollout bucket in [0, 100).
*
* Keep this algorithm stable: changing it reshuffles partial-ramp cohorts
* across every DialCache instance after an upgrade.
*/
export function deterministicRampSample(key: DialCacheKey, layer: CacheLayer): number {
return stablePercent(`${key.urn}:${layer}`);
}

function stablePercent(value: string): number {
let hash = 0x811c9dc5;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 0x01000193);
}
return ((hash >>> 0) / 0x1_0000_0000) * 100;
}
8 changes: 2 additions & 6 deletions src/internal/redis-cache.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { performance } from "node:perf_hooks";

import { CacheLayer, DEFAULT_WATERMARK_TTL_SEC, type CacheConfigProvider, type CacheRampSampler, type DialCacheKeyConfig } from "../config.js";
import { CacheLayer, DEFAULT_WATERMARK_TTL_SEC, type CacheConfigProvider, type DialCacheKeyConfig } from "../config.js";
import { invalidationPrefix, redisClusterHashTag, type DialCacheKey } from "../key.js";
import { labelsFor, type DialCacheMetricsAdapter, type MetricErrorKind } from "../metrics.js";
import type { DialCacheRedisClient, RedisCachePayload } from "../redis-client.js";
Expand All @@ -22,7 +22,6 @@ export interface RedisConfig {

interface RedisCacheOptions {
readonly configProvider: CacheConfigProvider;
readonly rampSampler: CacheRampSampler;
readonly redis: RedisConfig;
readonly metrics: DialCacheMetricsAdapter | null;
}
Expand All @@ -32,7 +31,6 @@ const REDIS_FRAME_KEY_SUFFIX = ":dialcache-frame-v1";

export class RedisCache {
private readonly configProvider: CacheConfigProvider;
private readonly rampSampler: CacheRampSampler;
private readonly defaultSerializer: Serializer<unknown>;
private readonly watermarkTtlMs: number;
private readonly client: DialCacheRedisClient;
Expand All @@ -49,7 +47,6 @@ export class RedisCache {
}

this.configProvider = options.configProvider;
this.rampSampler = options.rampSampler;
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 @@ -177,11 +174,10 @@ export class RedisCache {

private async resolveRemoteLayerConfig(key: DialCacheKey, keyConfig?: DialCacheKeyConfig | null) {
const config = keyConfig === undefined ? await fetchKeyConfig(this.configProvider, key) : keyConfig;
return await resolveLayerConfigResult({
return resolveLayerConfigResult({
config,
key,
layer: CacheLayer.REMOTE,
rampSampler: this.rampSampler,
});
}

Expand Down
16 changes: 6 additions & 10 deletions src/internal/runtime-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ import {
CacheLayer,
DialCacheKeyConfig,
type CacheConfigProvider,
type CacheRampSampler,
type LayerConfig,
} from "../config.js";
import type { DialCacheKey } from "../key.js";
import type { DisabledReason } from "../metrics.js";
import { deterministicRampSample } from "./ramp.js";

export interface ResolvedLayerConfig {
readonly ttlSec: number;
Expand All @@ -21,7 +21,6 @@ interface ResolveLayerConfigOptions {
readonly config: DialCacheKeyConfig | null;
readonly key: DialCacheKey;
readonly layer: CacheLayer;
readonly rampSampler: CacheRampSampler;
}

export async function fetchKeyConfig(
Expand All @@ -36,12 +35,12 @@ export async function fetchKeyConfig(
return mergeKeyConfig(defaultConfig, runtimeConfig);
}

export async function resolveLayerConfig(options: ResolveLayerConfigOptions): Promise<ResolvedLayerConfig | null> {
const resolution = await resolveLayerConfigResult(options);
export function resolveLayerConfig(options: ResolveLayerConfigOptions): ResolvedLayerConfig | null {
const resolution = resolveLayerConfigResult(options);
return resolution.status === "enabled" ? resolution.config : null;
}

export async function resolveLayerConfigResult(options: ResolveLayerConfigOptions): Promise<LayerConfigResolution> {
export function resolveLayerConfigResult(options: ResolveLayerConfigOptions): LayerConfigResolution {
const config = options.config;
if (config === null) {
return { status: "disabled", reason: "policy_disabled" };
Expand Down Expand Up @@ -69,12 +68,9 @@ export async function resolveLayerConfigResult(options: ResolveLayerConfigOption
return { status: "enabled", config: { ttlSec, ramp } };
}

const sample = await options.rampSampler({ key: options.key, layer: options.layer, ramp });
if (!Number.isFinite(sample)) {
return { status: "disabled", reason: "ramped_down" };
}
const sample = deterministicRampSample(options.key, options.layer);

return clampPercentage(sample) < ramp
return sample < ramp
? { status: "enabled", config: { ttlSec, ramp } }
: { status: "disabled", reason: "ramped_down" };
}
Expand Down
Loading
Loading