Skip to content
Open
51 changes: 34 additions & 17 deletions README.md

Large diffs are not rendered by default.

22 changes: 19 additions & 3 deletions scripts/test-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ const rootConsumer = `import {
type Serializer,
type ShadowComparator,
type ShadowConfig,
type ShadowMismatchLoggingConfig,
type ShadowValidationMetricLabels,
type ShadowValidationOutcome,
} from "dialcache";
Expand Down Expand Up @@ -147,9 +148,14 @@ const shadowCacheConfig: DialCacheConfig = {
shadowMaxInFlight: 2,
};
const shadowCache = new DialCache(shadowCacheConfig);
const shadowMismatchLoggingConfig: ShadowMismatchLoggingConfig = {
key: true,
value: true,
diff: true,
};
const shadowConfig: ShadowConfig = {
ramp: 50,
logMismatches: true,
mismatchLogging: shadowMismatchLoggingConfig,
};
const shadowKeyConfig = new DialCacheKeyConfig({ shadow: shadowConfig });
const dogStatsDClient: DatadogDogStatsDClient = {
Expand Down Expand Up @@ -203,6 +209,11 @@ const load = cache.cached(async (id: string) => id, {
cacheKey: (id) => id,
fallbackTimeoutMs: 1_000,
shadowComparator: stringShadowComparator,
shadowMismatchLogValue: (value) => value.length,
shadowMismatchLogDiff: (cachedValue, sourceValue) => ({
cachedLength: cachedValue.length,
sourceLength: sourceValue.length,
}),
defaultConfig: new DialCacheKeyConfig({
ttlSec: { [CacheLayer.LOCAL]: 60, [CacheLayer.REMOTE]: 60 },
ramp: { [CacheLayer.LOCAL]: 100, [CacheLayer.REMOTE]: 100 },
Expand Down Expand Up @@ -361,6 +372,7 @@ const legacyMissingConfigReason: DisabledReason = "missing_config";
const metricErrorKinds: Readonly<Record<MetricErrorKind, true>> = {
key_construction: true,
config_resolution: true,
config_unknown_field: true,
cache_read: true,
cache_read_timeout: true,
cache_write: true,
Expand Down Expand Up @@ -909,7 +921,9 @@ if (
esmDisabledOverlay.requestLocal !== false
|| esmDisabledOverlay.coalesce !== undefined
|| esmDisabledOverlay.shadow?.ramp !== 0
|| esmDisabledOverlay.shadow.logMismatches !== false
|| esmDisabledOverlay.shadow.mismatchLogging?.key !== false
|| esmDisabledOverlay.shadow.mismatchLogging.value !== false
|| esmDisabledOverlay.shadow.mismatchLogging.diff !== false
|| esmDisabledOverlay.ramp[root.CacheLayer.LOCAL] !== 0
|| esmDisabledOverlay.ramp[root.CacheLayer.REMOTE] !== 0
) {
Expand Down Expand Up @@ -1284,7 +1298,9 @@ if (
cjsDisabledOverlay.requestLocal !== false
|| cjsDisabledOverlay.coalesce !== undefined
|| cjsDisabledOverlay.shadow?.ramp !== 0
|| cjsDisabledOverlay.shadow.logMismatches !== false
|| cjsDisabledOverlay.shadow.mismatchLogging?.key !== false
|| cjsDisabledOverlay.shadow.mismatchLogging.value !== false
|| cjsDisabledOverlay.shadow.mismatchLogging.diff !== false
|| cjsDisabledOverlay.ramp[root.CacheLayer.LOCAL] !== 0
|| cjsDisabledOverlay.ramp[root.CacheLayer.REMOTE] !== 0
) {
Expand Down
174 changes: 154 additions & 20 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,108 @@ export enum CacheLayer {
export type Awaitable<T> = T | Promise<T>;
export type LayerConfig = Partial<Record<CacheLayer, number>>;

/**
* Content controls for the one warning emitted per confirmed shadow mismatch.
* Every field defaults to false; the warning is emitted only when at least one
* field is true. Fields merge independently at runtime, like cache-layer leaves.
*/
export interface ShadowMismatchLoggingConfig {
/** Include the logical cache key (the DialCache URN, byte-capped). */
readonly key?: boolean;
/**
* Include bounded native-JSON strings for the compared values. Values pass
* through the use case's `shadowMismatchLogValue` projection when one is
* defined and are logged raw otherwise.
*/
readonly value?: boolean;
/**
* Include a bounded JSON diff of the compared values: the use case's
* `shadowMismatchLogDiff` result when defined, and otherwise a structural
* diff of the same loggable forms `value` uses.
*/
readonly diff?: boolean;
}

/** Per-use-case runtime policy for detached Redis shadow work. */
export interface ShadowConfig {
/** Independent stable cohort percentage. Omitted and zero disable shadow work. */
readonly ramp?: number;
/**
* Emit one warning with the logical key and bounded native-JSON strings for
* the compared values for each confirmed mismatch. Defaults to false.
*/
readonly logMismatches?: boolean;
/** Confirmed-mismatch warning content. Omitted, empty, and all-false disable the warning. */
readonly mismatchLogging?: ShadowMismatchLoggingConfig;
}

interface DialCacheKeyConfigInput {
readonly ttlSec?: LayerConfig;
readonly ramp?: LayerConfig;
readonly shadow?: ShadowConfig;
readonly requestLocal?: boolean;
readonly coalesce?: boolean;
readonly remoteReadTimeoutMs?: number;
}

// `satisfies Record<keyof …, true>` fails to compile when the interface gains
// a field this set is missing, so every leaf loop stays exhaustive.
const SHADOW_MISMATCH_LOGGING_LEAF_SET = {
key: true,
value: true,
diff: true,
} as const satisfies Record<keyof ShadowMismatchLoggingConfig, true>;

/** Internal: the exhaustive `ShadowMismatchLoggingConfig` field list. */
export const SHADOW_MISMATCH_LOGGING_LEAVES = Object.keys(
SHADOW_MISMATCH_LOGGING_LEAF_SET,
) as readonly (keyof ShadowMismatchLoggingConfig)[];

const KEY_CONFIG_FIELD_SET = {
ttlSec: true,
ramp: true,
shadow: true,
requestLocal: true,
coalesce: true,
remoteReadTimeoutMs: true,
} as const satisfies Record<keyof DialCacheKeyConfigInput, true>;
const KEY_CONFIG_FIELDS = Object.keys(KEY_CONFIG_FIELD_SET);
const SHADOW_CONFIG_FIELD_SET = {
ramp: true,
mismatchLogging: true,
} as const satisfies Record<keyof ShadowConfig, true>;
const SHADOW_CONFIG_FIELDS = Object.keys(SHADOW_CONFIG_FIELD_SET);
const UNKNOWN_KEY_CONFIG_FIELDS = Symbol("DialCacheKeyConfig.unknownFields");

interface UnknownFieldMarkedConfig {
readonly [UNKNOWN_KEY_CONFIG_FIELDS]?: true;
}

/** Internal: reports unknown own string fields without exposing their names. */
export function hasUnknownKeyConfigFields(config: unknown): boolean {
if (!isConfigObject(config)) {
return false;
}
if (
Object.hasOwn(config, UNKNOWN_KEY_CONFIG_FIELDS)
&& (config as UnknownFieldMarkedConfig)[UNKNOWN_KEY_CONFIG_FIELDS] === true
) {
return true;
}
if (hasUnknownOwnFields(config, KEY_CONFIG_FIELDS)) {
return true;
}

const ttlSec = readOwnUnknown(config, "ttlSec");
const ramp = readOwnUnknown(config, "ramp");
const shadow = readOwnUnknown(config, "shadow");
if (
hasUnknownOwnFields(ttlSec, Object.values(CacheLayer))
|| hasUnknownOwnFields(ramp, Object.values(CacheLayer))
|| hasUnknownOwnFields(shadow, SHADOW_CONFIG_FIELDS)
) {
return true;
}

const mismatchLogging = isConfigObject(shadow)
? readOwnUnknown(shadow, "mismatchLogging")
: undefined;
return hasUnknownOwnFields(mismatchLogging, SHADOW_MISMATCH_LOGGING_LEAVES);
}

export class DialCacheKeyConfig {
Expand All @@ -46,23 +139,17 @@ export class DialCacheKeyConfig {
*/
readonly remoteReadTimeoutMs?: number;

constructor(config: {
ttlSec?: LayerConfig;
ramp?: LayerConfig;
shadow?: ShadowConfig;
requestLocal?: boolean;
coalesce?: boolean;
remoteReadTimeoutMs?: number;
}) {
constructor(config: DialCacheKeyConfigInput) {
if (config === null || typeof config !== "object" || Array.isArray(config)) {
throw new TypeError("DialCache key config must be an object");
}
if (Object.hasOwn(config, "shadowRamp")) {
throw new TypeError('DialCacheKeyConfig.shadowRamp was replaced by "shadow.ramp"');
}
const hasUnknownFields = hasUnknownKeyConfigFields(config);
this.ttlSec = cloneLayerConfig(config.ttlSec, "ttlSec");
this.ramp = cloneLayerConfig(config.ramp, "ramp");
const shadow = cloneShadowConfig(config.shadow);
// Own-property read: `shadow` carries the log-content controls, so a
// prototype-inherited group must not activate policy (its leaves would
// all be own properties and pass every later gate).
const shadow = cloneShadowConfig(Object.hasOwn(config, "shadow") ? config.shadow : undefined);
if (shadow !== undefined) {
this.shadow = shadow;
}
Expand All @@ -82,6 +169,9 @@ export class DialCacheKeyConfig {
assertValidDeadlineMs(config.remoteReadTimeoutMs, "DialCache remoteReadTimeoutMs");
this.remoteReadTimeoutMs = config.remoteReadTimeoutMs;
}
if (hasUnknownFields) {
Object.defineProperty(this, UNKNOWN_KEY_CONFIG_FIELDS, { value: true });
}
}

static enabled(ttlSec: number): DialCacheKeyConfig {
Expand Down Expand Up @@ -109,7 +199,14 @@ export class DialCacheKeyConfig {
requestLocal: false,
shadow: {
ramp: 0,
logMismatches: false,
// `Required` keeps this kill-switch overlay exhaustive: leaves merge
// independently, so an omitted leaf would let an inherited `true`
// survive disabled().
mismatchLogging: {
key: false,
value: false,
diff: false,
} satisfies Required<ShadowMismatchLoggingConfig>,
},
ramp: {
[CacheLayer.LOCAL]: 0,
Expand All @@ -126,7 +223,14 @@ function cloneLayerConfig(config: LayerConfig | undefined, name: "ttlSec" | "ram
if (config === null || typeof config !== "object" || Array.isArray(config)) {
throw new TypeError(`DialCache ${name} config must be a layer map`);
}
return { ...config };
const clone: LayerConfig = {};
for (const layer of Object.values(CacheLayer)) {
const value = Object.hasOwn(config, layer) ? config[layer] : undefined;
if (value !== undefined) {
clone[layer] = value;
}
}
return clone;
}

function cloneShadowConfig(config: ShadowConfig | undefined): ShadowConfig | undefined {
Expand All @@ -136,7 +240,37 @@ function cloneShadowConfig(config: ShadowConfig | undefined): ShadowConfig | und
if (config === null || typeof config !== "object" || Array.isArray(config)) {
throw new TypeError("DialCache shadow config must be an object");
}
return { ...config };
const ramp = Object.hasOwn(config, "ramp") ? config.ramp : undefined;
const mismatchLogging = Object.hasOwn(config, "mismatchLogging") ? config.mismatchLogging : undefined;
if (mismatchLogging === undefined) {
return ramp === undefined ? {} : { ramp };
}
if (mismatchLogging === null || typeof mismatchLogging !== "object" || Array.isArray(mismatchLogging)) {
throw new TypeError("DialCache shadow mismatchLogging config must be an object");
}
const clonedLogging: Record<string, unknown> = {};
for (const leaf of SHADOW_MISMATCH_LOGGING_LEAVES) {
if (Object.hasOwn(mismatchLogging, leaf)) {
clonedLogging[leaf] = mismatchLogging[leaf];
}
}
return {
...(ramp === undefined ? {} : { ramp }),
mismatchLogging: clonedLogging as ShadowMismatchLoggingConfig,
};
}

function isConfigObject(value: unknown): value is Record<PropertyKey, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}

function hasUnknownOwnFields(value: unknown, knownFields: readonly string[]): boolean {
return isConfigObject(value)
&& Object.keys(value).some((name) => !knownFields.includes(name));
}

function readOwnUnknown(source: Record<PropertyKey, unknown>, key: string): unknown {
return Object.hasOwn(source, key) ? source[key] : undefined;
}

/**
Expand Down
Loading
Loading