From 8059d2c06e20931d29d11c16bea416b25c30f2ed Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Thu, 23 Jul 2026 20:27:12 -0700 Subject: [PATCH 1/2] feat: add inline getOrLoad API --- README.md | 58 ++-- scripts/test-package.mjs | 62 +++++ src/dialcache.ts | 173 +++++++----- src/index.ts | 1 + test/dialcache-get-or-load.test.ts | 399 ++++++++++++++++++++++++++++ test/redis-real.integration.test.ts | 18 +- 6 files changed, 634 insertions(+), 77 deletions(-) create mode 100644 test/dialcache-get-or-load.test.ts diff --git a/README.md b/README.md index 3edfbef..2bca3bf 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Fine-grained TypeScript caching with explicit enabled contexts, request-local me - [How caching works](#how-caching-works) - [Enabled context](#enabled-context) - [Defining cached functions](#defining-cached-functions) + - [One-shot inline cache blocks](#one-shot-inline-cache-blocks) - [Keys, ids, and extra dimensions](#keys-ids-and-extra-dimensions) - [Runtime config and ramp controls](#runtime-config-and-ramp-controls) - [Cache layers](#cache-layers) @@ -103,13 +104,13 @@ await dialcache.enable(async () => { }); ``` -- Default is disabled — a cached function called **outside** any `enable()` scope simply runs uncached (no error), so wrap your read paths to actually cache. +- Default is disabled — `cached()` and `getOrLoad()` calls made **outside** any `enable()` scope simply run their loader uncached (no error), so wrap your read paths to actually cache. - Enabled state is async-scope-local, not process-global. - Nested `enable` / `disable` scopes restore the previous behavior when the callback completes. Nested `enable()` calls reuse the outer request-local scope rather than creating a new one. ## Defining cached functions -`cached(fn, options)` wraps a function; the wrapped callable has the same parameters and always returns a `Promise`. +Use `cached(fn, options)` for an extracted, reusable function. The wrapped callable has the same parameters and always returns a `Promise`. For a one-shot calculation that should remain inline, use [`getOrLoad()`](#one-shot-inline-cache-blocks). | Option | Required | Description | | --- | --- | --- | @@ -121,13 +122,38 @@ await dialcache.enable(async () => { | `trackForInvalidation` | no (default `false`) | Opts this use case's Redis entries into watermark-based targeted invalidation. | | `fallbackTimeoutMs` | no (default `60_000`) | Fallback deadline in milliseconds, at most 2,147,483,647; `null` disables it (see [Fallback deadlines](#fallback-deadlines)). | -`useCase` is validated at registration: a duplicate within one `DialCache` instance throws `UseCaseIsAlreadyRegisteredError`, and the internal name `watermark` throws `UseCaseNameIsReservedError`. +`cached()` validates `useCase` at registration: a duplicate within one `DialCache` instance throws `UseCaseIsAlreadyRegisteredError`. Both APIs reject the internal name `watermark` with `UseCaseNameIsReservedError`. + +### One-shot inline cache blocks + +`getOrLoad(load, options)` runs one zero-argument loader through the same policy, cache layers, coalescing, invalidation, metrics, serialization, deadlines, and fail-open behavior as `cached()`. It is useful when only part of a larger function should be cached and the loader needs to capture local values: + +```ts +const profile = await dialcache.getOrLoad( + async () => { + const user = await db.getUser(userId); + return renderProfile(user, locale); + }, + { + keyType: "user_id", + useCase: "BuildProfile", + key: { id: userId, args: { locale } }, + defaultConfig: DialCacheKeyConfig.enabled(60), + }, +); +``` + +The options match `cached()` except that the direct `key` replaces the `cacheKey` selector. `defaultConfig` and `fallbackTimeoutMs` are validated and snapshotted for each invocation. Outside an enabled scope, DialCache invokes `load` directly without constructing a key or resolving runtime policy. + +`getOrLoad()` does not register its `useCase`, so repeated calls should reuse one stable, deployment-defined name such as `"BuildProfile"`. Keep it bounded: never derive `useCase` from a user, request, id, or other high-cardinality input because it is part of both cache identity and metrics labels. Put those values in `key` instead. + +Every captured value that can change the result belongs in the bare id or `{ id, args }` key. Concurrent same-key calls may share one caller's in-flight loader and cached value, so all call sites for that identity must also agree on value meaning and serialization. Prefer `cached()` when a loader is reusable; prefer `getOrLoad()` when the calculation is intentionally local to one call site. ## Keys, ids, and extra dimensions -The cache key comes from the required `cacheKey` selector whose parameters are inferred from `fn`. Return a bare id, or return `{ id, args }` to extract a field or add secondary dimensions: +For `cached()`, the key comes from the required `cacheKey` selector whose parameters are inferred from `fn`. `getOrLoad()` accepts the same bare id or `{ id, args }` shape directly through `key`: -The `cacheKey` selector is the value identity contract. It must include every input dimension that can affect the returned value; otherwise distinct calls can reuse the same cached value or share the same in-flight fallback through request coalescing. +The selected or direct key is the value identity contract. It must include every input dimension that can affect the returned value; otherwise distinct calls can reuse the same cached value or share the same in-flight fallback through request coalescing. ```ts const searchPosts = dialcache.cached( @@ -156,7 +182,7 @@ That produces Redis keys beginning with `users-api:...`, or `{users-api:...}` fo - **`keyType` + `id` is the invalidation unit for tracked Redis entries.** `dialcache.invalidateRemote("user_id", "123", futureBufferMs)` writes one watermark for that user; any `trackForInvalidation` Redis entry with the same `keyType` and `id` is refreshed across all `args` variants when Redis is read. `invalidateRemote` does not evict existing request-local or process-local entries (see [Targeted invalidation](#targeted-invalidation-and-watermarks)), and untracked Redis entries do not consult the watermark. `useCase` identifies the individual cache (it's the metrics label and part of the stored key). - **`args` are part of the cache key** — different `args` produce different entries — but invalidation is by `id` only. - **Scalar key equality is string-based.** Runtime type is not an identity dimension: for matching surrounding dimensions, numeric `1`, string `"1"`, and bigint `1n` identify the same key; argument values `null` and `"null"` also match. `-0` matches `0`, and an `undefined` argument is omitted. If a deployment changes the logical meaning represented by a scalar, change an explicit identity dimension such as `keyType`, `useCase`, or an argument name/value. -- **Non-key inputs** (for example a db handle) are simply parameters the `cacheKey` selector ignores. They still reach `fn` for non-coalesced executions, but concurrent same-key cache misses share the leader's execution, so do not ignore values like `AbortSignal`, auth context, locale, or other request-scoped inputs unless sharing one result is correct. +- **Non-key inputs** (for example a db handle) are parameters ignored by a `cacheKey` selector or values captured by a `getOrLoad()` loader. They still reach non-coalesced executions, but concurrent same-key cache misses share the leader's execution, so do not omit values like auth context, locale, or cancellation behavior unless sharing one result is correct. - **Methods:** pass `obj.method.bind(obj)` (or `(...a) => obj.method(...a)`) — a bare `obj.method` reference loses `this`. Changing the namespace value intentionally creates a cold-cache boundary across every layer. Old and new keyspaces do not share Redis values or invalidation watermarks. During an overlapping deployment, an invalidation handled by one version is invisible to the other, which can continue serving a stale tracked value until its value TTL expires. If remote invalidation correctness matters, a normal rolling deployment is unsafe: use a coordinated no-overlap cutover, or an operational bridge that prevents both versions from serving remote cache across mutations (for example, temporarily disable and clear remote caching during the transition). After the cutover, provision for fallback/refill load and allow old Redis keys to expire by TTL. @@ -177,7 +203,7 @@ Instance-wide behavior is set through the `DialCache` constructor: Per-invocation cache policy is a `DialCacheKeyConfig`: per-layer `ttlSec` and `ramp` maps keyed by `CacheLayer.LOCAL` (process-local) and `CacheLayer.REMOTE` (Redis), plus a `requestLocal` boolean. -Every cached function can provide an optional per-use-case `defaultConfig`. It is the baseline policy, and the `cacheConfigProvider` result is a sparse field-level overlay on that baseline. Precedence is: runtime field, then `defaultConfig` field, then DialCache's disabled baseline. +Every cached definition or `getOrLoad()` invocation can provide an optional per-use-case `defaultConfig`. It is the baseline policy, and the `cacheConfigProvider` result is a sparse field-level overlay on that baseline. Precedence is: runtime field, then `defaultConfig` field, then DialCache's disabled baseline. The disabled baseline sets `requestLocal` to false and leaves the process-local and Redis TTLs unset. A shared layer with no effective TTL is disabled by policy. When a shared layer has an effective TTL but no effective ramp, its ramp defaults to 100%. @@ -185,13 +211,13 @@ The disabled baseline sets `requestLocal` to false and leaves the process-local A provider result of `null` (or defensive `undefined`) applies no overrides. An empty `DialCacheKeyConfig` and omitted runtime fields also inherit the baseline. Use explicit values to override inherited policy: `requestLocal: false` disables request-local caching and a layer ramp of `0` disables that shared layer. `DialCacheKeyConfig.disabled()` is that explicit kill switch in one call: request-local off and both shared layers ramped to 0. -DialCache validates `defaultConfig` when `cached()` registers the definition: TTLs must be positive safe integers, ramps must be finite percentages from 0 to 100, layer maps must be objects, and `requestLocal` must be a boolean when present. Invalid defaults are rejected immediately. +DialCache validates `defaultConfig` when `cached()` registers a definition and whenever `getOrLoad()` is invoked: TTLs must be positive safe integers, ramps must be finite percentages from 0 to 100, layer maps must be objects, and `requestLocal` must be a boolean when present. Invalid defaults are rejected immediately. -Registration captures an immutable internal snapshot of `defaultConfig`; mutating the supplied config or its maps later does not change the use case's baseline. Runtime policy changes belong in the provider's returned overlay. +Each registration or one-shot invocation captures an immutable internal snapshot of `defaultConfig`; mutating the supplied config or its maps later does not change that operation's baseline. Runtime policy changes belong in the provider's returned overlay. Runtime TTL and ramp leaves are used as supplied instead of falling back to valid default leaves. An invalid TTL disables that layer with `invalid_ttl`; a non-finite or nonnumeric ramp disables it with `invalid_ramp`; finite runtime ramps retain the defensive clamp to 0–100. Other layers can still run, and invalid leaves also record a `config_resolution` error so provider garbage is alertable separately from intentional ramp-downs. A malformed runtime config object, layer-map shape, or `requestLocal` value fails config resolution for the invocation, records `config_error`, and executes the fallback uncached. -`cacheConfigProvider` is called for every enabled cached-function invocation before DialCache performs any cache lookup. Keep it cheap, cache any remote/config-store reads inside the provider, and avoid work that would erase the benefit of a cache hit. +`cacheConfigProvider` is called for every enabled cache invocation before DialCache performs any cache lookup. Keep it cheap, cache any remote/config-store reads inside the provider, and avoid work that would erase the benefit of a cache hit. ```ts import { CacheLayer, DialCache, DialCacheKeyConfig } from "dialcache"; @@ -368,7 +394,7 @@ byte 10 payload encoding (0 = UTF-8, 1 = raw binary) bytes 11... serialized payload ``` -Redis's Lua `struct` library packs and unpacks the timestamp. Redis TTL is authoritative, so expiry metadata is not duplicated in the frame. `payload` is produced by the cached function's serializer, or by `JsonSerializer` by default. Custom serializers can return either `string` or `Buffer`; strings are stored as UTF-8 and Buffers are stored byte-for-byte without base64 expansion. Adapters restore the same representation before calling `serializer.load`. +Redis's Lua `struct` library packs and unpacks the timestamp. Redis TTL is authoritative, so expiry metadata is not duplicated in the frame. `payload` is produced by the operation's serializer, or by `JsonSerializer` by default. Custom serializers can return either `string` or `Buffer`; strings are stored as UTF-8 and Buffers are stored byte-for-byte without base64 expansion. Adapters restore the same representation before calling `serializer.load`. DialCache uses native `JSON.stringify` and `JSON.parse` by default. There is no runtime validation pass, so the default adds no traversal beyond JSON serialization itself. A top-level `undefined` result is supported with an internal sentinel. @@ -376,7 +402,7 @@ When `serializer.load` rejects a Redis payload, DialCache records a `serializati `JsonSerializer` validates JSON syntax only. It cannot detect that a structurally valid payload came from an incompatible application value schema. Applications that keep the same `useCase` across deployments must keep default-JSON values backward compatible. For an incompatible change, either provide a serializer whose `load` method validates and rejects the old shape, or change `useCase` to isolate the new cache entries. During a mixed deployment, mutually incompatible validating serializers can repeatedly reject and replace each other's values; correctness is preserved, but expect additional fallback and Redis-write load until the rollout converges. -When a cached function's resolved return type is statically JSON-compatible, `serializer` remains optional. This includes JSON primitives, arrays, plain object/interface shapes, optional object fields, and a top-level `undefined`. Types known not to survive the default round trip require a per-function `Serializer`: +When a cached function or inline loader's resolved return type is statically JSON-compatible, `serializer` remains optional. This includes JSON primitives, arrays, plain object/interface shapes, optional object fields, and a top-level `undefined`. Types known not to survive the default round trip require a typed `Serializer`: ```ts import { DialCache, type Serializer } from "dialcache"; @@ -398,7 +424,7 @@ const getUpdatedAt = dialcache.cached( ); ``` -The compile-time guard rejects known incompatible shapes such as `Date`, `Map`, `Set`, `bigint`, symbols, functions, Buffers, typed arrays, method-bearing class instances, required nested `undefined`, `unknown`, and `any`. It applies to every `cached()` declaration because active layers are selected at runtime. A global Redis serializer is not parameterized by each cached return type, so it cannot discharge this requirement; non-JSON functions must select a typed per-function serializer. +The compile-time guard rejects known incompatible shapes such as `Date`, `Map`, `Set`, `bigint`, symbols, functions, Buffers, typed arrays, method-bearing class instances, required nested `undefined`, `unknown`, and `any`. It applies to every `cached()` declaration and `getOrLoad()` invocation because active layers are selected at runtime. A global Redis serializer is not parameterized by each returned type, so it cannot discharge this requirement; non-JSON operations must select a typed serializer. This guard is deliberately conservative and is not a proof of runtime data. TypeScript cannot detect non-finite numbers, cyclic/shared references, runtime getter or `toJSON` behavior, or data-only class instances that look like plain objects. Opaque, generic, or deeply recursive types may also require an explicit serializer. Providing `Serializer` (including an explicitly typed `JsonSerializer`) is a trusted caller assertion; DialCache does not serialize-and-deserialize again to validate it. @@ -489,11 +515,11 @@ With Redis configured, an instance-scoped leader that misses the process-local c Coalescing only applies when at least one cache layer is active. Calls outside `enable()` are true pass-through. Calls where request-local, process-local, and Redis are all disabled are uncached and uncoalesced, but because they were initially enabled, the fallback deadline below still applies. -Because coalescing is keyed by `cacheKey`, concurrent calls with the same key share the leader's execution. Any argument ignored by `cacheKey` must be safe to share this way; include inputs such as locale, auth context, or cancellation behavior in the key when they can change the returned value or whether the underlying function should run separately. +Because coalescing is keyed by the selected or direct key, concurrent calls with the same key share the leader's execution. Any function argument or captured value omitted from the key must be safe to share this way; include inputs such as locale, auth context, or cancellation behavior when they can change the returned value or whether the underlying loader should run separately. ### Fallback deadlines -Once an initially enabled invocation starts its fallback, DialCache applies a 60-second monotonic deadline by default. Set `fallbackTimeoutMs` once on a cached wrapper to choose a positive integer deadline in milliseconds, up to 2,147,483,647, or set it to `null` to preserve an intentionally unbounded fallback: +Once an initially enabled invocation starts its fallback, DialCache applies a 60-second monotonic deadline by default. Set `fallbackTimeoutMs` once on a cached wrapper or on each `getOrLoad()` invocation to choose a positive integer deadline in milliseconds, up to 2,147,483,647, or set it to `null` to preserve an intentionally unbounded fallback: ```ts import { FallbackTimeoutError } from "dialcache"; @@ -521,7 +547,7 @@ try { } ``` -The timer starts only when the fallback begins. Same-key followers share the process or request-local leader's remaining budget and receive its `FallbackTimeoutError`; pass-through invocations where every layer is disabled have independent timers. Cache hits create no fallback timer. Calls that were initially outside an enabled context remain true pass-through and are not timed out, even when the wrapper configures `fallbackTimeoutMs`. +The timer starts only when the fallback begins. Same-key followers share the process or request-local leader's remaining budget and receive its `FallbackTimeoutError`; pass-through invocations where every layer is disabled have independent timers. Cache hits create no fallback timer. Calls that were initially outside an enabled context remain true pass-through and are not timed out, even when the operation configures `fallbackTimeoutMs`. Deadline delivery requires the JavaScript event loop to make progress. It cannot preempt a synchronous fallback prefix or other event-loop blocking, so rejection can arrive later than the configured duration; when control returns, DialCache checks the monotonic deadline before accepting the result. The deadline timer remains referenced until the fallback settles or times out. Consequently, an abandoned enabled fallback can keep an otherwise idle short-lived process alive until that deadline; shutdown code should drain outstanding DialCache work rather than discarding its promises. diff --git a/scripts/test-package.mjs b/scripts/test-package.mjs index fe56536..1864fc6 100644 --- a/scripts/test-package.mjs +++ b/scripts/test-package.mjs @@ -28,6 +28,7 @@ const rootConsumer = `import { type DialCacheMetricsAdapter, type DialCacheRedisClient, type DisabledReason, + type GetOrLoadOptions, type InvalidationMetricLabels, type MetricErrorKind, type ProcessCoalescingState, @@ -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, @@ -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; @@ -127,6 +141,11 @@ const dateOptions: CachedOptions = { ...optionsFor("TypedDateOptions"), serializer: dateSerializer, }; +const inlineDateOptions: GetOrLoadOptions = { + ...inlineOptionsFor("InlineDateWithSerializer"), + serializer: dateSerializer, +}; +const inlineDate: Promise = cache.getOrLoad(async () => new Date(0), inlineDateOptions); class MethodBearingValue { constructor(readonly id: string) {} @@ -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 = 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 = inlineOptionsFor("TypedInlineDateWithoutSerializer"); const requestLocalConfig = new DialCacheKeyConfig({ requestLocal: true }); const structuralConfigProvider: CacheConfigProvider = () => ({ @@ -252,6 +275,8 @@ const rootHasNoDatadogFactory: "createDatadogDialCacheMetrics" extends keyof Dia void load; void loadWithoutFallbackDeadline; +void inlineSync; +void inlineAsync; void loadJsonRecord; void loadEmptyObject; void loadUndefined; @@ -259,7 +284,10 @@ void loadVoid; void loadDate; void loadDateWithTrustedJsonAssertion; void dateOptions; +void inlineDateOptions; +void inlineDate; void missingDateSerializer; +void missingInlineDateSerializer; void requestLocalConfig; void structuralConfigProvider; void requestLocalCoalescingLabels; @@ -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; @@ -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 }, @@ -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; diff --git a/src/dialcache.ts b/src/dialcache.ts index 0f8b133..d7c252d 100644 --- a/src/dialcache.ts +++ b/src/dialcache.ts @@ -100,7 +100,7 @@ type IsJsonObject = [keyof T : false; }[keyof T]>; -interface CachedOptionsBase { +interface CacheOperationOptionsBase { readonly keyType: string; readonly useCase: string; readonly defaultConfig?: DialCacheKeyConfig | null; @@ -117,6 +117,9 @@ interface CachedOptionsBase { * published by DialCache, but does not cancel the underlying operation. */ readonly fallbackTimeoutMs?: number | null; +} + +interface CachedOptionsBase 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. @@ -127,6 +130,9 @@ interface CachedOptionsBase { type SerializerOption = IsJsonCompatible extends true ? { readonly serializer?: Serializer | null } : { readonly serializer: Serializer }; +type CacheOperationOptions = CacheOperationOptionsBase & { + readonly serializer?: Serializer | null; +}; /** * Options for a cached function. A serializer is required when the function's @@ -136,6 +142,22 @@ type SerializerOption = IsJsonCompatible extends true */ export type CachedOptions = CachedOptionsBase & SerializerOption>; +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 = GetOrLoadOptionsBase & SerializerOption; + /** * A cached function returns references owned by the cache. Treat returned * values as immutable; callers that need to mutate must copy them explicitly. @@ -251,67 +273,94 @@ export class DialCache { const fallbackTimeoutMs = resolveFallbackTimeoutMs(options.fallbackTimeoutMs); this.registerUseCase(options.useCase); - const run = async (...args: Parameters): Promise> => { - // `fn`'s awaited result is the cached value by construction; the generic `Fn` erases it to `unknown`. - const rawFallback = async (): Promise> => (await fn(...args)) as CachedValue; - const noLayerLabels = { - cacheNamespace: this.namespace, - useCase: options.useCase, - keyType: options.keyType, - layer: NO_CACHE_LAYER, - } as const; + return (...args: Parameters): Promise> => + this.executeCacheOperation( + // `Fn` preserves the public parameter and return types, but its + // `AnyFn` constraint erases the invocation result to `unknown`. + () => fn(...args) as Awaitable>, + () => 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(load: () => Awaitable, options: GetOrLoadOptions): Promise { + const defaultConfig = snapshotDefaultConfig(options.defaultConfig); + const fallbackTimeoutMs = resolveFallbackTimeoutMs(options.fallbackTimeoutMs); + this.assertUseCaseIsNotReserved(options.useCase); - const fallback = (): Promise> => - 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( + load: () => Awaitable, + selectKey: () => CacheKeySpec, + options: CacheOperationOptions, + defaultConfig: DialCacheKeyConfig | null, + fallbackTimeoutMs: number | null, + ): Promise { + const rawFallback = async (): Promise => 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 => 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); } /** @@ -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. */ @@ -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( - options: CachedOptions, + private assertUseCaseIsNotReserved(useCase: string): void { + if (useCase === "watermark") { + throw new UseCaseNameIsReservedError(useCase); + } + } + + private buildKey( + options: CacheOperationOptions, cacheKey: CacheKeySpec, defaultConfig: DialCacheKeyConfig | null, ): DialCacheKey { diff --git a/src/index.ts b/src/index.ts index 38d8726..f303ee8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,6 +27,7 @@ export type { CachedOptions, CachedValue, CoalescingState, + GetOrLoadOptions, ProcessCoalescingState, } from "./dialcache.js"; export { DialCacheKey, invalidationPrefix, normalizeArgs, redisClusterHashTag } from "./key.js"; diff --git a/test/dialcache-get-or-load.test.ts b/test/dialcache-get-or-load.test.ts new file mode 100644 index 0000000..519e722 --- /dev/null +++ b/test/dialcache-get-or-load.test.ts @@ -0,0 +1,399 @@ +import { performance } from "node:perf_hooks"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + CacheLayer, + DialCache, + DialCacheKeyConfig, + FallbackTimeoutError, + UseCaseNameIsReservedError, + type DialCacheKey, + type DialCacheMetricsAdapter, + type Serializer, +} from "../src/index.js"; +import { FakeRedis } from "./fake-redis.js"; + +interface Deferred { + readonly promise: Promise; + resolve: (value: T) => void; +} + +function deferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +const tick = (): Promise => new Promise((resolve) => setTimeout(resolve, 0)); + +const localOnly = (ttlSec = 60): DialCacheKeyConfig => + new DialCacheKeyConfig({ + ttlSec: { [CacheLayer.LOCAL]: ttlSec }, + ramp: { [CacheLayer.LOCAL]: 100 }, + }); + +const remoteOnly = (ttlSec = 60): DialCacheKeyConfig => + new DialCacheKeyConfig({ + ttlSec: { [CacheLayer.REMOTE]: ttlSec }, + ramp: { [CacheLayer.REMOTE]: 100 }, + }); + +function spyMetrics(): DialCacheMetricsAdapter { + return { + request: vi.fn(), + 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 getOrLoad", () => { + beforeEach(() => { + vi.useRealTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("reuses a stable use case without registering it and still rejects the reserved name", async () => { + const dialcache = new DialCache(); + let calls = 0; + const options = { + keyType: "user_id", + useCase: "InlineStableUseCase", + key: "123", + defaultConfig: localOnly(), + } as const; + + const first = await dialcache.enable(async () => + await dialcache.getOrLoad(async () => ({ calls: ++calls }), options), + ); + const second = await dialcache.enable(async () => + await dialcache.getOrLoad(async () => ({ calls: ++calls }), options), + ); + + const registered = dialcache.cached(async (id: string) => id, { + keyType: "user_id", + useCase: "InlineStableUseCase", + cacheKey: (id) => id, + }); + const afterRegistration = await dialcache.getOrLoad(async () => "pass-through", { + keyType: "user_id", + useCase: "InlineStableUseCase", + key: "other", + }); + + expect(first).toEqual({ calls: 1 }); + expect(second).toBe(first); + expect(calls).toBe(1); + expect(afterRegistration).toBe("pass-through"); + expect(registered).toBeTypeOf("function"); + expect(() => + dialcache.getOrLoad(async () => "value", { + keyType: "user_id", + useCase: "watermark", + key: "123", + }), + ).toThrow(UseCaseNameIsReservedError); + }); + + it("uses captured values from each invocation and requires them in the direct key", async () => { + const dialcache = new DialCache(); + let calls = 0; + const buildProfile = async (userId: string, locale: string) => + await dialcache.getOrLoad( + async () => ({ userId, locale, calls: ++calls }), + { + keyType: "user_id", + useCase: "BuildInlineProfile", + key: { id: userId, args: { locale } }, + defaultConfig: localOnly(), + }, + ); + + const values = await dialcache.enable(async () => [ + await buildProfile("u1", "en"), + await buildProfile("u1", "fr"), + await buildProfile("u2", "en"), + await buildProfile("u1", "en"), + ]); + + expect(values).toEqual([ + { userId: "u1", locale: "en", calls: 1 }, + { userId: "u1", locale: "fr", calls: 2 }, + { userId: "u2", locale: "en", calls: 3 }, + { userId: "u1", locale: "en", calls: 1 }, + ]); + expect(calls).toBe(3); + }); + + it.each([ + ["request-local", "InlineRequestLocalCoalescing", new DialCacheKeyConfig({ requestLocal: true }), 0], + ["process", "InlineProcessCoalescing", localOnly(), 1], + ] as const)("preserves %s same-key single-flight behavior", async (_scope, useCase, defaultConfig, activeLeaders) => { + const dialcache = new DialCache(); + const gate = deferred(); + let calls = 0; + const options = { + keyType: "user_id", + useCase, + key: "123", + defaultConfig, + } as const; + + const pending = dialcache.enable(async () => { + const leader = dialcache.getOrLoad(async () => { + calls += 1; + await gate.promise; + return { source: "leader" }; + }, options); + const follower = dialcache.getOrLoad(async () => { + calls += 1; + return { source: "follower" }; + }, options); + + await tick(); + expect(dialcache.getCoalescingState().process.activeLeaders).toBe(activeLeaders); + gate.resolve(); + return await Promise.all([leader, follower]); + }); + + const [leader, follower] = await pending; + + expect(calls).toBe(1); + expect(follower).toBe(leader); + expect(follower).toEqual({ source: "leader" }); + expect(dialcache.getCoalescingState().process.activeLeaders).toBe(0); + }); + + it("is true pass-through outside an enabled scope", async () => { + const dialcache = new DialCache(); + let calls = 0; + const key = Object.defineProperty({}, "id", { + get: () => { + throw new Error("disabled getOrLoad should not inspect the key"); + }, + }) as { readonly id: string }; + const options = { + keyType: "user_id", + useCase: "InlineDisabledContext", + key, + defaultConfig: localOnly(), + fallbackTimeoutMs: 1, + } as const; + + const first = await dialcache.getOrLoad(async () => ({ calls: ++calls }), options); + const second = await dialcache.getOrLoad(async () => ({ calls: ++calls }), options); + + expect(first).toEqual({ calls: 1 }); + expect(second).toEqual({ calls: 2 }); + }); + + it("validates and snapshots defaultConfig independently for each invocation", async () => { + const seenKeys: DialCacheKey[] = []; + const dialcache = new DialCache({ + cacheConfigProvider: (key) => { + seenKeys.push(key); + return DialCacheKeyConfig.disabled(); + }, + }); + const defaultConfig = localOnly(60); + const options = { + keyType: "user_id", + useCase: "InlineDefaultSnapshot", + key: "123", + defaultConfig, + } as const; + + await dialcache.enable(async () => await dialcache.getOrLoad(async () => "first", options)); + defaultConfig.ttlSec[CacheLayer.LOCAL] = 30; + await dialcache.enable(async () => await dialcache.getOrLoad(async () => "second", options)); + + expect(seenKeys).toHaveLength(2); + expect(seenKeys[0]?.defaultConfig?.ttlSec[CacheLayer.LOCAL]).toBe(60); + expect(seenKeys[1]?.defaultConfig?.ttlSec[CacheLayer.LOCAL]).toBe(30); + expect(Object.isFrozen(seenKeys[0]?.defaultConfig)).toBe(true); + expect(() => + dialcache.getOrLoad(async () => "invalid", { + ...options, + defaultConfig: new DialCacheKeyConfig({ + ttlSec: { [CacheLayer.LOCAL]: 0 }, + }), + }), + ).toThrow(new RangeError("DialCache defaultConfig ttlSec.local must be a positive safe integer")); + }); + + it("reads and writes Redis values with the per-invocation serializer", async () => { + const redis = new FakeRedis(); + const dialcache = new DialCache({ redis: { client: redis } }); + const serializer: Serializer = { + dump: vi.fn((value) => value.toISOString()), + load: vi.fn((value) => new Date(Buffer.isBuffer(value) ? value.toString("utf8") : value)), + }; + let calls = 0; + const options = { + keyType: "user_id", + useCase: "InlineRedisDate", + key: "123", + defaultConfig: remoteOnly(), + serializer, + } as const; + + const first = await dialcache.enable(async () => + await dialcache.getOrLoad(async () => { + calls += 1; + return new Date("2026-07-23T12:00:00.000Z"); + }, options), + ); + const second = await dialcache.enable(async () => + await dialcache.getOrLoad(async (): Promise => { + calls += 1; + throw new Error("Redis hit should not invoke the loader"); + }, options), + ); + + expect(first.toISOString()).toBe("2026-07-23T12:00:00.000Z"); + expect(second.toISOString()).toBe(first.toISOString()); + expect(calls).toBe(1); + expect(redis.getCalls).toBe(2); + expect(redis.setCalls).toBe(1); + expect(serializer.dump).toHaveBeenCalledTimes(1); + expect(serializer.load).toHaveBeenCalledTimes(1); + }); + + it("preserves tracked invalidation behavior", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-23T13:00:00.000Z")); + const redis = new FakeRedis(); + const dialcache = new DialCache({ redis: { client: redis } }); + let version = 1; + const load = async () => + await dialcache.getOrLoad( + async () => ({ version }), + { + keyType: "user_id", + useCase: "InlineTrackedProfile", + key: "123", + trackForInvalidation: true, + defaultConfig: remoteOnly(), + }, + ); + + const first = await dialcache.enable(load); + version = 2; + vi.advanceTimersByTime(1); + await dialcache.invalidateRemote("user_id", "123"); + vi.advanceTimersByTime(1); + const second = await dialcache.enable(load); + + expect(first).toEqual({ version: 1 }); + expect(second).toEqual({ version: 2 }); + }); + + it("fails open through Redis read and write failures", async () => { + const redis = new FakeRedis(); + redis.failGet = true; + redis.failSet = true; + const logger = { debug: vi.fn(), warn: vi.fn(), error: vi.fn() }; + const dialcache = new DialCache({ redis: { client: redis }, logger }); + let calls = 0; + const load = async () => + await dialcache.getOrLoad( + async () => ({ calls: ++calls }), + { + keyType: "user_id", + useCase: "InlineRedisFailOpen", + key: "123", + defaultConfig: remoteOnly(), + }, + ); + + const first = await dialcache.enable(load); + const second = await dialcache.enable(load); + + expect(first).toEqual({ calls: 1 }); + expect(second).toEqual({ calls: 2 }); + expect(logger.warn).toHaveBeenCalledWith("Error getting value from Redis cache", expect.any(Error)); + expect(logger.warn).toHaveBeenCalledWith("Error putting value in Redis cache", expect.any(Error)); + }); + + it("propagates loader errors, records them as fallback failures, and clears the flight", async () => { + const metrics = spyMetrics(); + const dialcache = new DialCache({ metrics }); + const error = new Error("loader failed"); + const options = { + keyType: "user_id", + useCase: "InlineLoaderError", + key: "123", + defaultConfig: localOnly(), + } as const; + + await expect( + dialcache.enable(async () => + await dialcache.getOrLoad(async () => { + throw error; + }, options), + ), + ).rejects.toBe(error); + const recovered = await dialcache.enable(async () => + await dialcache.getOrLoad(async () => "recovered", options), + ); + + expect(recovered).toBe("recovered"); + expect(metrics.error).toHaveBeenCalledWith({ + cacheNamespace: "urn", + useCase: "InlineLoaderError", + keyType: "user_id", + layer: CacheLayer.LOCAL, + error: "fallback", + inFallback: true, + }); + expect(dialcache.getCoalescingState().process.activeLeaders).toBe(0); + }); + + it("applies the per-invocation fallback deadline inside enabled scopes", async () => { + vi.useFakeTimers(); + let nowMs = 0; + vi.spyOn(performance, "now").mockImplementation(() => nowMs); + const dialcache = new DialCache(); + const started = deferred(); + const pending = dialcache.enable(async () => + await dialcache.getOrLoad( + async () => { + started.resolve(); + return await new Promise(() => undefined); + }, + { + keyType: "user_id", + useCase: "InlineFallbackDeadline", + key: "123", + defaultConfig: localOnly(), + fallbackTimeoutMs: 25, + }, + ), + ); + await started.promise; + const rejection = expect(pending).rejects.toMatchObject({ + name: FallbackTimeoutError.name, + useCase: "InlineFallbackDeadline", + timeoutMs: 25, + }); + + nowMs = 25; + await vi.advanceTimersByTimeAsync(25); + + await rejection; + expect(dialcache.getCoalescingState().process.activeLeaders).toBe(0); + }); +}); diff --git a/test/redis-real.integration.test.ts b/test/redis-real.integration.test.ts index 04725a7..87553d9 100644 --- a/test/redis-real.integration.test.ts +++ b/test/redis-real.integration.test.ts @@ -189,7 +189,7 @@ describe.each(engines)("DialCache Lua protocol on $name", ({ image }) => { client = harnesses[kind]; }); - it("round-trips untracked UTF-8 and binary values", async () => { + it("round-trips untracked UTF-8, binary, and inline-loader values", async () => { if (client === undefined || admin === undefined) { throw new Error("Redis test clients did not start"); } @@ -214,18 +214,34 @@ describe.each(engines)("DialCache Lua protocol on $name", ({ image }) => { defaultConfig: remoteOnly, serializer: binarySerializer, }); + let inlineCalls = 0; + const inlineOptions = { + keyType: "item_id", + useCase: "RealInline", + key: "inline", + defaultConfig: remoteOnly, + } as const; const firstJson = await dialcache.enable(async () => await getJson("json")); const secondJson = await dialcache.enable(async () => await getJson("json")); const firstBinary = await dialcache.enable(async () => await getBinary("buffer")); const secondBinary = await dialcache.enable(async () => await getBinary("buffer")); + const firstInline = await dialcache.enable(async () => + await dialcache.getOrLoad(async () => ({ source: "inline", calls: ++inlineCalls }), inlineOptions), + ); + const secondInline = await dialcache.enable(async () => + await dialcache.getOrLoad(async () => ({ source: "unexpected", calls: ++inlineCalls }), inlineOptions), + ); expect(firstJson).toEqual({ id: "json", calls: 1 }); expect(secondJson).toEqual(firstJson); expect(firstBinary).toBe("binary:buffer:1"); expect(secondBinary).toBe(firstBinary); + expect(firstInline).toEqual({ source: "inline", calls: 1 }); + expect(secondInline).toEqual(firstInline); expect(jsonCalls).toBe(1); expect(binaryCalls).toBe(1); + expect(inlineCalls).toBe(1); }); it("stores arbitrary binary payloads without base64 expansion", async () => { From 23d6fa459dbc71226e678e003904f727e00ed4c9 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Thu, 23 Jul 2026 22:06:00 -0700 Subject: [PATCH 2/2] docs: clarify getOrLoad usage contracts --- README.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 2bca3bf..ab93558 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,9 @@ Use `cached(fn, options)` for an extracted, reusable function. The wrapped calla `getOrLoad(load, options)` runs one zero-argument loader through the same policy, cache layers, coalescing, invalidation, metrics, serialization, deadlines, and fail-open behavior as `cached()`. It is useful when only part of a larger function should be cached and the loader needs to capture local values: ```ts +// Reuse the caller-owned defaults; getOrLoad() snapshots them per invocation. +const profileCacheDefaults = DialCacheKeyConfig.enabled(60); + const profile = await dialcache.getOrLoad( async () => { const user = await db.getUser(userId); @@ -138,7 +141,7 @@ const profile = await dialcache.getOrLoad( keyType: "user_id", useCase: "BuildProfile", key: { id: userId, args: { locale } }, - defaultConfig: DialCacheKeyConfig.enabled(60), + defaultConfig: profileCacheDefaults, }, ); ``` @@ -365,7 +368,7 @@ Pass the same module namespace that created the client. DialCache uses its itself, so linked workspaces and applications with another installed GLIDE version cannot accidentally mix native script handles. -The application owns the complete Redis lifecycle. It creates and connects the underlying client and passes the semantic adapter to DialCache. During shutdown, stop starting DialCache-backed work and await every outstanding cached-function and `invalidateRemote()` promise, including calls still running fallbacks that may later write Redis. Then dispose adapter-owned resources and close the underlying connection. DialCache only borrows `redis.client`; it has no close or drain method and never disposes or closes caller resources. +The application owns the complete Redis lifecycle. It creates and connects the underlying client and passes the semantic adapter to DialCache. During shutdown, stop starting DialCache-backed work and await every promise returned by a cached function, `getOrLoad()`, or `invalidateRemote()`, including calls still running fallbacks that may later write Redis. Then dispose adapter-owned resources and close the underlying connection. DialCache only borrows `redis.client`; it has no close or drain method and never disposes or closes caller resources. The node-redis adapter owns no additional resources, so the application closes the underlying node-redis client after draining work. The GLIDE adapter owns five native `Script` handles but not the wrapped connection. After outstanding operations finish, call its idempotent `dispose()` before closing GLIDE as shown above; disposal while an adapter operation is in flight throws rather than releasing a live script. @@ -430,7 +433,7 @@ This guard is deliberately conservative and is not a proof of runtime data. Type ## Cached-value ownership -Treat values returned by cached functions as immutable. DialCache does not clone or freeze values stored in request-local or process-local memory. Mutating a cached object can therefore be observed by later callers in the same request, callers in other requests that hit the process-local cache, or callers that coalesced onto the same in-flight result. +Treat values returned by cached functions or `getOrLoad()` as immutable. DialCache does not clone or freeze values stored in request-local or process-local memory. Mutating a cached object can therefore be observed by later callers in the same request, callers in other requests that hit the process-local cache, or callers that coalesced onto the same in-flight result. This contract includes nested objects and arrays, `Map`, `Set`, `Buffer`, typed arrays, and class instances. Redis deserialization can produce a different reference from an in-memory hit, so reference identity is layer-dependent and is not part of the API contract; never rely on a specific layer cloning a value before mutation.