From 4a2dc71094a14d7a13eae004ec360e9d18e1f548 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Thu, 23 Jul 2026 10:38:25 -0700 Subject: [PATCH 01/31] docs: focus README on safe cache rollouts --- AGENTS.md | 5 + README.md | 913 ++++++++++++++---------------------------- docs/coalescing.md | 220 ++++++++++ docs/configuration.md | 434 ++++++++++++++++++++ docs/invalidation.md | 205 ++++++++++ docs/maintainers.md | 76 ++++ docs/observability.md | 261 ++++++++++++ docs/redis.md | 377 +++++++++++++++++ 8 files changed, 1868 insertions(+), 623 deletions(-) create mode 100644 docs/coalescing.md create mode 100644 docs/configuration.md create mode 100644 docs/invalidation.md create mode 100644 docs/maintainers.md create mode 100644 docs/observability.md create mode 100644 docs/redis.md diff --git a/AGENTS.md b/AGENTS.md index 64d5883..2aaeca8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,6 +7,8 @@ DialCache is a TypeScript caching library with explicit request-scoped enablemen ## Structure ```text +README.md # Adoption guide, safety model, and reference routing +docs/ # Focused user-facing configuration and operations guides src/ dialcache.ts # Main DialCache API and cached-function wrapper config.ts # Public configuration and rollout types @@ -36,6 +38,9 @@ test/ # Unit and Redis integration tests ## Conventions - Preserve strict TypeScript settings and public abstraction boundaries. +- Keep the README focused on evaluation and adoption. Put complete operational + contracts in a focused `docs/` guide and link it from the relevant README + summary. - Keep Redis client-specific behavior in adapters; core code depends on `DialCacheRedisClient`. - Public exports belong in the root or an explicit integration entry point such as `src/node-redis.ts`, `src/prometheus.ts`, or `src/redis-protocol.ts`. - Use `corepack pnpm` for project commands. diff --git a/README.md b/README.md index 8c4ce1c..e676737 100644 --- a/README.md +++ b/README.md @@ -4,44 +4,80 @@ [![Codecov](https://codecov.io/gh/lan17/DialCache/branch/main/graph/badge.svg)](https://codecov.io/gh/lan17/DialCache) [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/lan17/DialCache/badge)](https://scorecard.dev/viewer/?uri=github.com/lan17/DialCache) -Fine-grained TypeScript caching with explicit enabled contexts, request-local memoization, process-local and Redis TTL caching, stable key construction, runtime rollout controls, request coalescing, adapter-based observability, and Redis watermark-based targeted invalidation. +**Roll out backend caching like a feature—not a leap of faith.** + +**DialCache is** a TypeScript library for caching database and service reads +inside Node.js backends. It routes reusable async functions and inline loaders +through one read-through path with request-local memoization, a bounded +in-process LRU, and optional Redis or Valkey caching. + +The “dial” is per-use-case runtime control. Start with caching off, dial the +process-local and remote layers up for stable cohorts of keys, and dial them +back down without changing the loader. + +**DialCache is not** a frontend data cache, cache server, Redis or Valkey +client, or runtime configuration service. It supplies the cache path and +rollout controls; your application still decides what is safe to cache and +owns loader behavior, connections, runtime configuration, keys, TTLs, +invalidation policy, and resource budgets. + +## Safety comes from explicit controls + +- **Off by default.** Outside `dialcache.enable(...)`, both cached wrappers and + inline loaders are true pass-throughs: DialCache does not build a key, + resolve config, access a cache, or coalesce the call. Inside an enabled + scope, a layer still needs an effective policy before it participates. +- **Gradual and reversible rollout.** Configure TTL and ramp independently for + the process-local and remote layers. A ramp of `0` is off, `100` is fully on, + and `DialCacheKeyConfig.disabled()` is the all-layer policy kill switch. +- **Fail-open cache path.** Key, config, cache-read, and serialization-load + failures fall through to the source loader. Cache-write, + serialization-dump, logging, and metrics failures do not replace an otherwise + usable fallback result. Explicit remote invalidation failures are rethrown so + callers never assume a mutation was made safe when it was not. +- **Bounded defaults.** The process-local cache has a 10,000-entry default cap, + active remote reads have a 50-millisecond default deadline, and enabled + fallback executions have a 60-second default deadline. The read deadline + bounds DialCache's wait, not necessarily the underlying Redis command; + applications still need resource-native budgets for client work, config + providers, serializers, and source I/O. + +Use DialCache when you want to: + +- add caching to database or service reads without scattering cache get/set + plumbing across call sites; +- begin with one layer or a small deterministic key cohort, observe it, and + expand or reverse the rollout per use case; +- combine request-local, process-local, and shared caching behind one key and + policy contract; or +- coalesce hot-key misses, invalidate related Redis entries, and emit bounded + cache metrics without rebuilding those mechanisms for every function. ## Contents - [Install](#install) - [Quick start](#quick-start) -- [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) - - [Request-local cache](#request-local-cache) · [Process-local cache](#process-local-cache) · [Redis-backed TTL cache](#redis-backed-ttl-cache) · [Remote read deadlines](#remote-read-deadlines-and-async-liveness) · [Serialization](#serialization) -- [Cached-value ownership](#cached-value-ownership) -- [Targeted invalidation and watermarks](#targeted-invalidation-and-watermarks) -- [Request coalescing](#request-coalescing) - - [Fallback deadlines](#fallback-deadlines) · [Coalescing state](#coalescing-state) -- [Metrics](#metrics) -- [Maintainers](#maintainers) +- [Dial caching up or down](#dial-caching-up-or-down) +- [How the read path works](#how-the-read-path-works) +- [Core concepts](#core-concepts) +- [Production checklist](#production-checklist) +- [Reference guides](#reference-guides) ## Install ```bash pnpm add dialcache -# Choose a Redis client when using the remote layer: -pnpm add redis@~4.7.1 -# or -pnpm add @valkey/valkey-glide -# Add a metrics client only when using its adapter: -pnpm add prom-client@^15.1.3 -# or -pnpm add hot-shots@^17.0.0 ``` DialCache requires Node.js 22.0.0 or newer. Production deployments should use a [currently supported LTS release](https://nodejs.org/en/about/previous-releases). +Redis, Valkey, Prometheus, and Datadog integrations are optional and keep their +clients application-owned: + +- [Redis and Valkey setup](https://github.com/lan17/DialCache/blob/main/docs/redis.md) +- [Prometheus and Datadog setup](https://github.com/lan17/DialCache/blob/main/docs/observability.md) + ## Quick start ```ts @@ -59,678 +95,309 @@ const getUser = dialcache.cached( }, ); -// Caching is OFF outside an enable() scope (see "Enabled context"), so this runs the fn uncached: +// Outside enable(), this is a true pass-through to db.fetchUser: await getUser("123"); -// Inside enable(), reads are cached: +// Inside enable(), the active cache layers participate: const user = await dialcache.enable(() => getUser("123")); ``` -## How caching works - -The wrapped function is the **fallback**: it runs whenever no active cache layer returns a value, whether because layers missed, were disabled, or failed open. - -When caching is enabled, reads flow through: - -```text -request-local cache -> process-local cache -> Redis cache -> fallback function -``` - -- Request-local hits return the value memoized in the current outermost `enable()` scope. -- Results from the lower chain are memoized request-locally when that layer is enabled. -- Process-local hits return immediately. -- Process-local misses try Redis and populate the process-local cache on a Redis hit. -- Redis misses call the fallback and attempt to populate Redis and, when active, the process-local cache. Tracked invalidation may suppress both publications. -- Redis read failures and timeouts are logged, counted in metrics, and fail open without attempting a second Redis operation. Redis write failures also fail open. `invalidateRemote` logs/counts Redis failures and rethrows them so callers do not assume invalidation succeeded. -- Cache-key construction and config-provider failures also fail open and run the fallback uncached. -- A missing effective process-local/Redis TTL disables that layer by policy; a configured TTL with no ramp defaults to 100%. Disabled layers record a disabled reason and fall through to the next layer/fallback. +`cached(fn, options)` preserves the function's parameters and returns a +Promise-based wrapper. `DialCacheKeyConfig.enabled(60)` gives the process-local +and remote layers a 60-second baseline TTL; the remote layer participates only +when a Redis or Valkey client is configured. -Caching as a whole is only active inside an enabled context, described next. +For a one-shot calculation that should remain inline, +[`getOrLoad()`](https://github.com/lan17/DialCache/blob/main/docs/configuration.md#one-shot-inline-loaders) +accepts a +zero-argument loader and a direct key through the same cache contract. -## Enabled context - -Caching is **off by default** and only active inside a `dialcache.enable(...)` scope. This is deliberate: it lets you turn caching **off in write paths** so a stale read can't be cached around a write. DialCache uses Node `AsyncLocalStorage` to keep enabled state scoped to the current asynchronous call chain. - -**Enable once at your request boundary** (e.g. a middleware that wraps read-request handling) so individual call sites don't each need it; wrap mutation handlers in `disable()`: +Enable caching once at a read-request boundary instead of at every call site. +Keep nested mutation work uncached with `disable()`: ```ts await dialcache.enable(async () => { - await getUser("123"); // cached + const user = await getUser("123"); await dialcache.disable(async () => { - await updateUser("123", patch); // reads here are uncached + await updateUser("123", patch); }); - - await getUser("123"); // cached again -}); -``` - -- 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 - -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 | -| --- | --- | --- | -| `keyType` | yes | The kind of id the key addresses (e.g. `"user_id"`). Together with the id, the invalidation unit for tracked entries. | -| `useCase` | yes | Identifies the individual cache: part of the stored key and the metrics label. | -| `cacheKey` | yes | Selector over `fn`'s parameters; returns a bare id or `{ id, args }`. | -| `defaultConfig` | no | `DialCacheKeyConfig` baseline policy that runtime config overlays field by field (see [Runtime config](#runtime-config-and-ramp-controls)). | -| `serializer` | when the return type is not statically JSON-compatible | Per-function `Serializer` for Redis values (see [Serialization](#serialization)). | -| `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)). | - -`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 -// 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); - return renderProfile(user, locale); - }, - { - keyType: "user_id", - useCase: "BuildProfile", - key: { id: userId, args: { locale } }, - defaultConfig: profileCacheDefaults, - }, -); -``` - -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 - -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 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( - (userId: string, page: number, filter: string) => db.searchPosts(userId, page, filter), - { - keyType: "user_id", - useCase: "SearchPosts", - cacheKey: (userId, page, filter) => ({ id: userId, args: { page, filter } }), - defaultConfig: DialCacheKeyConfig.enabled(60), - }, -); -await dialcache.enable(() => searchPosts("u1", 2, "active")); -``` - -`DialCacheConfig.namespace` is the logical cache namespace and the first component of every key. It defaults to `"urn"`, producing keys such as `urn:user_id:123#GetUser`. Set a stable application-specific value when multiple applications may use the same Redis deployment: - -```ts -const dialcache = new DialCache({ - namespace: "users-api", - redis: { client: redisClient }, }); ``` -That produces Redis keys beginning with `users-api:...`, or `{users-api:...}` for invalidation-tracked values. `namespace` is DialCache's single cache-identity and key-partitioning setting: it participates in request-local, process-local, Redis, coalescing, deterministic ramp, invalidation, and metrics. It may not contain `{` or `}` because DialCache reserves those characters for Redis Cluster hash tags. Use a namespace to express any required application or environment separation, such as `production-users-api`. - -- **`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 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. - -## Runtime config and ramp controls - -Instance-wide behavior is set through the `DialCache` constructor: - -| `DialCacheConfig` option | Default | Description | -| --- | --- | --- | -| `namespace` | `"urn"` | Logical cache namespace and first key component (see [Keys, ids, and extra dimensions](#keys-ids-and-extra-dimensions)). | -| `redis` | none | `{ client: DialCacheRedisClient, readTimeoutMs?: number }`; enables the Redis layer with a 50 ms default read deadline (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. | -| `metrics` | disabled | A `DialCacheMetricsAdapter` (see [Metrics](#metrics)). | -| `logger` | `console` | Receives operational cache failures (`debug`, `warn`, `error`). | - -Per-invocation cache policy is a `DialCacheKeyConfig`: per-layer `ttlSec` and `ramp` maps keyed by `CacheLayer.LOCAL` (process-local) and `CacheLayer.REMOTE` (Redis), a `requestLocal` boolean, and an optional `remoteReadTimeoutMs`. +Enabled state follows the current asynchronous call chain through Node +`AsyncLocalStorage`; it is not process-global. Nested scopes restore the +previous state when their callbacks settle. -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. For cache enablement fields, precedence is runtime config, then `defaultConfig`, then DialCache's disabled baseline. For the remote-read deadline, precedence is runtime `remoteReadTimeoutMs`, `defaultConfig.remoteReadTimeoutMs`, `redis.readTimeoutMs`, then the 50 ms library default. +`disable()` prevents cache access during its callback; it does not evict values +cached before a mutation. Use the appropriate invalidation or TTL policy before +serving later reads of mutable data. -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%. +## Dial caching up or down -`DialCacheKeyConfig` preserves an omitted `requestLocal` as `undefined` so the overlay can distinguish omission from an explicit `false`; the effective value still defaults to false after resolution. - -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 a definition and whenever `getOrLoad()` is invoked: TTLs and remote-read deadlines 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. - -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, `requestLocal` value, or explicit `remoteReadTimeoutMs` fails config resolution for the invocation, records `config_error`, and executes the fallback uncached without attempting Redis. - -`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. +Every cache operation can declare a stable `defaultConfig`. An optional +`cacheConfigProvider` returns a sparse runtime overlay for the current key, so +policy can change independently of the loader: ```ts import { CacheLayer, DialCache, DialCacheKeyConfig } from "dialcache"; -const dialcache = new DialCache({ - cacheConfigProvider: async (key) => { - if (key.useCase === "GetUser") { - return new DialCacheKeyConfig({ - // Sparse override: inherit both TTLs and the local ramp from defaultConfig. - ramp: { [CacheLayer.REMOTE]: 25 }, - // Can be changed by the provider at runtime for this use case. - remoteReadTimeoutMs: 35, - }); - } - return null; // apply no overrides; use the cached function's baseline - }, -}); +const runtimePolicies = new Map(); -const getUser = dialcache.cached((userId: string) => db.fetchUser(userId), { - keyType: "user_id", - useCase: "GetUser", - cacheKey: (userId) => userId, - defaultConfig: new DialCacheKeyConfig({ - // Omitted ramps default to 100% because these layers have TTLs. - ttlSec: { [CacheLayer.LOCAL]: 30, [CacheLayer.REMOTE]: 300 }, - }), +const dialcache = new DialCache({ + cacheConfigProvider: (key) => runtimePolicies.get(key.useCase) ?? null, }); -``` - -`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 - -### Request-local cache - -Set `requestLocal: true` to memoize resolved values for the lifetime of the outermost `enable()` scope: -```ts -import { DialCache, DialCacheKeyConfig } from "dialcache"; - -const dialcache = new DialCache(); const getUser = dialcache.cached( (userId: string) => db.fetchUser(userId), { keyType: "user_id", useCase: "GetUser", cacheKey: (userId) => userId, - defaultConfig: new DialCacheKeyConfig({ requestLocal: true }), + defaultConfig: DialCacheKeyConfig.enabled(60), }, ); -``` - -`requestLocal` is a runtime boolean rather than a TTL/ramp-controlled `CacheLayer`. The `cacheConfigProvider` can turn it on or off for each invocation. `DialCacheKeyConfig.enabled(ttlSec)` enables only process-local and Redis caching, so request-local caching must be selected explicitly. - -DialCache resolves the runtime config once per enabled invocation and uses it for the entire lookup. When the effective `requestLocal` value is false, the invocation skips request-local lookup and storage without deleting an entry already memoized in the scope. A later invocation that enables request-local caching can reuse that entry. - -The outermost `enable()` call owns the request-local lifetime, and nested `enable()` calls reuse that scope. Request-local state is allocated lazily, only when an invocation enables the layer, so scopes that use only process-local or Redis caching do not allocate it. - -Wrap the complete Node HTTP handler so the request-local scope matches the handler's lifetime: - -```ts -import { createServer } from "node:http"; - -const server = createServer((req, res) => { - void dialcache - .enable(async () => { - const user = await getUser(readUserId(req)); - res.setHeader("content-type", "application/json"); - res.end(JSON.stringify(user)); - }) - .catch((error: unknown) => handleRequestError(error, res)); -}); -``` - -Request-local storage has no capacity limit, eviction, or overflow mode. Entries are retained until the outermost `enable()` callback settles. Use it for short-lived scopes with bounded key cardinality; split long-running streams or large batch jobs into smaller scopes when necessary. - -### Process-local cache - -The process-local layer (`CacheLayer.LOCAL`) uses one LRU per `DialCache` instance. It keeps at most 10,000 entries by default across all use cases while retaining each entry's configured TTL. Set `localMaxSize` to a nonnegative safe integer to change the global entry cap; `0` disables process-local storage: - -```ts -const dialcache = new DialCache({ localMaxSize: 25_000 }); -``` - -The limit counts entries rather than estimating JavaScript object memory. Recently read entries stay resident ahead of less recently used entries when the limit is reached. - -### Redis-backed TTL cache - -The Redis layer supports standalone Redis, Valkey, and Redis Cluster. Register DialCache's native node-redis scripts when creating the client, then pass that client to DialCache: - -```ts -import { createClient } from "redis"; -import { DialCache } from "dialcache"; -import { createNodeRedisDialCacheClient, dialcacheRedisScripts } from "dialcache/node-redis"; - -const redisClient = createClient({ - url: process.env.REDIS_URL, - scripts: dialcacheRedisScripts, - disableOfflineQueue: true, - commandsQueueMaxLength: 1_000, - socket: { connectTimeout: 2_000 }, -}); -await redisClient.connect(); - -const dialcache = new DialCache({ - namespace: "users-api", - redis: { - client: createNodeRedisDialCacheClient(redisClient), - // Optional instance default; omit to use DialCache's 50 ms default. - readTimeoutMs: 100, - }, -}); - -async function shutdown(): Promise { - // Stop new work and await every outstanding cached call and invalidation first. - await redisClient.quit(); -} -``` - -`redis.client` is required when Redis is configured and accepts the semantic `DialCacheRedisClient` interface. `redis.readTimeoutMs` is optional and sets the instance default for remote reads; omit it to use 50 ms. Create and connect the underlying client before constructing `DialCache`. Node-redis users should register the supplied scripts and wrap their client with `createNodeRedisDialCacheClient` as shown above. - -Valkey GLIDE users pass an already-created standalone or cluster client and its -module namespace to the GLIDE adapter: - -```ts -import * as valkeyGlide from "@valkey/valkey-glide"; -import { DialCache } from "dialcache"; -import { createValkeyGlideDialCacheClient } from "dialcache/valkey-glide"; - -const glideClient = await valkeyGlide.GlideClient.createClient({ - addresses: [{ host: "127.0.0.1", port: 6379 }], - requestTimeout: 2_000, - advancedConfiguration: { connectionTimeout: 2_000 }, -}); -const redisClient = createValkeyGlideDialCacheClient(glideClient, valkeyGlide); -const dialcache = new DialCache({ - namespace: "users-api", - redis: { client: redisClient }, -}); - -function shutdown(): void { - // After draining cached calls and invalidations, release scripts before closing GLIDE. - redisClient.dispose(); - glideClient.close(); -} -``` - -Pass the same module namespace that created the client. DialCache uses its -`Script` constructor and `Decoder.Bytes` value without importing a GLIDE runtime -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 promise returned by a cached function, `getOrLoad()`, or `invalidateRemote()`, including calls still running fallbacks that may later write Redis. A read that crossed DialCache's wait deadline may still be active inside the client, so use client-native telemetry and shutdown controls to drain or terminate that work before disposing adapter-owned resources and closing the 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. - -Node-redis computes each script's SHA, uses `EVALSHA`, and retries with `EVAL` after `NOSCRIPT`. Its cluster client routes scripts by their first key and performs that fallback on the selected shard. The GLIDE adapter uses GLIDE's native `Script` lifecycle and byte decoder; GLIDE routes scripts from their declared keys. Tracked reads are deliberately routed to primaries so a lagging replica cannot hide an invalidation watermark. - -#### Remote read deadlines and async liveness - -DialCache bounds every active Redis read. The effective timeout is resolved per use case and per invocation: runtime `remoteReadTimeoutMs`, then `defaultConfig.remoteReadTimeoutMs`, then optional instance `redis.readTimeoutMs`, then 50 ms. Values must be positive safe integers no greater than 2,147,483,647. There is no unbounded escape hatch for remote reads. - -When the deadline expires, DialCache aborts the optional `RedisReadContext.signal`, records one `cache_read_timeout` error, logs a `RedisReadTimeoutError`, and starts the source fallback. Late read fulfillment or rejection is consumed and ignored. A read failure or timeout never triggers a post-fallback Redis write; an untracked active process-local miss may retain the source value, while a tracked key suppresses local publication because the failed read did not establish watermark safety. - -Same-key followers share the leader's remaining remote-read budget. The timer covers only the semantic Redis read, not config resolution, serializer load, fallback work, Redis writes, or invalidation. `fallbackTimeoutMs` starts separately when the source fallback begins. - -The bundled node-redis adapter passes the signal through per-command options, which can remove queued work where supported. Aborting after dispatch does not unsend a command or prove that Redis stopped executing it. GLIDE's current script API has no per-invocation signal, so its invocation may continue after DialCache has fallen back. Keep client-native connection, retry, queue, and response budgets in place; they bound underlying resource lifetime while DialCache's deadline bounds caller wait time. - -Writes, invalidations, async `cacheConfigProvider` calls, and custom serializer methods still need finite application-owned budgets. Do not put mutations behind a bare `Promise.race`: rejecting the outer promise neither removes queued work nor proves whether a dispatched mutation executed. - -#### Serialization - -The core Redis boundary is the client-agnostic `DialCacheRedisClient` interface. It exchanges serialized values as `string | Buffer` and does not expose client commands or wire encodings. Distinct untracked/tracked read and write Lua sources, the invalidation source, and wire constants are available from `dialcache/redis-protocol`. Custom adapters can throw the root-exported `DialCacheRedisPayloadError`, `DialCacheRedisPayloadEncodingError`, and `DialCacheRedisProtocolError` classes to distinguish malformed payloads, unsupported encodings, and Lua reply-domain violations in logs. DialCache records bounded `cache_read`, `cache_write`, or `invalidation` metrics by failure site. -Redis values use a compact binary frame: - -```text -byte 1 format version -bytes 2-9 Redis-created timestamp in milliseconds (uint64, big-endian) -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 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. - -When `serializer.load` rejects a Redis payload, DialCache records a `serialization_load` error, counts the read as a remote cache miss, runs the fallback, and attempts to replace the rejected payload. A validating custom serializer can therefore treat an incompatible cached value as a refreshable miss without adding a schema version to the cache key. - -`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 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"; - -const dialcache = new DialCache(); -const dateSerializer: Serializer = { - dump: (value) => value.toISOString(), - load: (value) => new Date(Buffer.isBuffer(value) ? value.toString("utf8") : value), -}; - -const getUpdatedAt = dialcache.cached( - (userId: string) => db.fetchUpdatedAt(userId), - { - keyType: "user_id", - useCase: "GetUpdatedAt", - cacheKey: (userId) => userId, - serializer: dateSerializer, - }, +// Start with the local 10% ramp cohort; keep the remote layer off. +runtimePolicies.set( + "GetUser", + new DialCacheKeyConfig({ + ramp: { + [CacheLayer.LOCAL]: 10, + [CacheLayer.REMOTE]: 0, + }, + }), ); -``` - -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. - -## Cached-value ownership - -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. - -If a caller needs a mutable value, copy it explicitly before changing it: - -```ts -const sharedUser = await getUser("123"); -const editableUser = structuredClone(sharedUser); -editableUser.displayName = "New name"; -``` - -Use a narrower copy when its semantics are sufficient; the ownership boundary is the caller's responsibility. - -## Targeted invalidation and watermarks - -Mutable Redis-backed use cases can opt into targeted invalidation by setting `trackForInvalidation: true` in the options and calling `dialcache.invalidateRemote(keyType, id, futureBufferMs)` after writes. The buffer is an application-owned safety value; DialCache cannot choose a universally safe nonzero value: -```ts -import { CacheLayer, DialCache, DialCacheKeyConfig } from "dialcache"; -import { createNodeRedisDialCacheClient } from "dialcache/node-redis"; - -const dialcache = new DialCache({ - namespace: "users-api", - redis: { client: createNodeRedisDialCacheClient(redisClient) }, -}); - -// Chosen from this application's clock-skew bound and measured worst-case source/fallback timings. -const USER_INVALIDATION_BUFFER_MS = 5_000; - -const getUser = dialcache.cached( - (userId: string) => db.fetchUser(userId), - { - keyType: "user_id", - useCase: "GetMutableUser", - cacheKey: (userId) => userId, - trackForInvalidation: true, - // Strongly invalidated mutable data should disable request-local and process-local caching. - defaultConfig: new DialCacheKeyConfig({ - ttlSec: { [CacheLayer.REMOTE]: 300 }, - ramp: { [CacheLayer.REMOTE]: 100 }, - }), - }, +// Later, ramp both shared layers to 100%. +runtimePolicies.set( + "GetUser", + new DialCacheKeyConfig({ + ramp: { + [CacheLayer.LOCAL]: 100, + [CacheLayer.REMOTE]: 100, + }, + }), ); -await updateUser("123", patch); -await dialcache.invalidateRemote("user_id", "123", USER_INVALIDATION_BUFFER_MS); +// Reverse the rollout without changing getUser. +runtimePolicies.set("GetUser", DialCacheKeyConfig.disabled()); ``` -Invalidation writes a Redis watermark at `{encodedNamespace:encodedKeyType:encodedId}#watermark`. Tracked Redis cache entries use the same Redis Cluster hash tag, for example `{users-api:user_id:123}?locale=en#GetMutableUser:dialcache-frame-v1`, so the value key and watermark key live in the same slot. Key components are percent-encoded before joining so delimiters inside IDs or args cannot collide with delimiters in the key format. Components may not contain `{` or `}` because those characters would corrupt the hash tag. - -The internal `:dialcache-frame-v1` suffix identifies values written with DialCache's binary protocol. Watermarks are stored as decimal timestamps. +In production, the provider can read from an application-owned dynamic config +client instead of an in-memory map. DialCache resolves one policy snapshot per +enabled invocation. Keep the provider cheap and give any asynchronous work its +own finite budget. -A cached Redis value whose Redis-created timestamp is older than or equal to the watermark is treated as stale and refreshed through fallback. `invalidateRemote(keyType, id, futureBufferMs)` sets the watermark to the greater of its existing value and Redis's current time plus the buffer. While that future window is active, an invocation that reaches the tracked Redis read treats the covered value as a miss. If its fallback then reaches the tracked Redis write, Redis rejects the write and DialCache also suppresses the corresponding process-local population; the fallback value still returns to its caller. Request-local memoization remains unconditional, and invocations whose remote layer is disabled or ramped out do not consult the watermark and are not fenced by it. +For the process-local and remote layers: -The bundled timestamp protocol assumes that system clocks are synchronized across every Redis node eligible for primary promotion. Redis does not guarantee that `TIME` is monotonic across nodes, and DialCache does not detect or compensate for cross-node clock skew. If this deployment assumption is violated, failover can temporarily suppress tracked cache fills or allow a pre-invalidation value to remain readable until it expires or a later invalidation advances the watermark past its timestamp. +- a missing effective TTL disables that layer by policy; +- a configured TTL with no ramp defaults to `100`; +- `0` disables the layer; +- `100` enables the layer for every key; and +- an intermediate ramp uses DialCache's deterministic key-and-layer + assignment. -Watermarks are invalidation state, not disposable cache entries. The Redis deployment must preserve them for their derived TTL: use `noeviction` or an equivalent guarantee for deployments that rely on the publication fence, and choose persistence and failover guarantees appropriate to the application's consistency requirements. A missing watermark makes tracked reads miss, but a later tracked write cannot distinguish an empty cache from lost invalidation history; it creates a new baseline watermark and can publish fallback data that the lost future watermark would have rejected. Redis replication is asynchronous by default, and DialCache does not issue `WAIT` or provide strong consistency across failover. +Ramps select key cohorts, not requests or load, so `10` does not guarantee 10% +of calls. Increasing or decreasing a ramp preserves membership for keys that +remain inside the threshold, and local and remote cohorts are layer-specific. +DialCache keeps the assignment stable across releases. -Tracked writes create a baseline watermark and extend its TTL to at least the value TTL plus one minute. Neither tracked writes nor invalidation shorten a longer or persistent watermark TTL; invalidation extends it to at least the remaining future-buffer window plus one minute. There is no fixed watermark retention floor, and reads do not extend watermark lifetime. +If an application needs an externally coordinated cohort, its +`cacheConfigProvider` can return a per-key ramp override of `0` or `100`. +Ramping down, including with `DialCacheKeyConfig.disabled()`, bypasses existing +entries rather than deleting them; a later ramp-up can reuse entries that +remain valid. -`futureBufferMs` must be a nonnegative safe integer. The default is zero, but zero provides no stale-publication protection once Redis time advances. Every production invalidation should pass a named, application-owned nonzero value based on that application's measured or conservatively bounded timings; there is no universally safe library value. +Request-local caching is controlled separately by the `requestLocal` boolean. +`DialCacheKeyConfig.disabled()` sets it to `false` and ramps both shared layers +to `0`. Provider errors do not silently activate the baseline: the invocation +records a config error and runs the source loader uncached. -Size the buffer to cover the maximum expected negative clock skew between promotion-eligible Redis nodes plus the complete interval in which stale data could still reach the Redis write: source visibility or replication lag, the full remaining tail of any fallback that may already have observed the pre-mutation value, `serializer.dump`, Redis client queue and network latency, Lua script execution, the write itself, and a safety margin. Invalidate only after the source mutation commits. Underestimating this interval can allow a delayed stale fallback to repopulate Redis after the watermark window ends. Overestimating it lengthens the tracked Redis miss/write-suppression window described above, increasing fallback load without publishing stale values. A larger buffer does not delay or suppress returning fallback values to callers. +Remote-read waiting is runtime-controlled too. An overlay +`remoteReadTimeoutMs` takes precedence over the operation's `defaultConfig`, +then the instance's `redis.readTimeoutMs`, then the 50-millisecond core default. +Remote reads always have a finite positive deadline. -This is a timing contract rather than a cancellation or acquisition fence: the buffer prevents stale fallback results from passing that tracked Redis write only while the configured window remains active, and it does not force a fallback to read from an authoritative source. +See [Configuration and cache layers](https://github.com/lan17/DialCache/blob/main/docs/configuration.md) +for sparse-overlay precedence, validation, and layer behavior. -Targeted invalidation is remote-only and enforced by Redis watermarks. `invalidateRemote` does not evict existing request-local or process-local entries. Strongly invalidated mutable data should disable request-local and process-local caching (or use a very short process-local TTL only when stale reads are acceptable). +## How the read path works -## Request coalescing +Inside an enabled scope, active layers are checked in order: -DialCache coalesces in-flight work at the lifetime of the first active cache layer: - -- When request-local caching is enabled, same-key callers in one outermost `enable()` scope share request-scoped in-flight work before the request-local lookup. Its resolved value is then memoized for later sequential calls in that scope. -- When process-local or Redis caching is enabled, same-key callers share in-flight work within one `DialCache` instance before the first active shared layer. This is reported as `scope="process"`, still applies when request-local caching is off, and can combine leaders from separate request scopes using the same instance. - -```ts -await dialcache.enable(async () => { - // Same cold key, concurrent calls: one fallback execution, one shared result. - const [a, b] = await Promise.all([getUser("456"), getUser("456")]); -}); -``` - -With Redis configured, an instance-scoped leader that misses the process-local cache runs one bounded Redis read and, on a normal miss, the fallback/cache write; followers share its remaining read budget and await the same result. Process-local-only misses share the leader's fallback/cache write. This protects Redis and the source of truth from a thundering herd on hot keys. - -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 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 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"; - -const getUser = dialcache.cached( - (userId: string) => db.fetchUser(userId), - { - keyType: "user_id", - useCase: "GetUserWithDeadline", - cacheKey: (userId) => userId, - defaultConfig: DialCacheKeyConfig.enabled(60), - fallbackTimeoutMs: 2_000, - }, -); - -try { - await dialcache.enable(() => getUser("123")); -} catch (error) { - if (error instanceof FallbackTimeoutError) { - logger.warn("source lookup exceeded its DialCache budget", { - useCase: error.useCase, - timeoutMs: error.timeoutMs, - }); - } -} -``` - -The timer starts only when the fallback begins, including after a remote-read deadline has elapsed. 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. - -Timing out rejects the DialCache chain and clears its flight normally. A later fallback resolution is ignored, so that timed-out invocation cannot proceed to serializer, Redis, or local-cache publication. The underlying function is not canceled and may continue its own I/O or side effects; give the source operation its own native timeout or `AbortSignal` whenever possible. `fallbackTimeoutMs: null` disables this guard and makes finite fallback settlement entirely application-owned. Use the `null` escape hatch only after intentionally accepting that liveness risk. - -Timeout failures retain the bounded metrics classification `error="fallback"` with `in_fallback="true"`; the typed error provides the timeout details without adding high-cardinality labels. - -### Coalescing state - -`getCoalescingState()` returns a detached, point-in-time snapshot of process-scoped flights owned by that `DialCache` instance: - -```ts -const state = dialcache.getCoalescingState(); - -state.process.activeLeaders; -state.process.activeFollowers; -state.process.oldestLeaderAgeMs; // null when idle +```text +request-local -> process-local LRU -> Redis or Valkey -> source loader ``` -A leader is one exact cache key currently tracked by the instance-scoped coalescer. A follower is each later invocation that joined that pending leader; the initiating invocation is not counted as a follower. Followers remain counted until their leader settles because abandoning a JavaScript promise is not observable. Request-local flights are deliberately excluded because their lifecycle is bounded by the outer `enable()` scope. `oldestLeaderAgeMs` uses a monotonic clock and is computed when the snapshot is requested. +The wrapped function or inline loader is the fallback and remains the source of +the returned value when every active layer misses or a cache operation fails +open. -There is no library-wide flight cap or age-based replacement. A registry cap would bound only DialCache metadata while overflow or eviction could still create unbounded source work and unsafe duplicate publication. Finite operation deadlines provide eventual cleanup; application admission control and backpressure remain responsible for bounding simultaneous distinct-key work. Monitor leader count and oldest age to verify that those budgets hold in production. +- A request-local hit returns the value memoized in the current outermost + `enable()` scope. +- A process-local hit returns from the `DialCache` instance's bounded LRU. +- A process-local miss can read Redis and populate the process-local cache. +- A remote miss runs the fallback and attempts to populate active shared + layers. +- A remote read failure or timeout runs the fallback without a second Redis + operation. An untracked result may still populate process-local cache; a + tracked result does not, because watermark safety was not established. +- Same-key concurrent work is coalesced at the lifetime of the first active + layer. -## Metrics +When all layers are disabled by policy, an initially enabled call remains +uncached and uncoalesced, but its fallback deadline still applies. A call that +started outside an enabled scope remains a true pass-through and does not get a +DialCache deadline. -Metrics are disabled unless a `DialCacheMetricsAdapter` is passed to the constructor. `new DialCache()` does not import a metrics backend, register collectors, or emit metrics. +## Core concepts -### Prometheus +### Cache operations and keys -Install `prom-client` separately, create the registry your application owns, and pass the explicit Prometheus adapter to DialCache: +`cached(fn, options)` defines both a callable and the value-identity contract: -```bash -pnpm add prom-client@^15.1.3 -``` +| Option | Required | Purpose | +| --- | --- | --- | +| `keyType` | yes | Names the kind of id and, with `id`, the invalidation unit for tracked Redis entries. | +| `useCase` | yes | Identifies this individual cache in stored keys and metrics. | +| `cacheKey` | yes | Selects the bare id or `{ id, args }` from the function parameters. | +| `defaultConfig` | no | Supplies the baseline policy overlaid by runtime config. | +| `serializer` | for statically non-JSON return types | Defines the Redis representation for this operation's value. | +| `trackForInvalidation` | no | Opts the remote entries into watermark-based targeted invalidation. | +| `fallbackTimeoutMs` | no | Sets the fallback deadline; defaults to `60_000`, and `null` disables it. | + +Use `getOrLoad(load, options)` when a one-shot calculation should remain inline. +It follows the same cache, policy, coalescing, invalidation, serialization, and +deadline contracts, but takes a direct `key` instead of a `cacheKey` selector. +It does not register `useCase`, so repeated calls should reuse one stable, +deployment-defined name. + +The selected or direct key must include every input dimension that can affect +the returned value. Same-key concurrent calls may share the leader's execution, +so ignored function arguments or captured values such as auth context, locale, +or cancellation behavior must truly be safe to share. + +Set a stable, application-specific `namespace` when applications or +environments share Redis: ```ts -import { Registry } from "prom-client"; -import { DialCache } from "dialcache"; -import { createPrometheusDialCacheMetrics } from "dialcache/prometheus"; - -const registry = new Registry(); const dialcache = new DialCache({ - namespace: "users-api", - metrics: createPrometheusDialCacheMetrics({ - registry, - prefix: "myapp_", // myapp_dialcache_request_counter, etc. - }), -}); - -app.get("/metrics", async (_req, res) => { - res.type(registry.contentType).send(await registry.metrics()); + namespace: "production-users-api", + redis: { client: dialCacheRedisClient }, }); ``` -The adapter requires a caller-owned `Registry`; it never uses the global default registry and does not clear or otherwise own the registry lifecycle. Multiple adapters with the same registry and prefix reuse existing collectors when their type, help, labels, histogram buckets, and exemplar mode match. Adapter construction fails before registering anything if a same-name collector has an incompatible schema; use a unique prefix or a separate registry to resolve the collision. +See [Configuration and cache layers](https://github.com/lan17/DialCache/blob/main/docs/configuration.md) +for key encoding, secondary arguments, namespace changes, serializers, and +value ownership. -The Prometheus adapter emits: +### Cache layers -| Metric | Type | Labels | Description | -| --- | --- | --- | --- | -| `dialcache_request_counter` | Counter | `cache_namespace`, `use_case`, `key_type`, `layer` | Cache-layer requests that reached an enabled layer | -| `dialcache_miss_counter` | Counter | `cache_namespace`, `use_case`, `key_type`, `layer` | Cache misses | -| `dialcache_disabled_counter` | Counter | `cache_namespace`, `use_case`, `key_type`, `layer`, `reason` | Cache skips (`context`, `policy_disabled`, `invalid_ttl`, `invalid_ramp`, `ramped_down`, `config_error`) | -| `dialcache_error_counter` | Counter | `cache_namespace`, `use_case`, `key_type`, `layer`, `error`, `in_fallback` | Cache/fallback errors classified by a bounded failure site | -| `dialcache_invalidation_counter` | Counter | `cache_namespace`, `key_type`, `layer` | Invalidation calls for the layers touched | -| `dialcache_coalesced_counter` | Counter | `cache_namespace`, `use_case`, `key_type`, `scope` | Coalesced requests split by `request_local` or `process` scope | -| `dialcache_get_timer` | Histogram | `cache_namespace`, `use_case`, `key_type`, `layer` | Cache get latency in seconds | -| `dialcache_fallback_timer` | Histogram | `cache_namespace`, `use_case`, `key_type`, `layer` | Elapsed time until the underlying function settles or timeout rejection is delivered | -| `dialcache_serialization_timer` | Histogram | `cache_namespace`, `use_case`, `key_type`, `layer`, `operation` | Redis serializer dump/load latency | -| `dialcache_size_histogram` | Histogram | `cache_namespace`, `use_case`, `key_type`, `layer` | Serialized Redis payload size in bytes | +| Layer | Scope | Primary use | +| --- | --- | --- | +| Request-local | Outermost `enable()` scope | Memoize repeated reads during one bounded request or job. | +| Process-local | One `DialCache` instance | Serve hot values from a bounded in-process LRU. | +| Redis or Valkey | Shared remote store | Reuse TTL-cached values across processes and hosts. | -`policy_disabled` means that a process-local or Redis layer has no effective TTL after runtime overlays are applied. It is an intentional policy outcome, including the default when `defaultConfig` is omitted, rather than a configuration-loading failure. +Each invocation uses one resolved policy snapshot for all three layers. +Request-local storage has no capacity limit, so use it only for short-lived +scopes with bounded key cardinality. Process-local values count toward one +instance-wide entry cap. Remote values use a serializer selected by the cache +operation or the Redis configuration. -Every metric carries `cache_namespace`, including disabled-context, key-construction, coalescing, and invalidation paths that do not have a constructed key. Its value is `DialCacheConfig.namespace`, defaulting to `urn`. The `layer` label is `request_local`, `local` (process-local), or `remote`. Disabled-context, key-construction, and config-provider failures use `noop` because no cache layer was reached. The bounded `scope` label on `dialcache_coalesced_counter` distinguishes request-local from instance-scoped single-flight work. `scope="process"` coordinates calls only within one `DialCache` instance; separate instances in the same process do not share in-flight state. +Cached in-memory values are shared by reference. Treat every returned value as +immutable, or copy it explicitly before mutation. -### Datadog +### Targeted invalidation -Install `hot-shots` separately, create the DogStatsD client your application owns, and pass it to the Datadog adapter: - -```bash -pnpm add hot-shots@^17.0.0 -``` +Mutable Redis-backed use cases can opt into watermark-based invalidation with +`trackForInvalidation: true`, then call: ```ts -import StatsD from "hot-shots"; -import { DialCache } from "dialcache"; -import { createDatadogDialCacheMetrics } from "dialcache/datadog"; - -const dogStatsD = new StatsD({ - host: process.env.DD_AGENT_HOST, - globalTags: { service: "users-api", env: process.env.DD_ENV ?? "development" }, - errorHandler: (error) => logger.warn("DogStatsD error", { error }), -}); - -const dialcache = new DialCache({ - namespace: "users-api", // cache identity and cache_namespace tag - metrics: createDatadogDialCacheMetrics({ - client: dogStatsD, - observationMetricType: "distribution", - namespace: "dialcache", // metric-name prefix: dialcache.request.count, etc. - }), -}); - -// After outstanding cache operations finish during application shutdown: -dogStatsD.close(); -``` - -`hot-shots` is the supported and tested client, but the adapter depends only on the exported `DatadogDogStatsDClient` structural interface. DialCache does not import or install `hot-shots`, create a client, flush buffers, close sockets, or otherwise own the client lifecycle. - -`observationMetricType` is required. `"distribution"` is recommended when latency and size percentiles must aggregate across hosts; enable the desired distribution percentiles and aggregations in Datadog. Choose `"histogram"` when host-level histogram aggregation matches your existing Datadog setup. The choice applies uniformly to all four duration/size metrics. Both modes produce Datadog custom metrics. Distribution volume scales with unique tag-value combinations: Datadog counts five baseline aggregations per combination, and enabling percentile aggregations adds five more. Review [Datadog's custom-metrics billing guidance](https://docs.datadoghq.com/account_management/billing/custom_metrics/) before rollout. Do not send both types under the same namespace: when changing types, use a new namespace during migration so one metric identity never mixes histogram and distribution points. - -`DatadogMetricsOptions.namespace` is the metric-name namespace and defaults to `dialcache`. It is separate from `DialCacheConfig.namespace`, the logical cache namespace emitted as the `cache_namespace` tag. The Datadog metric namespace must start with a letter and contain only letters, numbers, underscores, and dot-separated non-empty segments. The adapter rejects invalid metric namespaces and final metric names longer than 200 characters rather than relying on client-side normalization. A `hot-shots` `prefix` is applied after the adapter constructs the name, so include that prefix when checking the final length and avoid combining it with the metric namespace accidentally. Client-level `globalTags` are appended by `hot-shots`; the table below lists the tags added by the adapter. - -The Datadog adapter emits exact increments of `1` for counters and preserves seconds and bytes without unit conversion: - -| Metric | Type | Tags | Description | -| --- | --- | --- | --- | -| `dialcache.request.count` | Count | `cache_namespace`, `use_case`, `key_type`, `layer` | Cache-layer requests that reached an enabled layer | -| `dialcache.miss.count` | Count | `cache_namespace`, `use_case`, `key_type`, `layer` | Cache misses | -| `dialcache.disabled.count` | Count | `cache_namespace`, `use_case`, `key_type`, `layer`, `reason` | Cache skips by bounded reason | -| `dialcache.error.count` | Count | `cache_namespace`, `use_case`, `key_type`, `layer`, `error`, `in_fallback` | Cache/fallback errors by bounded failure site | -| `dialcache.invalidation.count` | Count | `cache_namespace`, `key_type`, `layer` | Invalidation calls for the layers touched | -| `dialcache.coalesced.count` | Count | `cache_namespace`, `use_case`, `key_type`, `scope` | Coalesced requests by sharing scope | -| `dialcache.get.duration` | Distribution or histogram | `cache_namespace`, `use_case`, `key_type`, `layer` | Cache get latency in seconds | -| `dialcache.fallback.duration` | Distribution or histogram | `cache_namespace`, `use_case`, `key_type`, `layer` | Elapsed time until the underlying function settles or timeout rejection is delivered | -| `dialcache.serialization.duration` | Distribution or histogram | `cache_namespace`, `use_case`, `key_type`, `layer`, `operation` | Redis serializer dump/load latency in seconds | -| `dialcache.serialization.size` | Distribution or histogram | `cache_namespace`, `use_case`, `key_type`, `layer` | Serialized Redis payload size in bytes | - -Synchronous client throws are isolated by DialCache's fail-open metrics boundary. Buffered transport failures happen outside that synchronous call, so configure the DogStatsD client's error handling and shutdown behavior as part of application ownership. - -### Error categories - -The `error` label reports where an operation failed rather than copying the thrown value's class or `Error.name`: - -| `error` | Meaning | -| --- | --- | -| `key_construction` | The cache-key selector or `DialCacheKey` construction failed | -| `config_resolution` | Runtime or layer configuration, or ramp resolution, failed | -| `cache_read` | A local-cache or Redis read failed | -| `cache_read_timeout` | A Redis read exceeded its effective remote-read deadline | -| `cache_write` | A local-cache or Redis write failed | -| `serialization_load` | Deserializing a Redis payload failed | -| `serialization_dump` | Serializing a value for Redis failed | -| `invalidation` | Writing an invalidation watermark failed | -| `fallback` | The wrapped application function failed or exceeded its DialCache deadline | -| `unknown` | Reserved for an otherwise unclassified future failure site | - -These values are defined by the backend-neutral core and are identical for every metrics adapter. Raw thrown values, error names, messages, timeout values, cache IDs, arguments, and Redis keys are never included in metric labels. Operational errors are still passed to the configured logger where the existing failure path logs them. `in_fallback` remains the explicit cache-plumbing-versus-application distinction. - -### Custom adapters - -For other telemetry backends, implement `DialCacheMetricsAdapter` and pass the adapter through `new DialCache({ metrics })`. Every backend-neutral label object exposes the logical namespace as camel-case `cacheNamespace`; adapters should map it to their backend's `cache_namespace` label/tag. This field is present even when no key or cache layer was reached. Synchronous adapter failures are isolated from cache behavior and application fallbacks. Omit `metrics` to disable metrics. - -## Maintainers - -### Cache-path benchmark - -From a repository checkout, run the semantic microbenchmark after installing dependencies: - -```bash -pnpm benchmark:request-local +await updateUser("123", patch); +await dialcache.invalidateRemote( + "user_id", + "123", + USER_INVALIDATION_BUFFER_MS, +); ``` -The command builds `dist` before reporting six scenarios: sequential request-local hits, sequential process-local hits, enabled bounded fallbacks, request-local coalescing fan-out, process coalescing fan-out, and remote-read-deadline coalescing fan-out. The benchmark is a maintainer tool and is not included in the published package. It asserts fallback counts, coalescing state, timer cleanup, and returned values but deliberately applies no timing threshold. Override its work sizes with `DIALCACHE_BENCH_ITERATIONS` and `DIALCACHE_BENCH_FANOUT`. - -### Releasing - -Publishing starts by manually running the `Release` workflow from current `main`. After the package checks pass, Semantic Release selects the next version from Conventional Commits since the highest stable `vX.Y.Z` tag. Breaking changes bump major, `feat` bumps minor, and every other normal PR-title type (`fix`, `perf`, `docs`, `style`, `refactor`, `test`, `build`, `chore`, `ci`, and `revert`) bumps patch. The highest required bump wins. - -The workflow opens a `release: ` PR whose only change is the matching `package.json` version. `release` is a reserved Conventional Commit type configured not to request another release, so the version-control commit does not cause an extra bump. GitHub marks workflow runs for a PR opened with `GITHUB_TOKEN` as approval-required; approve those runs, review the PR, and squash-merge it normally through the protected branch. - -The merge triggers the publish job. Before any release side effect, it verifies current `main`, the release commit subject, the one-file diff, the package version, the absent tag, and Semantic Release's independently calculated version and commit. It then reruns the package checks and asks Semantic Release to create the matching Git tag, publish the public npm package with provenance, and publish the GitHub release. - -The repository must enable **Allow GitHub Actions to create and approve pull requests** under Actions workflow permissions. This workflow uses that capability only to create the version PR; it never approves or merges one, and no ruleset bypass actor or persistent release credential is required. +Invalidation is deliberately remote-only. It does not evict existing +request-local or process-local values, so strongly invalidated mutable data +should disable those layers or tolerate their TTL-bounded staleness. + +The buffer must be a named, application-owned nonzero value sized for clock +skew and the full stale-work window. See +[Targeted invalidation](https://github.com/lan17/DialCache/blob/main/docs/invalidation.md) +before enabling it in production. + +### Request coalescing and fallback deadlines + +Concurrent callers with the same cache key share active work within the first +active cache scope: one outer request for request-local caching, or one +`DialCache` instance for the shared layers. This mitigates hot-key stampedes +inside that scope; it is not cross-process coordination. + +Same-key followers share the leader's remaining remote-read budget. The +fallback deadline starts separately only if and when the source loader begins. + +Enabled fallbacks have a 60-second monotonic deadline by default. Timing out +rejects the DialCache chain and prevents the late result from being published, +but it does not cancel the underlying function. Give source operations their +own native timeout or `AbortSignal`. + +See [Coalescing and fallback liveness](https://github.com/lan17/DialCache/blob/main/docs/coalescing.md) +for exact sharing, deadline, cleanup, and admission-control contracts. + +### Observability + +Metrics are disabled unless a `DialCacheMetricsAdapter` is supplied. First-party +adapters support caller-owned Prometheus registries and Datadog DogStatsD +clients. Bounded labels report layer requests, misses, disabled reasons, +coalescing scopes, serialization work, and cache versus fallback failures. + +See [Observability](https://github.com/lan17/DialCache/blob/main/docs/observability.md) +for installation, collector schemas, metric names, and custom adapters. + +## Production checklist + +Before ramping a use case: + +- enable DialCache only around read paths, and keep mutation paths inside + `disable()` or outside the enabled boundary; +- verify that every selected or direct key includes each value and execution + dimension that is unsafe to share; +- begin at `0` or a small deterministic key cohort, monitor source load, cache + errors, hit rate, latency, remote-read and fallback timeouts, and coalescing + state, then increase in controlled steps; +- keep a runtime path to `DialCacheKeyConfig.disabled()`; +- choose an effective DialCache remote-read deadline, and configure + resource-native budgets for the underlying Redis work, config providers, + serializers, and source operation; +- use a conservative `localMaxSize` and bounded request-local scopes; +- treat cached values as immutable; +- verify serializer compatibility across mixed application versions; and +- for tracked invalidation, synchronize promotion-eligible Redis clocks, + preserve watermark keys for their derived TTL with `noeviction` or an + equivalent guarantee, choose suitable persistence and failover behavior, and + size a nonzero buffer from measured or conservatively bounded timings. + +## Reference guides + +- [Configuration and cache layers](https://github.com/lan17/DialCache/blob/main/docs/configuration.md) — definitions, keys, + runtime overlays, request-local and process-local behavior, and value + ownership. +- [Redis and Valkey](https://github.com/lan17/DialCache/blob/main/docs/redis.md) — node-redis and GLIDE setup, lifecycle, + liveness, binary protocol, and serialization. +- [Targeted invalidation](https://github.com/lan17/DialCache/blob/main/docs/invalidation.md) — watermarks, Redis Cluster + placement, clock assumptions, and buffer sizing. +- [Coalescing and fallback liveness](https://github.com/lan17/DialCache/blob/main/docs/coalescing.md) — sharing scopes, + deadlines, state inspection, cleanup, and backpressure. +- [Observability](https://github.com/lan17/DialCache/blob/main/docs/observability.md) — Prometheus, Datadog, metric schemas, + error categories, and custom adapters. +- [Maintainer guide](https://github.com/lan17/DialCache/blob/main/docs/maintainers.md) — benchmarks and the protected release + workflow. + +DialCache is licensed under the +[MIT License](https://github.com/lan17/DialCache/blob/main/LICENSE). diff --git a/docs/coalescing.md b/docs/coalescing.md new file mode 100644 index 0000000..4ce4c34 --- /dev/null +++ b/docs/coalescing.md @@ -0,0 +1,220 @@ +# Coalescing and fallback liveness + +[Back to the README](../README.md) + +DialCache shares same-key in-flight work within the lifetime of the first active +cache layer. It applies a finite deadline to each active remote read and a +separate default deadline once an initially enabled invocation begins its +fallback loader. + +These mechanisms reduce duplicate source work and give active flights eventual +cleanup. They do not replace cross-process coordination, source-native +cancellation, application admission control, or backpressure. + +## Request coalescing + +DialCache has two sharing scopes. + +### Request-local scope + +When request-local caching is active, callers with the same key in one outermost +`enable()` scope share in-flight work before the request-local lookup. + +The resolved value is memoized for later sequential calls in that scope. A +different outer request has a different request-local flight registry. + +### Process scope + +When process-local or remote caching is active, same-key callers share work +within one `DialCache` instance before the first active shared layer. + +This is reported as `scope="process"`, but it is instance-scoped: + +- separate requests using the same `DialCache` instance can share; +- separate `DialCache` instances in one process do not share; and +- separate processes or hosts do not share. + +```ts +await dialcache.enable(async () => { + // Same cold key and active shared layer: + // one fallback execution, one shared result. + const [first, second] = await Promise.all([ + getUser("456"), + getUser("456"), + ]); +}); +``` + +With a remote layer configured, an instance-scoped leader that misses +process-local cache performs one bounded Redis read. Followers share that read +and its remaining deadline. On a remote miss, the leader runs the fallback and +cache write; followers await that result. + +For a process-local-only miss, followers share the leader's fallback and local +write. This mitigates a thundering herd on one hot key within the instance. + +## When calls do not coalesce + +Coalescing applies only when at least one cache layer is active: + +- calls that start outside `enable()` are true pass-through; +- initially enabled calls with every layer disabled are uncached and + uncoalesced; and +- process-scoped work is never shared across `DialCache` instances. + +An initially enabled all-disabled call still receives the fallback deadline +described below. + +Because coalescing is keyed by the full constructed cache key, concurrent calls +with the same identity share the leader's execution. Every function argument +or captured value omitted from the selected or direct key must be safe to share +this way. + +Include locale, auth context, cancellation behavior, or any other input in the +key when it can change: + +- the returned value; +- whether the underlying function should run independently; or +- whether two callers may safely share one result. + +## Fallback deadlines + +Once an initially enabled invocation begins its wrapped fallback, DialCache +applies a 60-second monotonic deadline by default. + +Set `fallbackTimeoutMs` on a cached wrapper or `getOrLoad()` invocation to +choose a positive integer deadline in milliseconds, up to 2,147,483,647. Set +it to `null` only when the application intentionally accepts an unbounded +fallback: + +```ts +import { FallbackTimeoutError } from "dialcache"; + +const getUser = dialcache.cached( + (userId: string) => db.fetchUser(userId), + { + keyType: "user_id", + useCase: "GetUserWithDeadline", + cacheKey: (userId) => userId, + defaultConfig: DialCacheKeyConfig.enabled(60), + fallbackTimeoutMs: 2_000, + }, +); + +try { + await dialcache.enable(() => getUser("123")); +} catch (error) { + if (error instanceof FallbackTimeoutError) { + logger.warn("source lookup exceeded its DialCache budget", { + useCase: error.useCase, + timeoutMs: error.timeoutMs, + }); + } + throw error; +} +``` + +### When the timer runs + +The timer starts only when the fallback begins: + +- same-key followers share the request-local or process leader's remaining + budget and receive its `FallbackTimeoutError`; +- a remote read failure or timeout starts the fallback timer only when the + source loader begins; +- enabled pass-through invocations where every layer is disabled have + independent timers; +- cache hits create no fallback timer; and +- calls that began outside an enabled context remain true pass-through and are + not timed out, even when the operation has `fallbackTimeoutMs`. + +The fallback deadline does not cover work that happens before fallback. An +active remote read has its own resolved +[remote-read deadline](redis.md#remote-read-deadlines-and-async-liveness), while +a pending config provider or serializer load does not. Serialization and a +Redis write after fallback also remain outside it. Give every injected +operation its own finite, resource-native budget. + +### Event-loop behavior + +Deadline delivery requires the JavaScript event loop to make progress. It +cannot preempt a synchronous fallback prefix or other event-loop blocking. +Rejection can therefore arrive later than the configured duration. + +When control returns, DialCache checks the monotonic deadline before accepting +the result. The timer remains referenced until the fallback settles or times +out. An abandoned enabled fallback can keep an otherwise idle short-lived +process alive until the deadline. + +Shutdown code should drain outstanding DialCache work rather than discarding +its promises. + +### Timeout does not cancel the source + +Timing out: + +1. rejects the DialCache chain; +2. clears its tracked flight normally; +3. ignores a later fallback resolution; and +4. prevents that invocation from proceeding to serializer, Redis, or local + publication. + +The underlying loader is not canceled and may continue its own I/O or side +effects. Give the source operation a native timeout or `AbortSignal` whenever +possible. + +`fallbackTimeoutMs: null` disables the guard and makes finite fallback +settlement entirely application-owned. Use that escape hatch only after +intentionally accepting the liveness risk. + +Timeout failures retain the bounded metrics classification +`error="fallback"` with `in_fallback="true"`. The typed error carries timeout +details without adding high-cardinality labels. + +A shared remote-read timeout emits one `cache_read_timeout` error for the +leader, not one per follower. + +## Inspecting process-scoped flights + +`getCoalescingState()` returns a detached, point-in-time snapshot of +process-scoped flights owned by one `DialCache` instance: + +```ts +const state = dialcache.getCoalescingState(); + +state.process.activeLeaders; +state.process.activeFollowers; +state.process.oldestLeaderAgeMs; // null when idle +``` + +A leader is one exact cache key currently tracked by the instance-scoped +coalescer. A follower is each later invocation that joined that pending leader; +the initiating invocation is not counted as a follower. + +Followers remain counted until their leader settles because abandoning a +JavaScript promise is not observable. Request-local flights are deliberately +excluded because their lifecycle is bounded by the outer `enable()` scope. +`oldestLeaderAgeMs` uses a monotonic clock and is computed when the snapshot is +requested. + +## Admission control remains application-owned + +There is no library-wide flight cap or age-based replacement. + +A registry cap would bound only DialCache metadata. Overflow or eviction could +still create unbounded source work and unsafe duplicate publication. Finite +operation deadlines provide eventual cleanup; application admission control +and backpressure remain responsible for bounding simultaneous distinct-key +work. + +Monitor: + +- active leader count; +- active follower count; +- oldest leader age; +- remote-read timeout errors; +- fallback deadline errors; and +- source concurrency and saturation. + +Use those signals to verify that application budgets and admission control hold +under production load. diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..fbaf568 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,434 @@ +# Configuration and cache layers + +[Back to the README](../README.md) + +This guide covers reusable cached functions, one-shot inline loaders, cache +identity, runtime policy, request-local and process-local behavior, and +cached-value ownership. For the shared remote layer, see +[Redis and Valkey](redis.md). + +## Defining cache operations + +### Reusable cached functions + +`cached(fn, options)` wraps a function; the wrapped callable has the same +parameters and always returns a `Promise`. + +| Option | Required | Description | +| --- | --- | --- | +| `keyType` | yes | The kind of id the key addresses, such as `"user_id"`. Together with the id, this is the invalidation unit for tracked entries. | +| `useCase` | yes | Identifies the individual cache. It is part of the stored key and a metrics label. | +| `cacheKey` | yes | Selects a bare id or `{ id, args }` from `fn`'s parameters. | +| `defaultConfig` | no | Provides the `DialCacheKeyConfig` baseline that runtime config overlays field by field. | +| `serializer` | when the return type is not statically JSON-compatible | Selects a per-function `Serializer` for Redis values; see [Serialization](redis.md#serialization). | +| `trackForInvalidation` | no; default `false` | Opts this use case's Redis entries into watermark-based [targeted invalidation](invalidation.md). | +| `fallbackTimeoutMs` | no; default `60_000` | Sets the fallback deadline in milliseconds, up to 2,147,483,647. `null` disables it; see [Fallback deadlines](coalescing.md#fallback-deadlines). | + +`useCase` is validated when the function is registered. A duplicate within one +`DialCache` instance throws `UseCaseIsAlreadyRegisteredError`, and the internal +name `watermark` throws `UseCaseNameIsReservedError`. + +### One-shot inline loaders + +`getOrLoad(load, options)` runs one zero-argument synchronous or asynchronous +loader through the same cache layers, runtime policy, coalescing, invalidation, +metrics, serialization, and deadline behavior as `cached()`. Cache-plumbing +failures fall through to the loader; loader failures still reject and clear +their tracked flight: + +```ts +const profile = await dialcache.enable(() => + 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 calls +`load` directly without constructing a key or resolving runtime policy. + +`getOrLoad()` does not register its `useCase` or detect duplicates, but it still +rejects the reserved internal name `"watermark"`. + +Repeated calls should reuse one stable, deployment-defined name such as +`"BuildProfile"`. Never derive it 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()` for reusable loaders and `getOrLoad()` for calculations +intentionally local to one call site. + +## Keys, ids, and extra dimensions + +For `cached()`, the required `cacheKey` selector receives the wrapped +function's inferred parameters. `getOrLoad()` accepts the same bare id or +`{ id, args }` shape directly through `key`: + +```ts +const searchPosts = dialcache.cached( + (userId: string, page: number, filter: string) => + db.searchPosts(userId, page, filter), + { + keyType: "user_id", + useCase: "SearchPosts", + cacheKey: (userId, page, filter) => ({ + id: userId, + args: { page, filter }, + }), + defaultConfig: DialCacheKeyConfig.enabled(60), + }, +); + +await dialcache.enable(() => searchPosts("u1", 2, "active")); +``` + +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. + +### Namespace + +`DialCacheConfig.namespace` is the logical cache namespace and the first +component of every key. It defaults to `"urn"`, producing keys such as +`urn:user_id:123#GetUser`. + +Set a stable application-specific value when applications or environments may +share one Redis deployment: + +```ts +const dialcache = new DialCache({ + namespace: "production-users-api", + redis: { client: dialCacheRedisClient }, +}); +``` + +That produces Redis keys beginning with `production-users-api:...`, or +`{production-users-api:...}` for invalidation-tracked values. `namespace` is +DialCache's single cache-identity and key-partitioning setting. It participates +in request-local, process-local, Redis, coalescing, deterministic ramp, +invalidation, and metrics. + +A namespace may not contain `{` or `}` because DialCache reserves those +characters for Redis Cluster hash tags. + +### Identity rules + +- **`keyType` plus `id` is the invalidation unit for tracked Redis entries.** + `dialcache.invalidateRemote("user_id", "123", futureBufferMs)` writes one + watermark for that user. Any tracked Redis entry with the same `keyType` and + `id` is refreshed across all `args` variants when Redis is read. Untracked + entries do not consult the watermark. Invalidation does not evict existing + request-local or process-local entries. +- **`args` are part of the cache key.** Different arguments produce different + entries, but targeted invalidation is by id rather than by argument. +- **Scalar equality is string-based.** For matching surrounding dimensions: + - numeric `1`, string `"1"`, and bigint `1n` identify the same key; and + - argument values `null` and `"null"` 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 or value. +- **Non-key inputs still reach the loader.** A database handle can be a normal + function parameter ignored by `cacheKey` or a value captured by a + `getOrLoad()` loader. Concurrent same-key misses share the leader's + execution. Do not omit values such as `AbortSignal`, auth context, locale, or + other request-scoped inputs unless sharing one result is correct. +- **Methods need a receiver.** Pass `obj.method.bind(obj)` or + `(...args) => obj.method(...args)`; a bare `obj.method` reference loses + `this`. + +### Changing a namespace + +Changing `namespace` 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. The other version can continue serving a stale tracked +value until its value TTL expires. If remote invalidation correctness matters, +a normal rolling namespace change 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 and refill load, and allow old Redis keys to +expire by TTL. + +## Runtime config and ramp controls + +Instance-wide behavior is set through the `DialCache` constructor: + +| `DialCacheConfig` option | Default | Description | +| --- | --- | --- | +| `namespace` | `"urn"` | Logical cache namespace and first key component. | +| `redis` | none | `{ client, readTimeoutMs?, serializer? }`; enables the [remote layer](redis.md). Remote reads default to a 50 ms deadline. | +| `localMaxSize` | `10_000` | Global process-local entry cap. `0` disables process-local storage. Must be a nonnegative safe integer. | +| `cacheConfigProvider` | none | Resolves runtime config per enabled invocation as a sparse overlay on the operation's `defaultConfig`; `null` applies no overrides. | +| `metrics` | disabled | A `DialCacheMetricsAdapter`; see [Observability](observability.md). | +| `logger` | `console` | Receives operational cache failures through `debug`, `warn`, and `error`. | + +Per-invocation policy is a `DialCacheKeyConfig`: per-layer `ttlSec` and `ramp` +maps keyed by `CacheLayer.LOCAL` and `CacheLayer.REMOTE`, a `requestLocal` +boolean, and an optional `remoteReadTimeoutMs`. + +### Baseline and overlay precedence + +Every cached definition or `getOrLoad()` invocation can provide an optional +per-use-case `defaultConfig`. That is the baseline policy. The +`cacheConfigProvider` result is a sparse field-level overlay on it. + +Enablement fields use this precedence: + +```text +runtime field -> defaultConfig field -> DialCache disabled baseline +``` + +The disabled baseline sets `requestLocal` to `false` and leaves the +process-local and remote TTLs unset. A shared layer with no effective TTL is +disabled by policy. A shared layer with an effective TTL but no effective ramp +defaults to a 100% ramp. + +The remote-read deadline has two additional fallbacks: + +```text +runtime remoteReadTimeoutMs + -> defaultConfig.remoteReadTimeoutMs + -> redis.readTimeoutMs + -> 50 ms +``` + +This value bounds how long DialCache waits for an active Redis or Valkey read. +It can be tuned per use case at runtime, but it cannot be disabled. + +`DialCacheKeyConfig` preserves an omitted `requestLocal` as `undefined`, so the +overlay can distinguish omission from an explicit `false`. Its effective value +still defaults to `false` after resolution. + +A provider result of `null`, or a defensive `undefined`, applies no overrides. +An empty `DialCacheKeyConfig` and omitted runtime fields also inherit the +baseline. + +Use explicit values to replace inherited policy: + +- `requestLocal: false` disables request-local caching; +- a shared-layer ramp of `0` disables that layer; and +- `DialCacheKeyConfig.disabled()` turns request-local off and ramps both shared + layers to `0`. + +### Validation and snapshots + +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; +- `requestLocal` must be a boolean when present; and +- remote-read deadlines must be positive safe integers no greater than + 2,147,483,647 milliseconds. + +Invalid instance `redis.readTimeoutMs` values throw during `DialCache` +construction. Invalid defaults are rejected when `cached()` registers a +definition or `getOrLoad()` is invoked. `null`, zero, fractional, non-finite, +string, and larger timeout values are invalid; remote reads have no unbounded +escape hatch. + +Each registration or one-shot invocation captures an immutable internal +snapshot, so 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 rather than falling back to +valid default leaves: + +- an invalid TTL disables that layer with `invalid_ttl`; +- a nonnumeric or non-finite ramp disables it with `invalid_ramp`; +- a finite runtime ramp retains a defensive clamp to 0 through 100; and +- other valid layers can continue to run. + +Invalid leaves also record a `config_resolution` error, distinguishing provider +garbage from an intentional ramp-down. A malformed runtime config object, +layer-map shape, `requestLocal`, or `remoteReadTimeoutMs` value fails config +resolution for the whole invocation. DialCache records `config_resolution`, +marks the no-layer path `config_error`, and runs the fallback without a Redis +read or write. + +### Provider behavior + +`cacheConfigProvider` is called for every enabled cache invocation before any +cache lookup. Keep it cheap, cache remote or config-store reads inside the +provider, and give asynchronous work a finite application-owned deadline. + +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 uncached. + +```ts +import { CacheLayer, DialCache, DialCacheKeyConfig } from "dialcache"; + +const dialcache = new DialCache({ + redis: { + client: dialCacheRedisClient, + readTimeoutMs: 75, + }, + cacheConfigProvider: async (key) => { + if (key.useCase === "GetUser") { + return new DialCacheKeyConfig({ + // Sparse override: inherit both TTLs and the local ramp. + ramp: { [CacheLayer.REMOTE]: 25 }, + // Per-use-case override of the instance's 75 ms read deadline. + remoteReadTimeoutMs: 35, + }); + } + return null; + }, +}); + +const getUser = dialcache.cached( + (userId: string) => db.fetchUser(userId), + { + keyType: "user_id", + useCase: "GetUser", + cacheKey: (userId) => userId, + defaultConfig: new DialCacheKeyConfig({ + // Omitted ramps default to 100% because these layers have TTLs. + ttlSec: { + [CacheLayer.LOCAL]: 30, + [CacheLayer.REMOTE]: 300, + }, + }), + }, +); +``` + +Ramp values are thresholds from 0 to 100. `0` disables the layer, `100` enables +it for every key, and an intermediate value selects keys whose DialCache-owned +deterministic bucket for the full cache key and layer is below that threshold. + +For a fixed cache identity and layer, increasing a ramp only adds keys and +decreasing it only removes keys; it does not reshuffle existing membership. +Local and remote cohorts are layer-specific. + +Ramps select key cohorts, not requests or load, so a ramp of `10` does not +guarantee 10% of calls, especially for a small or skewed key population. +DialCache keeps the assignment 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`. + +Ramping down bypasses affected entries; it does not evict them, so a later +ramp-up can reuse entries that remain valid. + +## Request-local cache + +Set `requestLocal: true` to memoize resolved values for the lifetime of the +outermost `enable()` scope: + +```ts +const getUser = dialcache.cached( + (userId: string) => db.fetchUser(userId), + { + keyType: "user_id", + useCase: "GetUser", + cacheKey: (userId) => userId, + defaultConfig: new DialCacheKeyConfig({ requestLocal: true }), + }, +); +``` + +`requestLocal` is a runtime boolean rather than a TTL/ramp-controlled +`CacheLayer`. The provider can turn it on or off for each invocation. +`DialCacheKeyConfig.enabled(ttlSec)` enables only process-local and remote +caching, so request-local caching must be selected explicitly. + +The resolved config applies to the whole invocation. When `requestLocal` is +false, the invocation skips request-local lookup and storage without deleting a +value already memoized in the scope. A later invocation that enables it can +reuse that value. + +The outermost `enable()` call owns the request-local lifetime; nested `enable()` +calls reuse the same scope. State is allocated lazily, so scopes that use only +process-local or remote caching do not allocate it. + +Wrap the complete Node HTTP handler so the scope matches the request: + +```ts +import { createServer } from "node:http"; + +const server = createServer((req, res) => { + void dialcache + .enable(async () => { + const user = await getUser(readUserId(req)); + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify(user)); + }) + .catch((error: unknown) => handleRequestError(error, res)); +}); +``` + +Request-local storage has no capacity limit, eviction, or overflow mode. Values +are retained until the outermost callback settles. Use it for short-lived +scopes with bounded key cardinality. Split long-running streams or large batch +jobs into smaller scopes. + +## Process-local cache + +The process-local layer, `CacheLayer.LOCAL`, uses one LRU per `DialCache` +instance. It keeps at most 10,000 entries by default across all use cases while +retaining each entry's configured TTL. + +Set `localMaxSize` to a nonnegative safe integer to change the global entry cap. +`0` disables process-local storage: + +```ts +const dialcache = new DialCache({ localMaxSize: 25_000 }); +``` + +The limit counts entries rather than estimating JavaScript object memory. +Recently read entries stay resident ahead of less recently used entries when +the limit is reached. + +## Cached-value ownership + +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 be observed by: + +- later callers in the same request; +- callers in other requests that hit the process-local cache; and +- 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. + +Copy a value explicitly before changing it: + +```ts +const sharedUser = await getUser("123"); +const editableUser = structuredClone(sharedUser); +editableUser.displayName = "New name"; +``` + +Use a narrower copy when its semantics are sufficient. The ownership boundary +remains the caller's responsibility. diff --git a/docs/invalidation.md b/docs/invalidation.md new file mode 100644 index 0000000..b853ec5 --- /dev/null +++ b/docs/invalidation.md @@ -0,0 +1,205 @@ +# Targeted invalidation + +[Back to the README](../README.md) + +DialCache can invalidate related Redis entries without scanning or enumerating +keys. The mechanism is opt-in, remote-only, and based on per-identity Redis +watermarks. + +Read this complete contract before using targeted invalidation for mutable +production data. Correctness depends on cache-layer policy, Redis clock +synchronization, and an application-owned timing buffer. + +## Configure a tracked use case + +Set `trackForInvalidation: true` on a Redis-backed cached function or +`getOrLoad()` operation. After the source mutation commits, call +`dialcache.invalidateRemote(keyType, id, futureBufferMs)`: + +```ts +import { CacheLayer, DialCache, DialCacheKeyConfig } from "dialcache"; + +const dialcache = new DialCache({ + namespace: "users-api", + redis: { client: dialCacheRedisClient }, +}); + +// Chosen from this application's clock-skew bound and measured +// worst-case source and fallback timings. +const USER_INVALIDATION_BUFFER_MS = 5_000; + +const getUser = dialcache.cached( + (userId: string) => db.fetchUser(userId), + { + keyType: "user_id", + useCase: "GetMutableUser", + cacheKey: (userId) => userId, + trackForInvalidation: true, + // Strongly invalidated mutable data should not use in-memory layers. + defaultConfig: new DialCacheKeyConfig({ + ttlSec: { + [CacheLayer.REMOTE]: 300, + }, + ramp: { + [CacheLayer.REMOTE]: 100, + }, + }), + }, +); + +await updateUser("123", patch); +await dialcache.invalidateRemote( + "user_id", + "123", + USER_INVALIDATION_BUFFER_MS, +); +``` + +The buffer is an application-owned safety value. DialCache cannot choose a +universally safe nonzero default. + +## Identity and Redis Cluster placement + +Invalidation writes a watermark at: + +```text +{encodedNamespace:encodedKeyType:encodedId}#watermark +``` + +Tracked Redis values use the same Redis Cluster hash tag. For example: + +```text +{users-api:user_id:123}?locale=en#GetMutableUser:dialcache-frame-v1 +``` + +The value and watermark therefore live in the same Redis Cluster slot. Key +components are percent-encoded before joining, so delimiters inside ids or +arguments cannot collide with delimiters in the key format. + +`namespace` may never contain `{` or `}`; tracked `keyType` and `id` values may +not contain them because those three components form the hash tag. `args` and +`useCase` are encoded outside the hash tag and may contain braces. + +The internal `:dialcache-frame-v1` suffix identifies values written with +DialCache's binary protocol. Watermarks are stored as decimal timestamps. + +`keyType` plus `id` is the invalidation unit. One watermark covers every tracked +`useCase` and `args` variant with that identity. Untracked values do not consult +it. + +## Read and write behavior + +A tracked Redis value whose Redis-created timestamp is older than or equal to +the watermark is treated as stale and refreshed through fallback. + +`invalidateRemote(keyType, id, futureBufferMs)` sets the watermark to the +greater of: + +- its existing value; and +- Redis's current time plus the buffer. + +While that future window is active: + +1. A tracked Redis read treats the covered value as a miss. +2. The invocation runs its fallback. +3. If that fallback reaches the tracked Redis write before the window ends, + Redis rejects the write. +4. DialCache also suppresses the corresponding process-local population. +5. The fallback value still returns to its caller. + +Request-local memoization remains unconditional. An invocation whose remote +layer is disabled or ramped out does not consult the watermark and is not +fenced by it. + +This is a timing contract, not a cancellation or acquisition fence. The buffer +blocks stale fallback results from passing the tracked Redis write only while +the configured window remains active. It does not cancel the fallback or force +it to read from an authoritative source. + +If a tracked remote read rejects or exceeds its deadline, DialCache cannot +establish watermark safety. It runs the fallback but skips both the Redis write +and process-local publication. This differs from a normal tracked miss, which +can attempt the fenced Redis write. Untracked fallbacks may still populate +process-local cache, and request-local memoization remains unconditional. + +## Redis clock contract + +The bundled timestamp protocol assumes synchronized system clocks across every +Redis node eligible for primary promotion. + +Redis does not guarantee that `TIME` is monotonic across nodes, and DialCache +does not detect or compensate for cross-node clock skew. If the assumption is +violated, failover can: + +- temporarily suppress tracked cache fills; or +- allow a pre-invalidation value to remain readable until it expires or a later + invalidation advances the watermark past its timestamp. + +Monitor and bound the maximum negative clock skew across all promotion-eligible +nodes. Include that bound when sizing `futureBufferMs`. + +## Watermark durability + +Watermarks are invalidation state, not disposable cache entries. Redis must +preserve each marker for its derived TTL with `noeviction` or an equivalent +guarantee. Choose persistence, restore, and failover behavior that matches the +application's consistency requirements. + +Losing a marker through eviction, failover, restore, or external deletion +removes its prior publication fence. A missing marker makes tracked reads miss, +but a later tracked write creates a new baseline and can publish data that a +lost future watermark would have rejected. + +Redis replication is asynchronous. DialCache does not issue `WAIT` and does not +provide strong consistency across failover. + +## Watermark lifetime + +Tracked writes create a missing baseline watermark and ensure its TTL is at +least the value TTL plus one minute. They never shorten a longer or persistent +watermark TTL. + +Invalidation ensures the TTL covers both the requested future buffer and any +still-future existing watermark, plus one minute. It also preserves a longer or +persistent TTL. There is no fixed or configurable retention floor, and reads do +not extend watermark lifetime. + +## Choosing `futureBufferMs` + +`futureBufferMs` must be a nonnegative safe integer. The API default is zero, +but zero provides no stale-publication protection once Redis time advances. + +Every production invalidation should pass a named, application-owned nonzero +value based on measured or conservatively bounded timings. Size it to cover: + +- maximum expected negative clock skew between promotion-eligible Redis nodes; +- source visibility or replication lag; +- the full remaining tail of any fallback that may already have observed the + pre-mutation value; +- `serializer.dump`; +- Redis client queue and network latency; +- Lua script execution; +- the Redis write itself; and +- a safety margin. + +Invalidate only after the source mutation commits. + +Underestimating the interval can allow a delayed stale fallback to repopulate +Redis after the watermark window ends. Overestimating it lengthens the tracked +Redis miss and write-suppression window, increasing fallback load without +publishing stale values. + +A larger buffer does not delay or suppress returning fallback values to +callers. + +## In-memory layers remain local + +Targeted invalidation is remote-only. `invalidateRemote` does not evict existing +request-local or process-local entries. + +Strongly invalidated mutable data should disable request-local and process-local +caching. A short process-local TTL is appropriate only when the application +explicitly accepts that bounded stale-read window. + +If those layers remain enabled, their existing values can be returned without +reaching the remote watermark. diff --git a/docs/maintainers.md b/docs/maintainers.md new file mode 100644 index 0000000..4da5075 --- /dev/null +++ b/docs/maintainers.md @@ -0,0 +1,76 @@ +# Maintainer guide + +[Back to the README](../README.md) + +## Cache-path benchmark + +From a repository checkout, install dependencies and run: + +```bash +corepack pnpm benchmark:request-local +``` + +The command builds `dist` before reporting six scenarios: + +- sequential request-local hits; +- sequential process-local hits; +- enabled bounded fallbacks; +- request-local coalescing fan-out; +- process coalescing fan-out; and +- Redis read-deadline coalescing. + +The benchmark is a maintainer tool and is not included in the published +package. It asserts fallback counts, coalescing state, returned values, and one +semantic read and one cleaned-up deadline timer for the remote coalescing +scenario. It deliberately applies no timing threshold. + +Override its work sizes with: + +- `DIALCACHE_BENCH_ITERATIONS`; and +- `DIALCACHE_BENCH_FANOUT`. + +## Releasing + +Publishing starts by manually running the `Release` workflow from current +`main`. + +After the package checks pass, Semantic Release selects the next version from +Conventional Commits since the highest stable `vX.Y.Z` tag: + +- breaking changes bump major; +- `feat` bumps minor; and +- every other normal PR-title type bumps patch. + +Patch types are `fix`, `perf`, `docs`, `style`, `refactor`, `test`, `build`, +`chore`, `ci`, and `revert`. The highest required bump wins. + +The workflow opens a `release: ` pull request whose only change is the +matching `package.json` version. `release` is a reserved Conventional Commit +type configured not to request another release, so the version-control commit +does not cause an extra bump. + +GitHub marks workflow runs for a pull request opened with `GITHUB_TOKEN` as +approval-required. Approve those runs, review the pull request, and squash-merge +it normally through the protected branch. + +The merge triggers the publish job. Before any release side effect, it verifies: + +- current `main`; +- the release commit subject; +- the one-file diff; +- the package version; +- the absent tag; and +- Semantic Release's independently calculated version and commit. + +It then reruns the package checks and asks Semantic Release to: + +1. create the matching Git tag; +2. publish the public npm package with provenance; and +3. publish the GitHub release. + +The repository must enable **Allow GitHub Actions to create and approve pull +requests** under Actions workflow permissions. + +The workflow uses that capability only to create the version pull request. It +never approves or merges one, and no ruleset bypass actor or persistent release +credential is required. diff --git a/docs/observability.md b/docs/observability.md new file mode 100644 index 0000000..ac08f1c --- /dev/null +++ b/docs/observability.md @@ -0,0 +1,261 @@ +# Observability + +[Back to the README](../README.md) + +Metrics are disabled unless a `DialCacheMetricsAdapter` is passed to the +constructor. `new DialCache()` does not import a metrics backend, register +collectors, or emit metrics. + +DialCache provides first-party adapters for Prometheus and Datadog. Both use +caller-created, caller-owned clients and preserve one backend-neutral set of +bounded labels. + +## Prometheus + +Install `prom-client` separately: + +```bash +pnpm add prom-client@^15.1.3 +``` + +Create the registry your application owns, then pass an explicit adapter to +DialCache: + +```ts +import { Registry } from "prom-client"; +import { DialCache } from "dialcache"; +import { createPrometheusDialCacheMetrics } from "dialcache/prometheus"; + +const registry = new Registry(); + +const dialcache = new DialCache({ + namespace: "users-api", + metrics: createPrometheusDialCacheMetrics({ + registry, + prefix: "myapp_", + }), +}); + +app.get("/metrics", async (_req, res) => { + res.type(registry.contentType).send(await registry.metrics()); +}); +``` + +The adapter requires a caller-owned `Registry`. It never uses the global +default registry, and it does not clear or otherwise own the registry +lifecycle. + +Multiple adapters with the same registry and prefix reuse existing collectors +when their type, help, labels, histogram buckets, and exemplar mode match. +Adapter construction fails before registering anything if a same-name +collector has an incompatible schema. Use a unique prefix or separate registry +to resolve a collision. + +### Prometheus metrics + +The names below exclude the optional caller-selected prefix: + +| Metric | Type | Labels | Description | +| --- | --- | --- | --- | +| `dialcache_request_counter` | Counter | `cache_namespace`, `use_case`, `key_type`, `layer` | Cache-layer requests that reached an enabled layer | +| `dialcache_miss_counter` | Counter | `cache_namespace`, `use_case`, `key_type`, `layer` | Cache misses | +| `dialcache_disabled_counter` | Counter | `cache_namespace`, `use_case`, `key_type`, `layer`, `reason` | Cache skips by bounded reason | +| `dialcache_error_counter` | Counter | `cache_namespace`, `use_case`, `key_type`, `layer`, `error`, `in_fallback` | Cache or fallback errors by bounded failure site | +| `dialcache_invalidation_counter` | Counter | `cache_namespace`, `key_type`, `layer` | Invalidation calls for the layers touched | +| `dialcache_coalesced_counter` | Counter | `cache_namespace`, `use_case`, `key_type`, `scope` | Coalesced requests split by request-local or process scope | +| `dialcache_get_timer` | Histogram | `cache_namespace`, `use_case`, `key_type`, `layer` | Cache get latency in seconds | +| `dialcache_fallback_timer` | Histogram | `cache_namespace`, `use_case`, `key_type`, `layer` | Elapsed time until the wrapped fallback settles or timeout rejection is delivered | +| `dialcache_serialization_timer` | Histogram | `cache_namespace`, `use_case`, `key_type`, `layer`, `operation` | Redis serializer dump/load latency in seconds | +| `dialcache_size_histogram` | Histogram | `cache_namespace`, `use_case`, `key_type`, `layer` | Serialized Redis payload size in bytes | + +The disabled reasons are: + +- `context`; +- `policy_disabled`; +- `invalid_ttl`; +- `invalid_ramp`; +- `ramped_down`; and +- `config_error`. + +`policy_disabled` means that a process-local or remote layer has no effective +TTL after runtime overlays. This is an intentional policy result, including the +default when `defaultConfig` is omitted, rather than a configuration-loading +failure. + +Every metric includes `cache_namespace`, even disabled-context, +key-construction, coalescing, and invalidation paths that do not have a +constructed key. Its value is `DialCacheConfig.namespace`, which defaults to +`urn`. + +The `layer` label is: + +- `request_local`; +- `local`, meaning process-local; +- `remote`; or +- `noop` for disabled-context, key-construction, and config-provider failures + where no cache layer was reached. + +The bounded `scope` label on `dialcache_coalesced_counter` distinguishes +`request_local` from `process`. `scope="process"` coordinates calls only within +one `DialCache` instance; separate instances in the same process do not share +in-flight state. + +## Datadog + +Install `hot-shots` separately: + +```bash +pnpm add hot-shots@^17.0.0 +``` + +Create the DogStatsD client your application owns, then pass it to the Datadog +adapter: + +```ts +import StatsD from "hot-shots"; +import { DialCache } from "dialcache"; +import { createDatadogDialCacheMetrics } from "dialcache/datadog"; + +const dogStatsD = new StatsD({ + host: process.env.DD_AGENT_HOST, + globalTags: { + service: "users-api", + env: process.env.DD_ENV ?? "development", + }, + errorHandler: (error) => + logger.warn("DogStatsD error", { error }), +}); + +const dialcache = new DialCache({ + namespace: "users-api", + metrics: createDatadogDialCacheMetrics({ + client: dogStatsD, + observationMetricType: "distribution", + namespace: "dialcache", + }), +}); + +// Drain outstanding cache operations before application shutdown. +dogStatsD.close(); +``` + +`hot-shots` is the supported and tested client, but the adapter depends only on +the exported `DatadogDogStatsDClient` structural interface. + +DialCache does not: + +- import or install `hot-shots`; +- create a client; +- flush buffers; +- close sockets; or +- otherwise own the client lifecycle. + +### Distribution or histogram + +`observationMetricType` is required. + +Choose `"distribution"` when latency and size percentiles must aggregate across +hosts. Enable the desired distribution percentiles and aggregations in +Datadog. + +Choose `"histogram"` when host-level histogram aggregation matches the existing +Datadog setup. The choice applies uniformly to all four duration and size +metrics. Both modes produce Datadog custom metrics. + +Distribution volume scales with unique tag-value combinations. Datadog counts +five baseline aggregations per combination; enabling percentile aggregations +adds five more. Review +[Datadog's custom-metrics billing guidance](https://docs.datadoghq.com/account_management/billing/custom_metrics/) +before rollout. + +Do not send both observation types under the same metric namespace. When +changing types, use a new namespace during migration so one metric identity +never mixes histogram and distribution points. + +### Datadog namespaces + +`DatadogMetricsOptions.namespace` is the metric-name namespace and defaults to +`dialcache`. It is separate from `DialCacheConfig.namespace`, the logical cache +namespace emitted as the `cache_namespace` tag. + +The Datadog metric namespace must: + +- start with a letter; +- contain only letters, numbers, underscores, and dot-separated non-empty + segments; and +- produce final metric names no longer than 200 characters. + +The adapter rejects invalid namespaces and overlong final names instead of +relying on client-side normalization. + +A `hot-shots` `prefix` is applied after the adapter constructs the name. Include +that prefix when checking final length, and avoid accidentally combining it +with the adapter namespace. Client-level `globalTags` are appended by +`hot-shots`; the table below lists only tags added by DialCache. + +### Datadog metrics + +The adapter emits exact increments of `1` for counters and preserves seconds +and bytes without unit conversion: + +| Metric | Type | Tags | Description | +| --- | --- | --- | --- | +| `dialcache.request.count` | Count | `cache_namespace`, `use_case`, `key_type`, `layer` | Cache-layer requests that reached an enabled layer | +| `dialcache.miss.count` | Count | `cache_namespace`, `use_case`, `key_type`, `layer` | Cache misses | +| `dialcache.disabled.count` | Count | `cache_namespace`, `use_case`, `key_type`, `layer`, `reason` | Cache skips by bounded reason | +| `dialcache.error.count` | Count | `cache_namespace`, `use_case`, `key_type`, `layer`, `error`, `in_fallback` | Cache or fallback errors by bounded failure site | +| `dialcache.invalidation.count` | Count | `cache_namespace`, `key_type`, `layer` | Invalidation calls for the layers touched | +| `dialcache.coalesced.count` | Count | `cache_namespace`, `use_case`, `key_type`, `scope` | Coalesced requests by sharing scope | +| `dialcache.get.duration` | Distribution or histogram | `cache_namespace`, `use_case`, `key_type`, `layer` | Cache get latency in seconds | +| `dialcache.fallback.duration` | Distribution or histogram | `cache_namespace`, `use_case`, `key_type`, `layer` | Elapsed time until the wrapped fallback settles or timeout rejection is delivered | +| `dialcache.serialization.duration` | Distribution or histogram | `cache_namespace`, `use_case`, `key_type`, `layer`, `operation` | Redis serializer dump/load latency in seconds | +| `dialcache.serialization.size` | Distribution or histogram | `cache_namespace`, `use_case`, `key_type`, `layer` | Serialized Redis payload size in bytes | + +Synchronous client throws are isolated by DialCache's fail-open metrics +boundary. Buffered transport failures happen outside that synchronous call. +Configure the DogStatsD client's error handling and shutdown behavior as part +of application ownership. + +## Error categories + +The `error` label reports the operation that failed instead of copying the +thrown value's class or `Error.name`: + +| `error` | Meaning | +| --- | --- | +| `key_construction` | The cache-key selector or `DialCacheKey` construction failed | +| `config_resolution` | Runtime or layer configuration validation or resolution failed | +| `cache_read` | A process-local read or non-timeout remote read failed | +| `cache_read_timeout` | A remote read exceeded its effective DialCache deadline | +| `cache_write` | A process-local or remote cache write failed | +| `serialization_load` | Deserializing a Redis payload failed | +| `serialization_dump` | Serializing a value for Redis failed | +| `invalidation` | Writing an invalidation watermark failed | +| `fallback` | The source loader failed or exceeded its DialCache deadline | +| `unknown` | Reserved for a future failure site that cannot be classified otherwise | + +These values are defined by the backend-neutral core and are identical for +every adapter. + +Remote-read timeouts use `layer="remote"` and `in_fallback="false"`. They are +errors rather than misses, and the remote get-duration observation includes +the wait. Coalesced followers do not multiply the timeout error. Deadline +details remain out of labels and are available on the logged +`RedisReadTimeoutError`. + +Raw thrown values, error names, messages, cache ids, arguments, and Redis keys +are never included in labels. Operational errors still reach the configured +logger. `in_fallback` remains the explicit distinction between cache plumbing +and application fallback failures. + +## Custom adapters + +Implement `DialCacheMetricsAdapter` and pass it through +`new DialCache({ metrics })` for another telemetry backend. + +Every backend-neutral label object exposes the logical namespace as camel-case +`cacheNamespace`. Map it to the backend's `cache_namespace` label or tag. This +field is present even when no key or cache layer was reached. + +Synchronous adapter failures are isolated from cache behavior and application +fallbacks. Omit `metrics` to disable metrics entirely. diff --git a/docs/redis.md b/docs/redis.md new file mode 100644 index 0000000..bd3f81d --- /dev/null +++ b/docs/redis.md @@ -0,0 +1,377 @@ +# Redis and Valkey + +[Back to the README](../README.md) + +DialCache's remote TTL layer supports standalone Redis, standalone Valkey, and +Redis Cluster. The application creates, connects, configures, drains, and closes +the underlying client. DialCache borrows a semantic `DialCacheRedisClient` and +does not own the connection lifecycle. + +## Install a client + +Choose one supported integration: + +```bash +# node-redis +pnpm add redis@~4.7.1 + +# or Valkey GLIDE +pnpm add @valkey/valkey-glide +``` + +## node-redis + +Register DialCache's native scripts when creating the client, connect it, and +pass the semantic adapter to `DialCache`: + +```ts +import { createClient } from "redis"; +import { DialCache } from "dialcache"; +import { + createNodeRedisDialCacheClient, + dialcacheRedisScripts, +} from "dialcache/node-redis"; + +const redisClient = createClient({ + url: process.env.REDIS_URL, + scripts: dialcacheRedisScripts, + disableOfflineQueue: true, + commandsQueueMaxLength: 1_000, + socket: { connectTimeout: 2_000 }, +}); + +await redisClient.connect(); + +const dialcache = new DialCache({ + namespace: "users-api", + redis: { + client: createNodeRedisDialCacheClient(redisClient), + }, +}); + +async function shutdown(): Promise { + // Stop new work and await every cached call and invalidation first. + await redisClient.quit(); +} +``` + +`redis.client` is required when the remote layer is configured. Node-redis +users should register the supplied scripts and wrap the connected client with +`createNodeRedisDialCacheClient` as shown above. Active remote reads have a +50-millisecond DialCache deadline by default. Set `redis.readTimeoutMs` for an +instance-wide value or use `DialCacheKeyConfig.remoteReadTimeoutMs` for +per-use-case static and runtime policy. + +The adapter computes each script's SHA, uses `EVALSHA`, and retries with `EVAL` +after `NOSCRIPT`. Its cluster client routes scripts by their first key and +performs that fallback on the selected shard. Tracked reads are deliberately +routed to primaries so a lagging replica cannot hide an invalidation watermark. + +Deployments using tracked invalidation must also satisfy the +[watermark durability](invalidation.md#watermark-durability) contract. + +## Valkey GLIDE + +Pass an already-created standalone or cluster client and the exact module +namespace that created it: + +```ts +import * as valkeyGlide from "@valkey/valkey-glide"; +import { DialCache } from "dialcache"; +import { createValkeyGlideDialCacheClient } from "dialcache/valkey-glide"; + +const glideClient = await valkeyGlide.GlideClient.createClient({ + addresses: [{ host: "127.0.0.1", port: 6379 }], + requestTimeout: 2_000, + advancedConfiguration: { + connectionTimeout: 2_000, + }, +}); + +const redisClient = createValkeyGlideDialCacheClient( + glideClient, + valkeyGlide, +); + +const dialcache = new DialCache({ + namespace: "users-api", + redis: { client: redisClient }, +}); + +function shutdown(): void { + // Drain cached calls and invalidations before releasing resources. + redisClient.dispose(); + glideClient.close(); +} +``` + +DialCache uses the supplied namespace's `Script` constructor and +`Decoder.Bytes` value without importing a GLIDE runtime itself. Passing the same +module namespace that created the client prevents linked workspaces or +applications with another installed GLIDE version from mixing native script +handles. + +The GLIDE adapter uses GLIDE's native script lifecycle and byte decoder. GLIDE +routes scripts from their declared keys. + +## Lifecycle ownership + +The application owns the complete Redis lifecycle: + +1. Create and connect the underlying client. +2. Construct the semantic DialCache adapter. +3. Pass that adapter as `redis.client`. +4. During shutdown, stop starting DialCache-backed work. +5. Await every outstanding cached-function, `getOrLoad()`, and + `invalidateRemote()` promise, including fallbacks that may still write + Redis. +6. Drain or terminate client-native Redis work that may have outlived + DialCache's remote-read wait. +7. Dispose adapter-owned resources. +8. Close the underlying connection. + +DialCache has no `close()` or drain method. It never disposes or closes caller +resources. + +The node-redis adapter owns no additional resources, so close the underlying +client after draining work. + +The GLIDE adapter owns five native `Script` handles but not the wrapped +connection. Call its idempotent `dispose()` after operations finish and before +closing GLIDE. Disposing while an adapter operation is in flight throws rather +than releasing a live script. A DialCache read timeout does not prove that the +client-side invocation has settled. + +## Remote-read deadlines and async liveness + +Every active semantic remote-read leader has a finite monotonic deadline. +DialCache uses this precedence for `cached()` and `getOrLoad()`: + +```text +runtime remoteReadTimeoutMs + -> defaultConfig.remoteReadTimeoutMs + -> redis.readTimeoutMs + -> 50 ms +``` + +Each explicit value must be a positive safe integer no greater than +2,147,483,647 milliseconds. Remote reads have no unbounded escape hatch. +Outside an enabled scope, on a local hit, or when remote policy is disabled or +ramped out, DialCache creates no remote-read timer. + +### Timeout and fail-open behavior + +When the deadline expires, DialCache: + +1. aborts the optional adapter signal; +2. logs a root-exported `RedisReadTimeoutError` carrying `useCase` and + `timeoutMs`; +3. records `cache_read_timeout`; +4. consumes and ignores any late read fulfillment or rejection; and +5. runs the source fallback. + +The deadline bounds caller wait and cache publication. It does not guarantee +server-side cancellation, and an event-loop-blocking operation cannot be +preempted. When control returns, DialCache still checks the monotonic deadline +before accepting the result. + +A remote read rejection or timeout does not count as a miss and never triggers +a second Redis operation. After fallback, an untracked key may still populate +an active process-local cache. A tracked key suppresses process-local +publication because watermark safety was not established. Request-local +memoization remains unconditional. + +Same-key callers in one request-local or process coalescing scope share the +leader's read, timer, and remaining budget. A later independent invocation may +start a new remote read even if the prior client operation is still settling. + +The `fallbackTimeoutMs` timer is separate and starts only if and when the source +loader begins. The remote-read timer covers neither config resolution, +serializer loading, the fallback, Redis writes, nor invalidation. + +### Custom-client read context + +The semantic boundary exposes an optional second argument: + +```ts +interface RedisReadContext { + readonly timeoutMs: number; + readonly signal: AbortSignal; +} + +interface DialCacheRedisClient { + read( + request: RedisReadRequest, + context?: RedisReadContext, + ): Awaitable; +} +``` + +The optional argument keeps existing one-argument custom clients structurally +compatible. Adapters should use the signal for cooperative cancellation where +their client supports it, but the core deadline remains authoritative when +they do not. + +The bundled node-redis adapter forwards the signal in per-command options. This +can remove queued work where supported, but aborting after dispatch cannot +unsend a command or prove that Redis stopped executing it. + +The GLIDE script API has no per-invocation signal, so a timed-out script +invocation may continue inside the adapter. Its configured +[`requestTimeout`](https://glide.valkey.io/languages/nodejs/api/interfaces/BaseClient.BaseClientConfiguration.html) +and +[`advancedConfiguration.connectionTimeout`](https://glide.valkey.io/languages/nodejs/api/interfaces/BaseClient.AdvancedBaseClientConfiguration.html) +still bound client-native work. + +### Native operation budgets + +DialCache's read deadline bounds its caller wait, not the complete lifetime of +the underlying client work. Configure finite client-native budgets for: + +- connection establishment; +- reconnection and retries; +- offline queueing; +- dispatch; and +- response time. + +For node-redis 4.7, `socket.connectTimeout`, `disableOfflineQueue`, and +`commandsQueueMaxLength` bound connection or queue behavior but do not impose a +strict response deadline after dispatch. Use client-native shutdown or +termination behavior that matches the application's resource and ambiguity +requirements. + +Redis writes and invalidations, asynchronous `cacheConfigProvider` work, and +custom `Serializer` methods still require their own finite budgets. Do not put +writes or invalidations behind a bare `Promise.race`: rejecting the outer +promise neither removes queued work nor proves that a dispatched mutation did +not execute. + +DialCache's [fallback deadline](coalescing.md#fallback-deadlines) covers only the +source loader. Prefer resource-native budgets and cooperative cancellation for +every injected operation. + +## Serialization + +The core Redis boundary is the client-agnostic `DialCacheRedisClient` interface. +It exchanges serialized values as `string | Buffer` and does not expose +client-specific commands or wire encodings. + +Distinct untracked and tracked read/write Lua sources, the invalidation source, +and wire constants are exported from `dialcache/redis-protocol`. Custom adapters +can throw these root-exported error classes: + +- `DialCacheRedisPayloadError`; +- `DialCacheRedisPayloadEncodingError`; and +- `DialCacheRedisProtocolError`. + +They distinguish malformed payloads, unsupported encodings, and invalid Lua +reply domains in logs. DialCache records bounded `cache_read`, +`cache_read_timeout`, `cache_write`, or `invalidation` metrics by failure site. + +### Binary frame + +Redis values use a compact binary frame: + +```text +byte 1 format version +bytes 2-9 Redis-created timestamp in milliseconds (uint64, big-endian) +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. + +The payload comes from the cache operation's serializer or `JsonSerializer` by +default. Custom serializers can return `string` or `Buffer`. Strings are +stored as UTF-8; Buffers are stored byte-for-byte without base64 expansion. +Adapters restore the same representation before calling `serializer.load`. + +### Default JSON behavior + +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. + +When `serializer.load` rejects a Redis payload, DialCache: + +1. records a `serialization_load` error; +2. counts the read as a remote miss; +3. runs the fallback; and +4. attempts to replace the rejected payload. + +A validating custom serializer can therefore treat an incompatible cached +value as a refreshable miss without adding a schema version to the cache key. + +`JsonSerializer` validates JSON syntax only. It cannot detect that a +structurally valid payload came from an incompatible application value schema. +Applications that retain one `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. + +### Typed serializer requirement + +When a cached function or inline loader's resolved return type is statically +JSON-compatible, `serializer` is optional. This includes JSON primitives, +arrays, plain object or 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"; + +const dialcache = new DialCache(); + +const dateSerializer: Serializer = { + dump: (value) => value.toISOString(), + load: (value) => + new Date(Buffer.isBuffer(value) ? value.toString("utf8") : value), +}; + +const getUpdatedAt = dialcache.cached( + (userId: string) => db.fetchUpdatedAt(userId), + { + keyType: "user_id", + useCase: "GetUpdatedAt", + cacheKey: (userId) => userId, + serializer: dateSerializer, + }, +); +``` + +The compile-time guard rejects known incompatible shapes such as: + +- `Date`, `Map`, and `Set`; +- `bigint`, symbols, and functions; +- Buffers and typed arrays; +- method-bearing class instances; +- required nested `undefined`; and +- `unknown` and `any`. + +The guard 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 rather than a proof of runtime data. +TypeScript cannot detect non-finite numbers, cyclic or shared references, +runtime getters, `toJSON` behavior, or data-only class instances that resemble +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 perform +an additional serialize-and-deserialize cycle to validate it. From 183395bca67916a033f59ddc7bc1808ccbaf7416 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sat, 25 Jul 2026 20:26:48 -0700 Subject: [PATCH 02/31] docs: improve onboarding and reference coverage --- README.md | 98 +++++++++++++++++++------------------------ docs/configuration.md | 68 ++++++++++++++++++++++++++++++ docs/maintainers.md | 37 ++++++++++++++++ docs/observability.md | 22 +++++++++- docs/redis.md | 44 ++++++++++++++----- 5 files changed, 201 insertions(+), 68 deletions(-) diff --git a/README.md b/README.md index e676737..43391e3 100644 --- a/README.md +++ b/README.md @@ -23,35 +23,14 @@ invalidation policy, and resource budgets. ## Safety comes from explicit controls -- **Off by default.** Outside `dialcache.enable(...)`, both cached wrappers and - inline loaders are true pass-throughs: DialCache does not build a key, - resolve config, access a cache, or coalesce the call. Inside an enabled - scope, a layer still needs an effective policy before it participates. -- **Gradual and reversible rollout.** Configure TTL and ramp independently for - the process-local and remote layers. A ramp of `0` is off, `100` is fully on, - and `DialCacheKeyConfig.disabled()` is the all-layer policy kill switch. -- **Fail-open cache path.** Key, config, cache-read, and serialization-load - failures fall through to the source loader. Cache-write, - serialization-dump, logging, and metrics failures do not replace an otherwise - usable fallback result. Explicit remote invalidation failures are rethrown so - callers never assume a mutation was made safe when it was not. -- **Bounded defaults.** The process-local cache has a 10,000-entry default cap, - active remote reads have a 50-millisecond default deadline, and enabled - fallback executions have a 60-second default deadline. The read deadline - bounds DialCache's wait, not necessarily the underlying Redis command; - applications still need resource-native budgets for client work, config - providers, serializers, and source I/O. - -Use DialCache when you want to: - -- add caching to database or service reads without scattering cache get/set - plumbing across call sites; -- begin with one layer or a small deterministic key cohort, observe it, and - expand or reverse the rollout per use case; -- combine request-local, process-local, and shared caching behind one key and - policy contract; or -- coalesce hot-key misses, invalidate related Redis entries, and emit bounded - cache metrics without rebuilding those mechanisms for every function. +- **Off by default.** Outside `dialcache.enable(...)`, calls go straight to the + loader without building a key, resolving policy, accessing a cache, or + coalescing work. +- **Gradual and reversible.** Start either shared layer at `0`, expand it by a + stable subset of keys, and turn every cache layer off through runtime policy. +- **Fail-open cache path.** Cache-plumbing failures fall through to the loader + instead of replacing a usable result. Explicit invalidation failures still + surface to the caller. ## Contents @@ -66,7 +45,7 @@ Use DialCache when you want to: ## Install ```bash -pnpm add dialcache +npm install dialcache ``` DialCache requires Node.js 22.0.0 or newer. Production deployments should use a @@ -80,6 +59,9 @@ clients application-owned: ## Quick start +Create one long-lived `DialCache` instance for each cache and coalescing domain, +typically once per service process: + ```ts import { DialCache, DialCacheKeyConfig } from "dialcache"; @@ -98,14 +80,18 @@ const getUser = dialcache.cached( // Outside enable(), this is a true pass-through to db.fetchUser: await getUser("123"); -// Inside enable(), the active cache layers participate: -const user = await dialcache.enable(() => getUser("123")); +// Inside enable(), the first call loads and the second reuses the cached value: +const user = await dialcache.enable(async () => { + await getUser("123"); // db.fetchUser, then populate process-local cache + return await getUser("123"); // process-local hit +}); ``` `cached(fn, options)` preserves the function's parameters and returns a Promise-based wrapper. `DialCacheKeyConfig.enabled(60)` gives the process-local -and remote layers a 60-second baseline TTL; the remote layer participates only -when a Redis or Valkey client is configured. +and remote layers a 60-second baseline TTL. It does not enable request-local +memoization, and the remote layer participates only when a Redis or Valkey +client is configured. For a one-shot calculation that should remain inline, [`getOrLoad()`](https://github.com/lan17/DialCache/blob/main/docs/configuration.md#one-shot-inline-loaders) @@ -133,11 +119,26 @@ previous state when their callbacks settle. cached before a mutation. Use the appropriate invalidation or TTL policy before serving later reads of mutable data. +### From local trial to production + +A typical adoption path is: + +1. start with the process-local cache shown above; +2. add [Redis or Valkey](https://github.com/lan17/DialCache/blob/main/docs/redis.md) + when values should be shared across processes or hosts; +3. add [Prometheus or Datadog](https://github.com/lan17/DialCache/blob/main/docs/observability.md) + before increasing production exposure; and +4. connect an application-owned runtime configuration source, then ramp a + stable subset of keys as described next. + ## Dial caching up or down Every cache operation can declare a stable `defaultConfig`. An optional `cacheConfigProvider` returns a sparse runtime overlay for the current key, so -policy can change independently of the loader: +policy can change independently of the loader. + +The example below focuses on runtime policy. Remote ramp settings take effect +only when a Redis or Valkey client is configured. ```ts import { CacheLayer, DialCache, DialCacheKeyConfig } from "dialcache"; @@ -186,41 +187,28 @@ runtimePolicies.set("GetUser", DialCacheKeyConfig.disabled()); In production, the provider can read from an application-owned dynamic config client instead of an in-memory map. DialCache resolves one policy snapshot per -enabled invocation. Keep the provider cheap and give any asynchronous work its -own finite budget. +enabled invocation. -For the process-local and remote layers: - -- a missing effective TTL disables that layer by policy; -- a configured TTL with no ramp defaults to `100`; -- `0` disables the layer; -- `100` enables the layer for every key; and -- an intermediate ramp uses DialCache's deterministic key-and-layer - assignment. +A shared layer needs an effective TTL. With a TTL but no ramp, it defaults to +`100`; a ramp of `0` disables it, `100` selects every key, and an intermediate +value selects a stable key cohort for that layer. Ramps select key cohorts, not requests or load, so `10` does not guarantee 10% of calls. Increasing or decreasing a ramp preserves membership for keys that remain inside the threshold, and local and remote cohorts are layer-specific. DialCache keeps the assignment stable across releases. -If an application needs an externally coordinated cohort, its -`cacheConfigProvider` can return a per-key ramp override of `0` or `100`. Ramping down, including with `DialCacheKeyConfig.disabled()`, bypasses existing entries rather than deleting them; a later ramp-up can reuse entries that remain valid. Request-local caching is controlled separately by the `requestLocal` boolean. `DialCacheKeyConfig.disabled()` sets it to `false` and ramps both shared layers -to `0`. Provider errors do not silently activate the baseline: the invocation -records a config error and runs the source loader uncached. - -Remote-read waiting is runtime-controlled too. An overlay -`remoteReadTimeoutMs` takes precedence over the operation's `defaultConfig`, -then the instance's `redis.readTimeoutMs`, then the 50-millisecond core default. -Remote reads always have a finite positive deadline. +to `0`. See [Configuration and cache layers](https://github.com/lan17/DialCache/blob/main/docs/configuration.md) -for sparse-overlay precedence, validation, and layer behavior. +for sparse-overlay precedence, provider failure behavior, externally +coordinated cohorts, remote-read deadlines, and layer validation. ## How the read path works diff --git a/docs/configuration.md b/docs/configuration.md index fbaf568..5b42998 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -74,6 +74,39 @@ value meaning and serialization. Prefer `cached()` for reusable loaders and `getOrLoad()` for calculations intentionally local to one call site. +## Enable and disable scopes + +DialCache performs cache work only inside an enabled asynchronous scope. Create +each `DialCache` instance once and reuse it for the lifetime of its cache and +coalescing domain, typically one service process: + +| API | Behavior | +| --- | --- | +| `enable(fn)` | Enables caching for `fn` and the asynchronous work it awaits. The outermost call owns any request-local state. | +| `disable(fn)` | Temporarily restores pass-through behavior, commonly around nested mutation work. It does not evict existing values. | +| `isEnabled()` | Reports whether the current asynchronous call chain is inside a live enabled scope. | +| `withEnabled(fn)` | Exact alias for `enable(fn)`. | +| `withDisabled(fn)` | Exact alias for `disable(fn)`. | + +All five methods are instance-scoped. `enable()` and `disable()` always return a +`Promise`, including when their callback returns synchronously. Nested scopes +restore the previous state when their callbacks settle, and a nested +`enable()` inside `disable()` can opt a smaller read region back in. + +Enabled state follows Node's `AsyncLocalStorage`; it is not a process-global +flag. Once the outermost `enable()` callback settles, detached asynchronous work +that inherited the old context becomes pass-through and cannot repopulate its +closed request-local state. + +The root-exported `DialCacheContext` exposes the lower-level +`enable()`, `disable()`, and `isEnabled()` context primitive. It does not attach +itself to a `DialCache` instance or perform cache work. Most applications should +use the methods on `DialCache`. + +Keep mutation work outside the enabled boundary or inside `disable()`. Because +disabling does not evict existing values, mutable data still needs an +appropriate TTL or [targeted invalidation](invalidation.md) policy. + ## Keys, ids, and extra dimensions For `cached()`, the required `cacheKey` selector receives the wrapped @@ -337,6 +370,41 @@ Applications that need an externally coordinated cohort can use Ramping down bypasses affected entries; it does not evict them, so a later ramp-up can reuse entries that remain valid. +### Provider key input + +`cacheConfigProvider` receives the fully constructed, read-only `DialCacheKey` +for the invocation: + +| Field | Meaning | +| --- | --- | +| `namespace` | Logical application or environment namespace. | +| `keyType` and `id` | Primary identity. The selected id has already been converted to a string. | +| `args` | Secondary dimensions as normalized, name-sorted string pairs; entries whose value was `undefined` are omitted. | +| `useCase` | Stable operation name used in cache identity and metrics. | +| `prefix` | Encoded identity prefix, including a Redis Cluster hash tag when invalidation tracking is enabled. | +| `urn` | Complete encoded cache identity, including arguments and `useCase`. | +| `defaultConfig` | The operation's snapshotted baseline policy, or `null`. | +| `serializer` | The operation-specific serializer, or `null`. | +| `trackForInvalidation` | Whether the operation uses remote watermark tracking. | + +Use the identity fields to select policy; do not derive policy names or metric +dimensions from unbounded user input. The provider result remains a sparse +overlay and must not mutate the key. + +Most applications do not construct keys directly. Custom integrations can use +the root exports: + +- `new DialCacheKey(init)` to build the same public key shape; +- `normalizeArgs(record)` to omit `undefined`, stringify scalar values, and + sort argument names; +- `invalidationPrefix(namespace, keyType, id)` to build the encoded tracked + identity; and +- `redisClusterHashTag(value)` to wrap a validated value in a Redis Cluster hash + tag. + +The namespace and hash-tag components reject `{` and `}` as described under +[Identity rules](#identity-rules). + ## Request-local cache Set `requestLocal: true` to memoize resolved values for the lifetime of the diff --git a/docs/maintainers.md b/docs/maintainers.md index 4da5075..7db9422 100644 --- a/docs/maintainers.md +++ b/docs/maintainers.md @@ -2,6 +2,43 @@ [Back to the README](../README.md) +## Validation + +Use the repository's pinned pnpm version through Corepack: + +```bash +corepack pnpm install --frozen-lockfile +corepack pnpm check +corepack pnpm test:integration +``` + +`pnpm check` runs strict typechecking, the unit suite with coverage, +bundles/declarations, and packed ESM/CJS consumer tests. The integration suite +uses Testcontainers and requires a working Docker-compatible container runtime +for Redis, Valkey, and Redis Cluster. + +CI runs development and integration checks on Node.js 24, then switches to the +declared minimum Node.js 22.0.0 to test the packed package. Keep the consumer +floor separate from the development runtime so a new dependency or emitted +syntax cannot silently raise the published requirement. + +Before changing a compatibility-sensitive surface, identify and extend the +corresponding packed, unit, and integration assertions: + +- package root and explicit adapter/protocol entry points; +- full cache-key identity, encoding, namespace behavior, and Redis Cluster hash + tags; +- deterministic partial-ramp assignment, which must not reshuffle cohorts + across releases; +- the binary Redis frame, Lua arguments and reply domains, tracked + read/write/invalidation semantics, and mixed-version serializer behavior; and +- bounded metrics names, labels, reasons, error categories, scopes, and units. + +When changing user-facing examples, parse TypeScript fences, validate local +files and anchors, verify that README repository links are absolute for npm +rendering, and inspect the packed README. The package ships `README.md` but not +`docs/`. + ## Cache-path benchmark From a repository checkout, install dependencies and run: diff --git a/docs/observability.md b/docs/observability.md index ac08f1c..599a530 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -15,7 +15,7 @@ bounded labels. Install `prom-client` separately: ```bash -pnpm add prom-client@^15.1.3 +npm install prom-client@^15.1.3 ``` Create the registry your application owns, then pass an explicit adapter to @@ -105,7 +105,7 @@ in-flight state. Install `hot-shots` separately: ```bash -pnpm add hot-shots@^17.0.0 +npm install hot-shots@^17.0.0 ``` Create the DogStatsD client your application owns, then pass it to the Datadog @@ -253,6 +253,24 @@ and application fallback failures. Implement `DialCacheMetricsAdapter` and pass it through `new DialCache({ metrics })` for another telemetry backend. +| Hook | Required | Value | +| --- | --- | --- | +| `request(labels)` | yes | One active cache-layer lookup. | +| `miss(labels)` | yes | One cache miss. | +| `disabled(labels)` | yes | One skipped layer or no-layer invocation with a bounded `reason`. | +| `error(labels)` | yes | One bounded failure site with `inFallback`. | +| `invalidation(labels)` | yes | One explicit remote invalidation call. | +| `coalesced(labels)` | no | One follower that joined request-local or process-scoped work. | +| `observeGet(labels, seconds)` | yes | Cache-read duration in seconds. | +| `observeFallback(labels, seconds)` | yes | Fallback duration in seconds. | +| `observeSerialization(labels, seconds)` | yes | Serializer dump/load duration in seconds. | +| `observeSize(labels, bytes)` | yes | Serialized remote payload size in bytes. | + +The root package exports `DialCacheMetricsAdapter` and every associated label, +reason, error-kind, layer, and scope type. All hooks are synchronous; adapters +that buffer or transmit asynchronously own that later lifecycle. Keep label +values bounded and preserve the seconds and bytes units shown above. + Every backend-neutral label object exposes the logical namespace as camel-case `cacheNamespace`. Map it to the backend's `cache_namespace` label or tag. This field is present even when no key or cache layer was reached. diff --git a/docs/redis.md b/docs/redis.md index bd3f81d..36c01ef 100644 --- a/docs/redis.md +++ b/docs/redis.md @@ -13,10 +13,10 @@ Choose one supported integration: ```bash # node-redis -pnpm add redis@~4.7.1 +npm install redis@~4.7.1 # or Valkey GLIDE -pnpm add @valkey/valkey-glide +npm install @valkey/valkey-glide ``` ## node-redis @@ -189,9 +189,9 @@ The `fallbackTimeoutMs` timer is separate and starts only if and when the source loader begins. The remote-read timer covers neither config resolution, serializer loading, the fallback, Redis writes, nor invalidation. -### Custom-client read context +### Custom-client contract -The semantic boundary exposes an optional second argument: +Custom adapters implement the complete client-agnostic semantic boundary: ```ts interface RedisReadContext { @@ -204,13 +204,26 @@ interface DialCacheRedisClient { request: RedisReadRequest, context?: RedisReadContext, ): Awaitable; + write(request: RedisWriteRequest): Awaitable; + invalidate(request: RedisInvalidationRequest): Awaitable; } ``` -The optional argument keeps existing one-argument custom clients structurally -compatible. Adapters should use the signal for cooperative cancellation where -their client supports it, but the core deadline remains authoritative when -they do not. +| Method | Required semantics | +| --- | --- | +| `read` | Return the serialized `string` or `Buffer`, or `null` for a miss. A tracked request includes `watermarkKey`; compare the value timestamp and watermark atomically. | +| `write` | Apply `cacheTtlMs` and record server time atomically. A tracked request includes `watermarkKey`; return `false` when the watermark rejects publication and `true` when the value was written. | +| `invalidate` | Advance `watermarkKey` monotonically to at least server time plus `futureBufferMs`, while preserving the required derived lifetime. Reject on failure. | + +`write()` returning `false` is a safe publication refusal, not an adapter error. +DialCache still returns the fallback value but skips the corresponding +process-local population. A thrown cache-write error fails open; a thrown +explicit invalidation error is rethrown to the caller. + +The optional `RedisReadContext` keeps existing one-argument readers +structurally compatible. Adapters should use its signal for cooperative +cancellation where their client supports it, but the core deadline remains +authoritative when they do not. The bundled node-redis adapter forwards the signal in per-command options. This can remove queued work where supported, but aborting after dispatch cannot @@ -256,9 +269,18 @@ The core Redis boundary is the client-agnostic `DialCacheRedisClient` interface. It exchanges serialized values as `string | Buffer` and does not expose client-specific commands or wire encodings. -Distinct untracked and tracked read/write Lua sources, the invalidation source, -and wire constants are exported from `dialcache/redis-protocol`. Custom adapters -can throw these root-exported error classes: +The `dialcache/redis-protocol` entry point exports the exact bundled protocol +building blocks: + +- `READ_CACHE_SCRIPT` and `READ_TRACKED_CACHE_SCRIPT`; +- `WRITE_CACHE_SCRIPT` and `WRITE_TRACKED_CACHE_SCRIPT`; +- `INVALIDATE_CACHE_SCRIPT`; and +- `REDIS_FRAME_VERSION`, `REDIS_ENCODING_UTF8`, and + `REDIS_ENCODING_BINARY`. + +The scripts implement the atomic read, publication, invalidation, server-time, +and derived-watermark-lifetime behavior required above. Custom adapters can +throw these root-exported error classes: - `DialCacheRedisPayloadError`; - `DialCacheRedisPayloadEncodingError`; and From 0a833b2241c05d9784ac92ddcaa70832f17ef961 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sat, 25 Jul 2026 20:36:02 -0700 Subject: [PATCH 03/31] docs: refine rollout safety guidance --- README.md | 51 +++++++++++--------- docs/coalescing.md | 21 ++++---- docs/configuration.md | 4 +- docs/invalidation.md | 5 +- docs/observability.md | 6 ++- docs/redis.md | 110 ++++++++++++++++++++++-------------------- 6 files changed, 107 insertions(+), 90 deletions(-) diff --git a/README.md b/README.md index 43391e3..ce8e5de 100644 --- a/README.md +++ b/README.md @@ -26,8 +26,9 @@ invalidation policy, and resource budgets. - **Off by default.** Outside `dialcache.enable(...)`, calls go straight to the loader without building a key, resolving policy, accessing a cache, or coalescing work. -- **Gradual and reversible.** Start either shared layer at `0`, expand it by a - stable subset of keys, and turn every cache layer off through runtime policy. +- **Gradual and reversible.** Start the process-local or remote layer at `0`, + expand it by a stable subset of keys, and turn every cache layer off through + runtime policy. - **Fail-open cache path.** Cache-plumbing failures fall through to the loader instead of replacing a usable result. Explicit invalidation failures still surface to the caller. @@ -63,7 +64,7 @@ Create one long-lived `DialCache` instance for each cache and coalescing domain, typically once per service process: ```ts -import { DialCache, DialCacheKeyConfig } from "dialcache"; +import { CacheLayer, DialCache, DialCacheKeyConfig } from "dialcache"; const dialcache = new DialCache(); @@ -73,7 +74,9 @@ const getUser = dialcache.cached( keyType: "user_id", useCase: "GetUser", cacheKey: (userId) => userId, - defaultConfig: DialCacheKeyConfig.enabled(60), + defaultConfig: new DialCacheKeyConfig({ + ttlSec: { [CacheLayer.LOCAL]: 60 }, + }), }, ); @@ -88,10 +91,9 @@ const user = await dialcache.enable(async () => { ``` `cached(fn, options)` preserves the function's parameters and returns a -Promise-based wrapper. `DialCacheKeyConfig.enabled(60)` gives the process-local -and remote layers a 60-second baseline TTL. It does not enable request-local -memoization, and the remote layer participates only when a Redis or Valkey -client is configured. +Promise-based wrapper. The configuration above enables only the process-local +layer with a 60-second TTL; its omitted ramp defaults to `100`. Request-local +memoization and the remote layer remain off. For a one-shot calculation that should remain inline, [`getOrLoad()`](https://github.com/lan17/DialCache/blob/main/docs/configuration.md#one-shot-inline-loaders) @@ -124,12 +126,12 @@ serving later reads of mutable data. A typical adoption path is: 1. start with the process-local cache shown above; -2. add [Redis or Valkey](https://github.com/lan17/DialCache/blob/main/docs/redis.md) - when values should be shared across processes or hosts; -3. add [Prometheus or Datadog](https://github.com/lan17/DialCache/blob/main/docs/observability.md) +2. add [Prometheus or Datadog](https://github.com/lan17/DialCache/blob/main/docs/observability.md) before increasing production exposure; and -4. connect an application-owned runtime configuration source, then ramp a - stable subset of keys as described next. +3. connect an application-owned runtime configuration source with the remote + ramp at `0`; then +4. add [Redis or Valkey](https://github.com/lan17/DialCache/blob/main/docs/redis.md) + and ramp a stable subset of keys as described next. ## Dial caching up or down @@ -170,7 +172,7 @@ runtimePolicies.set( }), ); -// Later, ramp both shared layers to 100%. +// Later, ramp the process-local and remote layers to 100%. runtimePolicies.set( "GetUser", new DialCacheKeyConfig({ @@ -189,9 +191,9 @@ In production, the provider can read from an application-owned dynamic config client instead of an in-memory map. DialCache resolves one policy snapshot per enabled invocation. -A shared layer needs an effective TTL. With a TTL but no ramp, it defaults to -`100`; a ramp of `0` disables it, `100` selects every key, and an intermediate -value selects a stable key cohort for that layer. +The process-local and remote layers each need an effective TTL. With a TTL but +no ramp, a layer defaults to `100`; a ramp of `0` disables it, `100` selects +every key, and an intermediate value selects a stable key cohort. Ramps select key cohorts, not requests or load, so `10` does not guarantee 10% of calls. Increasing or decreasing a ramp preserves membership for keys that @@ -203,8 +205,8 @@ entries rather than deleting them; a later ramp-up can reuse entries that remain valid. Request-local caching is controlled separately by the `requestLocal` boolean. -`DialCacheKeyConfig.disabled()` sets it to `false` and ramps both shared layers -to `0`. +`DialCacheKeyConfig.disabled()` sets it to `false` and ramps both the +process-local and remote layers to `0`. See [Configuration and cache layers](https://github.com/lan17/DialCache/blob/main/docs/configuration.md) for sparse-overlay precedence, provider failure behavior, externally @@ -226,11 +228,11 @@ open. `enable()` scope. - A process-local hit returns from the `DialCache` instance's bounded LRU. - A process-local miss can read Redis and populate the process-local cache. -- A remote miss runs the fallback and attempts to populate active shared +- A remote miss runs the fallback and attempts to populate the active cache layers. - A remote read failure or timeout runs the fallback without a second Redis - operation. An untracked result may still populate process-local cache; a - tracked result does not, because watermark safety was not established. + operation. Tracked invalidation adds a stricter + [publication rule](https://github.com/lan17/DialCache/blob/main/docs/invalidation.md#read-and-write-behavior). - Same-key concurrent work is coalesced at the lifetime of the first active layer. @@ -324,8 +326,9 @@ before enabling it in production. Concurrent callers with the same cache key share active work within the first active cache scope: one outer request for request-local caching, or one -`DialCache` instance for the shared layers. This mitigates hot-key stampedes -inside that scope; it is not cross-process coordination. +`DialCache` instance when process-local or remote caching is active. This +mitigates hot-key stampedes inside that scope; it is not cross-process +coordination. Same-key followers share the leader's remaining remote-read budget. The fallback deadline starts separately only if and when the source loader begins. diff --git a/docs/coalescing.md b/docs/coalescing.md index 4ce4c34..ca7ffaa 100644 --- a/docs/coalescing.md +++ b/docs/coalescing.md @@ -7,9 +7,10 @@ cache layer. It applies a finite deadline to each active remote read and a separate default deadline once an initially enabled invocation begins its fallback loader. -These mechanisms reduce duplicate source work and give active flights eventual -cleanup. They do not replace cross-process coordination, source-native -cancellation, application admission control, or backpressure. +These mechanisms reduce duplicate source work. Their deadlines help flights +settle, but eventual cleanup still requires finite application-owned budgets +for every injected operation. They do not replace cross-process coordination, +source-native cancellation, admission control, or backpressure. ## Request coalescing @@ -26,7 +27,8 @@ different outer request has a different request-local flight registry. ### Process scope When process-local or remote caching is active, same-key callers share work -within one `DialCache` instance before the first active shared layer. +within one `DialCache` instance before the first active process-local or remote +layer. This is reported as `scope="process"`, but it is instance-scoped: @@ -36,7 +38,7 @@ This is reported as `scope="process"`, but it is instance-scoped: ```ts await dialcache.enable(async () => { - // Same cold key and active shared layer: + // Same cold key and active process-local or remote layer: // one fallback execution, one shared result. const [first, second] = await Promise.all([ getUser("456"), @@ -202,10 +204,11 @@ requested. There is no library-wide flight cap or age-based replacement. A registry cap would bound only DialCache metadata. Overflow or eviction could -still create unbounded source work and unsafe duplicate publication. Finite -operation deadlines provide eventual cleanup; application admission control -and backpressure remain responsible for bounding simultaneous distinct-key -work. +still create unbounded source work and unsafe duplicate publication. +DialCache's remote-read and fallback deadlines cover only those phases; +provider, serializer, and Redis-write settlement remains application-owned. +Admission control and backpressure remain responsible for bounding +simultaneous distinct-key work. Monitor: diff --git a/docs/configuration.md b/docs/configuration.md index 5b42998..20b21d1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -235,8 +235,8 @@ runtime field -> defaultConfig field -> DialCache disabled baseline ``` The disabled baseline sets `requestLocal` to `false` and leaves the -process-local and remote TTLs unset. A shared layer with no effective TTL is -disabled by policy. A shared layer with an effective TTL but no effective ramp +process-local and remote TTLs unset. Either layer is disabled by policy when it +has no effective TTL. With an effective TTL but no effective ramp, that layer defaults to a 100% ramp. The remote-read deadline has two additional fallbacks: diff --git a/docs/invalidation.md b/docs/invalidation.md index b853ec5..1652293 100644 --- a/docs/invalidation.md +++ b/docs/invalidation.md @@ -161,8 +161,9 @@ watermark TTL. Invalidation ensures the TTL covers both the requested future buffer and any still-future existing watermark, plus one minute. It also preserves a longer or -persistent TTL. There is no fixed or configurable retention floor, and reads do -not extend watermark lifetime. +persistent TTL. The one-minute safety margin is fixed; there is no separate +configurable or global retention floor, and reads do not extend watermark +lifetime. ## Choosing `futureBufferMs` diff --git a/docs/observability.md b/docs/observability.md index 599a530..ca361d9 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -135,8 +135,10 @@ const dialcache = new DialCache({ }), }); -// Drain outstanding cache operations before application shutdown. -dogStatsD.close(); +function shutdown(): void { + // Drain outstanding cache operations before application shutdown. + dogStatsD.close(); +} ``` `hot-shots` is the supported and tested client, but the adapter depends only on diff --git a/docs/redis.md b/docs/redis.md index 36c01ef..8f0b52e 100644 --- a/docs/redis.md +++ b/docs/redis.md @@ -4,8 +4,8 @@ DialCache's remote TTL layer supports standalone Redis, standalone Valkey, and Redis Cluster. The application creates, connects, configures, drains, and closes -the underlying client. DialCache borrows a semantic `DialCacheRedisClient` and -does not own the connection lifecycle. +the underlying client. DialCache borrows a client-independent +`DialCacheRedisClient` adapter and does not own the connection lifecycle. ## Install a client @@ -22,7 +22,7 @@ npm install @valkey/valkey-glide ## node-redis Register DialCache's native scripts when creating the client, connect it, and -pass the semantic adapter to `DialCache`: +pass the DialCache-compatible adapter to `DialCache`: ```ts import { createClient } from "redis"; @@ -144,7 +144,7 @@ client-side invocation has settled. ## Remote-read deadlines and async liveness -Every active semantic remote-read leader has a finite monotonic deadline. +Every active remote-read leader has a finite monotonic deadline. DialCache uses this precedence for `cached()` and `getOrLoad()`: ```text @@ -191,7 +191,8 @@ serializer loading, the fallback, Redis writes, nor invalidation. ### Custom-client contract -Custom adapters implement the complete client-agnostic semantic boundary: +Custom adapters implement the complete client-independent read, write, and +invalidate contract: ```ts interface RedisReadContext { @@ -211,9 +212,9 @@ interface DialCacheRedisClient { | Method | Required semantics | | --- | --- | -| `read` | Return the serialized `string` or `Buffer`, or `null` for a miss. A tracked request includes `watermarkKey`; compare the value timestamp and watermark atomically. | -| `write` | Apply `cacheTtlMs` and record server time atomically. A tracked request includes `watermarkKey`; return `false` when the watermark rejects publication and `true` when the value was written. | -| `invalidate` | Advance `watermarkKey` monotonically to at least server time plus `futureBufferMs`, while preserving the required derived lifetime. Reject on failure. | +| `read` | Return the serialized `string` or `Buffer`, or `null` for a miss. For a tracked request, compare the value timestamp and watermark atomically; a missing watermark or a value at or behind it is a miss. | +| `write` | Apply `cacheTtlMs` and record server time atomically. For a tracked request, create a missing baseline, retain it for at least the value TTL plus one minute without shortening a longer or persistent lifetime, and return `false` when it rejects publication. Return `true` only when the value was written. | +| `invalidate` | Advance `watermarkKey` monotonically to at least server time plus `futureBufferMs`. Retain it long enough to cover that buffer and any still-future existing watermark, plus one minute, without shortening a longer or persistent lifetime. Reject on failure. | `write()` returning `false` is a safe publication refusal, not an adapter error. DialCache still returns the fallback value but skips the corresponding @@ -265,49 +266,10 @@ every injected operation. ## Serialization -The core Redis boundary is the client-agnostic `DialCacheRedisClient` interface. -It exchanges serialized values as `string | Buffer` and does not expose -client-specific commands or wire encodings. - -The `dialcache/redis-protocol` entry point exports the exact bundled protocol -building blocks: - -- `READ_CACHE_SCRIPT` and `READ_TRACKED_CACHE_SCRIPT`; -- `WRITE_CACHE_SCRIPT` and `WRITE_TRACKED_CACHE_SCRIPT`; -- `INVALIDATE_CACHE_SCRIPT`; and -- `REDIS_FRAME_VERSION`, `REDIS_ENCODING_UTF8`, and - `REDIS_ENCODING_BINARY`. - -The scripts implement the atomic read, publication, invalidation, server-time, -and derived-watermark-lifetime behavior required above. Custom adapters can -throw these root-exported error classes: - -- `DialCacheRedisPayloadError`; -- `DialCacheRedisPayloadEncodingError`; and -- `DialCacheRedisProtocolError`. - -They distinguish malformed payloads, unsupported encodings, and invalid Lua -reply domains in logs. DialCache records bounded `cache_read`, -`cache_read_timeout`, `cache_write`, or `invalidation` metrics by failure site. - -### Binary frame - -Redis values use a compact binary frame: - -```text -byte 1 format version -bytes 2-9 Redis-created timestamp in milliseconds (uint64, big-endian) -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. - -The payload comes from the cache operation's serializer or `JsonSerializer` by -default. Custom serializers can return `string` or `Buffer`. Strings are -stored as UTF-8; Buffers are stored byte-for-byte without base64 expansion. -Adapters restore the same representation before calling `serializer.load`. +DialCache uses `JsonSerializer` by default. A cache operation can select a +typed serializer, and `redis.serializer` supplies the instance default when an +operation does not select one. Serializers run only for remote reads and +writes; request-local and process-local values remain native references. ### Default JSON behavior @@ -397,3 +359,49 @@ explicit serializer. Providing `Serializer`, including an explicitly typed `JsonSerializer`, is a trusted caller assertion. DialCache does not perform an additional serialize-and-deserialize cycle to validate it. + +### Advanced wire protocol + +The core Redis boundary is the client-independent `DialCacheRedisClient` +interface. It exchanges serialized values as `string | Buffer` and does not +expose client-specific commands or wire encodings. + +The `dialcache/redis-protocol` entry point exports the exact bundled protocol +building blocks: + +- `READ_CACHE_SCRIPT` and `READ_TRACKED_CACHE_SCRIPT`; +- `WRITE_CACHE_SCRIPT` and `WRITE_TRACKED_CACHE_SCRIPT`; +- `INVALIDATE_CACHE_SCRIPT`; and +- `REDIS_FRAME_VERSION`, `REDIS_ENCODING_UTF8`, and + `REDIS_ENCODING_BINARY`. + +The scripts implement the atomic read, publication, invalidation, server-time, +and derived-watermark-lifetime behavior required above. Custom adapters can +throw these root-exported error classes: + +- `DialCacheRedisPayloadError`; +- `DialCacheRedisPayloadEncodingError`; and +- `DialCacheRedisProtocolError`. + +They distinguish malformed payloads, unsupported encodings, and invalid Lua +reply domains in logs. DialCache records bounded `cache_read`, +`cache_read_timeout`, `cache_write`, or `invalidation` metrics by failure site. + +#### Binary frame + +Redis values use a compact binary frame: + +```text +byte 1 format version +bytes 2-9 Redis-created timestamp in milliseconds (uint64, big-endian) +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. + +The payload comes from the cache operation's serializer or `JsonSerializer` by +default. Custom serializers can return `string` or `Buffer`. Strings are +stored as UTF-8; Buffers are stored byte-for-byte without base64 expansion. +Adapters restore the same representation before calling `serializer.load`. From 6502c7c82ac3d9c268dbc4a33eaf20212d9e2502 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sat, 25 Jul 2026 20:43:07 -0700 Subject: [PATCH 04/31] docs: make rollout examples fail safe --- README.md | 23 ++++++++++++++++++----- docs/configuration.md | 7 ++++--- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index ce8e5de..c83595f 100644 --- a/README.md +++ b/README.md @@ -60,8 +60,9 @@ clients application-owned: ## Quick start -Create one long-lived `DialCache` instance for each cache and coalescing domain, -typically once per service process: +Most services create one long-lived `DialCache` instance and reuse it across +the process. It owns one process-local LRU and one process-coalescing scope; +create separate instances only when those resources should be isolated: ```ts import { CacheLayer, DialCache, DialCacheKeyConfig } from "dialcache"; @@ -128,8 +129,8 @@ A typical adoption path is: 1. start with the process-local cache shown above; 2. add [Prometheus or Datadog](https://github.com/lan17/DialCache/blob/main/docs/observability.md) before increasing production exposure; and -3. connect an application-owned runtime configuration source with the remote - ramp at `0`; then +3. extend the policy with a remote TTL and a remote ramp of `0`, using an + application-owned runtime configuration source; then 4. add [Redis or Valkey](https://github.com/lan17/DialCache/blob/main/docs/redis.md) and ramp a stable subset of keys as described next. @@ -157,7 +158,16 @@ const getUser = dialcache.cached( keyType: "user_id", useCase: "GetUser", cacheKey: (userId) => userId, - defaultConfig: DialCacheKeyConfig.enabled(60), + defaultConfig: new DialCacheKeyConfig({ + ttlSec: { + [CacheLayer.LOCAL]: 60, + [CacheLayer.REMOTE]: 60, + }, + ramp: { + [CacheLayer.LOCAL]: 0, + [CacheLayer.REMOTE]: 0, + }, + }), }, ); @@ -187,6 +197,9 @@ runtimePolicies.set( runtimePolicies.set("GetUser", DialCacheKeyConfig.disabled()); ``` +The zero-ramp baseline is the safety net: if the provider has no matching +entry, both layers remain off. + In production, the provider can read from an application-owned dynamic config client instead of an in-memory map. DialCache resolves one policy snapshot per enabled invocation. diff --git a/docs/configuration.md b/docs/configuration.md index 20b21d1..3ac1c44 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -76,9 +76,10 @@ intentionally local to one call site. ## Enable and disable scopes -DialCache performs cache work only inside an enabled asynchronous scope. Create -each `DialCache` instance once and reuse it for the lifetime of its cache and -coalescing domain, typically one service process: +DialCache performs cache work only inside an enabled asynchronous scope. Most +services create one instance and reuse it for the service process. Each +instance owns one process-local LRU and one process-coalescing registry; create +separate instances only to isolate those resources: | API | Behavior | | --- | --- | From e639a05d1857eda46a7e33395ac1d0193f060853 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sat, 25 Jul 2026 20:54:16 -0700 Subject: [PATCH 05/31] docs: clarify read-through cache positioning --- README.md | 38 ++++++++++++++++++++++---------------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index c83595f..10a109b 100644 --- a/README.md +++ b/README.md @@ -4,31 +4,37 @@ [![Codecov](https://codecov.io/gh/lan17/DialCache/branch/main/graph/badge.svg)](https://codecov.io/gh/lan17/DialCache) [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/lan17/DialCache/badge)](https://scorecard.dev/viewer/?uri=github.com/lan17/DialCache) -**Roll out backend caching like a feature—not a leap of faith.** +**Read-through caching with the controls production systems need.** -**DialCache is** a TypeScript library for caching database and service reads -inside Node.js backends. It routes reusable async functions and inline loaders -through one read-through path with request-local memoization, a bounded -in-process LRU, and optional Redis or Valkey caching. +DialCache is a TypeScript read-through caching library for asynchronous +database and service reads in Node.js. Wrap a reusable function with +`cached()` or keep a loader inline with `getOrLoad()`; when the active cache +layers miss, DialCache calls your loader and publishes the result to whichever +request-local, bounded process-local, and optional Redis or Valkey layers are +active. -The “dial” is per-use-case runtime control. Start with caching off, dial the -process-local and remote layers up for stable cohorts of keys, and dial them -back down without changing the loader. +Around that core path, DialCache provides patterns that high-scale services +otherwise have to build themselves: request coalescing, per-use-case runtime +policy, deterministic ramp-up and ramp-down, fail-open cache access, targeted +invalidation, serialization, deadlines, and backend-neutral metrics. -**DialCache is not** a frontend data cache, cache server, Redis or Valkey -client, or runtime configuration service. It supplies the cache path and -rollout controls; your application still decides what is safe to cache and -owns loader behavior, connections, runtime configuration, keys, TTLs, -invalidation policy, and resource budgets. +The “dial” is the runtime policy: start a use case at zero, expand local or +remote caching to stable key cohorts, and reverse the rollout without changing +the loader. + +DialCache is a backend application library—not a frontend data cache, cache +server, Redis or Valkey client, or configuration control plane. Your service +owns the loader, clients, dynamic configuration source, cache identity, TTLs, +invalidation windows, admission control, and resource budgets. ## Safety comes from explicit controls - **Off by default.** Outside `dialcache.enable(...)`, calls go straight to the loader without building a key, resolving policy, accessing a cache, or coalescing work. -- **Gradual and reversible.** Start the process-local or remote layer at `0`, - expand it by a stable subset of keys, and turn every cache layer off through - runtime policy. +- **Gradual and reversible.** Start process-local and remote ramps at `0`, + expand either to a stable key cohort, and turn every cache layer back off + through runtime policy. - **Fail-open cache path.** Cache-plumbing failures fall through to the loader instead of replacing a usable result. Explicit invalidation failures still surface to the caller. From b44c9de34fae1344b063a9e5a6d0e3b8fdfadfc1 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sat, 25 Jul 2026 20:55:30 -0700 Subject: [PATCH 06/31] docs: make README opening easier to scan --- README.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 10a109b..1b83fc6 100644 --- a/README.md +++ b/README.md @@ -6,12 +6,13 @@ **Read-through caching with the controls production systems need.** -DialCache is a TypeScript read-through caching library for asynchronous -database and service reads in Node.js. Wrap a reusable function with -`cached()` or keep a loader inline with `getOrLoad()`; when the active cache -layers miss, DialCache calls your loader and publishes the result to whichever -request-local, bounded process-local, and optional Redis or Valkey layers are -active. +DialCache is a TypeScript read-through caching library for async database and +service reads in Node.js. + +Wrap a reusable function with `cached()` or keep a loader inline with +`getOrLoad()`; when the active cache layers miss, DialCache calls your loader +and publishes the result to whichever request-local, bounded process-local, +and optional Redis or Valkey layers are active. Around that core path, DialCache provides patterns that high-scale services otherwise have to build themselves: request coalescing, per-use-case runtime From e6ec8492ff8ceef38883bd0a4fe19687643b8524 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Wed, 12 Aug 2026 14:16:30 -0700 Subject: [PATCH 07/31] docs: refresh guides for v0.19.0 --- AGENTS.md | 7 +- README.md | 70 +++++-- docs/coalescing.md | 117 ++++++++--- docs/configuration.md | 167 ++++++++++++---- docs/invalidation.md | 62 +++++- docs/maintainers.md | 72 ++++++- docs/observability.md | 114 +++++++++-- docs/redis.md | 407 ++++++++++++++++++++++++++++++-------- docs/shadow-validation.md | 160 +++++++++------ 9 files changed, 918 insertions(+), 258 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 061674a..7572450 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,7 +44,12 @@ test/ # Unit and Redis integration tests - `invalidateRemote()` requires a configured Redis client and rejects when the client is absent or the watermark mutation fails. - Tracked Redis values and invalidation watermarks share a Redis Cluster hash tag. -- Tracked reads run on primaries so replica lag cannot hide invalidation. +- Tracked reads require one authoritative value/watermark snapshot; cluster + adapters explicitly route them to primaries so replica lag cannot hide + invalidation. +- Redis payload compression is default-on for writes, while reads always + interpret the compression envelope so disabling new compression does not + strand existing entries. - A tracked write's placeholder frame (version byte 0) is unreadable on both read paths until the stamp script promotes it, and the stamp promotes only the placeholder carrying its own per-write nonce. diff --git a/README.md b/README.md index c7ac8b4..12e8006 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,8 @@ and optional Redis or Valkey layers are active. Around that core path, DialCache provides patterns that high-scale services otherwise have to build themselves: request coalescing, per-use-case runtime policy, deterministic ramp-up and ramp-down, detached shadow validation, -fail-open cache access, targeted invalidation, serialization, deadlines, and -backend-neutral metrics. +fail-open cache access, targeted invalidation, transparent Redis compression, +deadlines, and backend-neutral metrics. The “dial” is the runtime policy: start a use case at zero, expand local or remote caching to stable key cohorts, and reverse the rollout without changing @@ -58,7 +58,8 @@ invalidation windows, admission control, and resource budgets. npm install dialcache ``` -DialCache requires Node.js 22.0.0 or newer. Production deployments should use a +DialCache requires Node.js `>=22.15.0 <23.0.0 || >=23.8.0`; Node.js 23.0 +through 23.7 lack the `node:zlib` zstd API. Production deployments should use a [currently supported LTS release](https://nodejs.org/en/about/previous-releases). Redis, Valkey, Prometheus, and Datadog integrations are optional and keep their @@ -144,7 +145,7 @@ the initial production rollout policy: 3. add a remote TTL and [Redis or Valkey](https://github.com/lan17/DialCache/blob/main/docs/redis.md) while the remote serving ramp remains `0`; -4. for tracked use cases, optionally +4. optionally [validate and fill Redis in shadow mode](https://github.com/lan17/DialCache/blob/main/docs/shadow-validation.md) without serving it; and 5. increase process-local, shadow, and remote cohorts independently while @@ -237,10 +238,12 @@ Ramping down, including with `DialCacheKeyConfig.disabled()`, bypasses existing entries rather than deleting them; a later ramp-up can reuse entries that remain valid. -Request-local caching is controlled separately by the `requestLocal` boolean. -`DialCacheKeyConfig.disabled()` sets it to `false`, sets `shadow.ramp` to `0` -and `shadow.logMismatches` to `false`, and ramps both the process-local and -remote layers to `0`. +Request-local caching and in-flight sharing are controlled separately. +`requestLocal` defaults to `false`; `coalesce` defaults to `true`. +`DialCacheKeyConfig.disabled()` turns request-local caching and shadow work off +and ramps both shared layers to `0`. It leaves `coalesce` unset, so a later +runtime ramp-up returns to default-on coalescing unless policy explicitly opts +out. A remote serving ramp of `0` alone does not override an inherited nonzero `shadow.ramp`; set both ramps to `0` to stop new invocation-driven Redis reads @@ -252,8 +255,8 @@ coordinated cohorts, remote-read deadlines, and layer validation. ## Validate Redis before serving it -For invalidation-tracked use cases, shadow mode can exercise Redis before Redis -is allowed to serve callers. On a selected tracked Redis hit, DialCache returns +Shadow mode can exercise tracked or untracked Redis entries before Redis is +allowed to serve callers. On a selected Redis hit, DialCache returns the cached value first, then compares a fresh decoding of the retained payload with a detached source read. @@ -265,7 +268,10 @@ sampled by `shadow.ramp`, bounded per instance by `shadowMaxInFlight`, and disabled unless the metrics adapter implements the shadow outcome hook. Shadow validation can add source and Redis work, remains best-effort during -shutdown, and requires tracked keys plus a valid remote TTL. +shutdown, and requires a valid remote TTL. Tracked keys retain watermark +fencing; untracked shadow fills use ordinary TTL-based last-writer-wins writes. +If upgrading from before `v0.15.0`, set `shadow.ramp` to `0` first when +untracked shadow reads and fills have not yet been approved. Confirmed mismatch warnings are separately opt-in through `shadow.logMismatches`. They can include logical cache keys and JSON-serialized @@ -294,14 +300,14 @@ open. - A process-local miss can read Redis and populate the process-local cache. - A remote miss runs the fallback and attempts to populate the active cache layers. -- Selected tracked keys can schedule detached shadow work after a Redis serving - hit or when the Redis serving ramp excludes the key. Shadow work never serves - the caller; it can validate a hit or fill a clean miss. +- Selected tracked or untracked keys can schedule detached shadow work after a + Redis serving hit or when the Redis serving ramp excludes the key. Shadow + work never serves the caller; it can validate a hit or fill a clean miss. - A caller-path remote read failure or timeout runs the fallback without a second caller-path Redis operation. Tracked invalidation adds a stricter [publication rule](https://github.com/lan17/DialCache/blob/main/docs/invalidation.md#read-and-write-behavior). - Same-key concurrent work is coalesced within the scope of the first active - layer. + layer unless the resolved policy sets `coalesce: false`. When all serving layers are disabled by policy, an initially enabled call remains uncached and uncoalesced even if selected shadow work runs @@ -365,6 +371,18 @@ scopes with bounded key cardinality. Process-local values count toward one instance-wide entry cap. Remote values use a serializer selected by the cache operation or the Redis configuration. +Redis payloads at least 4 KiB are compressed with zstd by default, but only +when the encoded value becomes smaller. Compression runs synchronously on the +Node.js event loop, so tune the threshold for your payload and latency profile +or set `redis.compression` to `false`. + +Reads still decode marked compressed entries after writes are disabled, which +means `compression: false` does not strand entries for readers that understand +the envelope. Older package versions are a separate mixed-deployment and +rollback concern. See +[Redis and Valkey](https://github.com/lan17/DialCache/blob/main/docs/redis.md#compression) +for configuration, metrics, size limits, and custom-serializer compatibility. + Shadow validation uses detached Redis work but is not another serving `CacheLayer`. Its sampling, capacity, deduplication, deadline, and metrics contracts are independent of request coalescing and the remote serving ramp. @@ -410,6 +428,12 @@ coordination. Same-key followers share the leader's remaining remote-read budget. The fallback deadline starts separately only if and when the source loader begins. +Set `coalesce: false` only when concurrent calls with the same cache key must +not share caller-specific execution. Each caller then performs independent +layer reads, fallback work, deadlines, and writes; settled request-local values +can still serve later sequential calls. This also gives up stampede protection +for that use case. + Enabled fallbacks have a 60-second monotonic deadline by default. Timing out rejects the DialCache chain and prevents the late result from being published, but it does not cancel the underlying function. Give source operations their @@ -423,8 +447,8 @@ for exact sharing, deadline, cleanup, and admission-control contracts. Metrics are disabled unless a `DialCacheMetricsAdapter` is supplied. First-party adapters support caller-owned Prometheus registries and Datadog DogStatsD clients. Their fixed schemas report layer requests, misses, disabled reasons, -coalescing scopes, serialization work, shadow outcomes, and cache versus -fallback failures. +coalescing scopes, serialization and compression work, shadow outcomes, and +cache versus fallback failures. Keep application-owned namespaces, use-case names, and key types stable and low-cardinality. Optional confirmed-mismatch warnings are value-bearing logs, @@ -451,14 +475,20 @@ Before ramping a use case: - use a conservative `localMaxSize` and bounded request-local scopes; - treat cached values as immutable; - verify serializer compatibility across mixed application versions; +- benchmark synchronous compression for representative payloads and monitor + compression outcomes, prepared payload size, and ratio before changing its + threshold; +- set `coalesce: false` only when independent same-key execution is required + and the resulting source and Redis fan-out is acceptable; - before enabling shadow mode, confirm the loader is safe for an extra observational read, preserve immutable inputs and results, bound concurrency, and monitor added load and outcomes; - before enabling shadow mismatch logging, approve how logical keys and serialized values are redacted, transported, accessed, and retained; - plan shutdown around detached shadow work and application-owned dependencies; and -- for tracked invalidation, use synchronized Redis clocks, durable non-evictable - watermarks, and an application-sized nonzero buffer. +- for tracked invalidation, use authoritative primary reads, synchronized Redis + clocks, durable non-evictable watermarks, and an application-sized nonzero + buffer. ## Reference guides @@ -466,7 +496,7 @@ Before ramping a use case: runtime overlays, request-local and process-local behavior, and value ownership. - [Redis and Valkey](https://github.com/lan17/DialCache/blob/main/docs/redis.md) — node-redis and GLIDE setup, lifecycle, - liveness, binary protocol, and serialization. + liveness, native command protocol, serialization, and compression. - [Shadow validation and Redis bootstrap](https://github.com/lan17/DialCache/blob/main/docs/shadow-validation.md) — non-serving rollout, eligibility, comparison, clean-miss filling, capacity, deadlines, metrics, mismatch diagnostics, and lifecycle. diff --git a/docs/coalescing.md b/docs/coalescing.md index 5f6c133..882d81a 100644 --- a/docs/coalescing.md +++ b/docs/coalescing.md @@ -2,10 +2,10 @@ [Back to the README](../README.md) -DialCache shares same-key in-flight work within the lifetime of the first active -cache layer. It applies a finite deadline to each active remote read and a -separate default deadline once an initially enabled invocation begins its -fallback loader. +By default, DialCache shares same-key in-flight work within the lifetime of the +first active cache layer. A per-use-case policy can disable that sharing. Each +active remote read has a finite deadline, and a separate default deadline begins +when an initially enabled invocation starts its fallback loader. These mechanisms reduce duplicate source work. Their deadlines help flights settle, but eventual cleanup still requires finite application-owned budgets @@ -21,17 +21,18 @@ DialCache has two sharing scopes. ### Request-local scope -When request-local caching is active, callers with the same key in one outermost -`enable()` scope share in-flight work before the request-local lookup. +When request-local caching is active and coalescing is enabled, callers with the +same key in one outermost `enable()` scope share in-flight work before the +request-local lookup. The resolved value is memoized for later sequential calls in that scope. A different outer request has a different request-local flight registry. ### Process scope -When process-local or remote caching is active, same-key callers share work -within one `DialCache` instance before the first active process-local or remote -layer. +When process-local or remote caching is active and coalescing is enabled, +same-key callers share work within one `DialCache` instance before the first +active process-local or remote layer. This is reported as `scope="process"`, but it is instance-scoped: @@ -58,29 +59,85 @@ cache write; followers await that result. For a process-local-only miss, followers share the leader's fallback and local write. This mitigates a thundering herd on one hot key within the instance. +### Per-use-case opt-out + +`DialCacheKeyConfig.coalesce` is a sparse runtime boolean whose effective +default is `true`. Set it to `false` in a use case's `defaultConfig` or runtime +overlay to disable both request-local and process-scoped single-flight: + +```ts +import { CacheLayer, DialCacheKeyConfig } from "dialcache"; + +const getUser = dialcache.cached( + (userId: string) => db.fetchUser(userId), + { + keyType: "user_id", + useCase: "GetUserWithoutSingleFlight", + cacheKey: (userId) => userId, + defaultConfig: new DialCacheKeyConfig({ + ttlSec: { [CacheLayer.LOCAL]: 60 }, + coalesce: false, + }), + }, +); +``` + +Concurrent same-key callers then each perform: + +- their own active-layer reads with a full independent remote-read budget; +- their own fallback, error, and fallback deadline when a fallback is needed; +- their own cache writes after a miss. + +Request-local and process-local publication is last-writer-wins. Each Redis +write keeps its ordinary TTL-based or watermark-fenced semantics. A settled +request-local value can still serve a later sequential call in the same outer +scope; the policy disables in-flight sharing, not memoization or cache hits. + +Runtime overlays can explicitly change the field in either direction. Omission +inherits the baseline and ultimately defaults to `true`. +`DialCacheKeyConfig.disabled()` deliberately leaves `coalesce` unset: with every +serving layer off there is no flight to share, and a later runtime ramp-up +coalesces again unless it explicitly opts out. + +The public constructor and static `defaultConfig` validation require a boolean +when the field is present. A malformed runtime value fails config resolution for +the whole invocation: DialCache warns, records `config_resolution` and +`config_error`, and executes the fallback uncached without touching Redis. + +Use the opt-out when executions with the same value identity must not inherit a +leader's failure, cancellation behavior, or `FallbackTimeoutError`. It does not +make an incomplete cache key safe: if an input changes the returned value, put +it in the key or disable the affected cache layers. Disabling coalescing +reintroduces thundering-herd exposure, independent Redis load, and write races. + +No metric or state surface is added. An opted-out use case emits no +`coalesced` event, records request, miss, and latency observations once per +caller rather than once per flight, and does not register process state in +`getCoalescingState()`. + ## When calls do not coalesce -Coalescing applies only when at least one cache layer is active: +Coalescing applies only when at least one cache layer is active and the resolved +`coalesce` policy is not `false`: - calls that start outside `enable()` are true pass-through; - initially enabled calls with every layer disabled are uncached and - uncoalesced; and + uncoalesced; +- a use case with `coalesce: false` keeps each caller's cache path independent; - process-scoped work is never shared across `DialCache` instances. An initially enabled all-disabled call still receives the fallback deadline described below. -Because coalescing is keyed by the full constructed cache key, concurrent calls -with the same identity share the leader's execution. Every function argument -or captured value omitted from the selected or direct key must be safe to share -this way. +The full constructed cache key always defines cached-value identity. Include +locale, auth context, or any other input that can change the returned value, +regardless of the coalescing policy. -Include locale, auth context, cancellation behavior, or any other input in the -key when it can change: - -- the returned value; -- whether the underlying function should run independently; or -- whether two callers may safely share one result. +When coalescing is enabled, that same key also defines execution identity: +concurrent calls with the same key share the leader's execution. Include +cancellation behavior and other execution-only inputs when they must differ by +key, or use `coalesce: false` when their results remain safe to cache under the +same value identity but their in-flight work must stay independent. ### Shadow work does not enable caller coalescing @@ -93,9 +150,12 @@ shadow jobs are deduplicated by admitting one and reporting the others as `dropped`; callers do not join or await that job. A serving Redis hit reached through a process-scoped leader schedules at most -one shadow job for its coalesced followers. `shadowMaxInFlight` limits scheduled -or running shadow jobs across the instance, independently of request-local and -process-scoped flights. See +one shadow job for its coalesced followers. With `coalesce: false`, each caller +can attempt to schedule validation, but exact-key shadow deduplication admits at +most one concurrent job and reports the other attempts as `dropped`. + +`shadowMaxInFlight` limits scheduled or running shadow jobs across the +instance, independently of request-local and process-scoped flights. See [Shadow validation and Redis bootstrap](shadow-validation.md) for the full admission and lifecycle contract. @@ -142,6 +202,8 @@ The timer starts only when the fallback begins: - same-key followers share the request-local or process leader's remaining budget and receive its `FallbackTimeoutError`; +- callers with `coalesce: false` start independent fallback timers and receive + independent errors; - a remote read failure or timeout starts the fallback timer only when the source loader begins; - enabled pass-through invocations where every layer is disabled have @@ -178,7 +240,7 @@ shutdown requirements. Timing out: 1. rejects the DialCache chain; -2. clears its tracked flight normally; +2. clears its coalescing flight normally, when one exists; 3. ignores a later fallback resolution; and 4. prevents that invocation from proceeding to serializer, Redis, or local publication. @@ -196,7 +258,8 @@ Timeout failures retain the bounded metrics classification details without adding high-cardinality labels. A shared remote-read timeout emits one `cache_read_timeout` error for the -leader, not one per follower. +leader, not one per follower. With coalescing disabled, each caller owns its +read and can emit its own timeout error. ### Shadow deadlines are separate @@ -242,6 +305,8 @@ after that point. Request-local flights are deliberately excluded because their lifecycle is bounded by the outer `enable()` scope. Shadow jobs are also excluded; they use their own capacity registry and outcome metrics. +Use cases with `coalesce: false` never register process flights and therefore do +not appear in this state. `oldestLeaderAgeMs` uses a monotonic clock and is computed when the snapshot is requested. diff --git a/docs/configuration.md b/docs/configuration.md index 97b4eea..d6a5539 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -3,8 +3,9 @@ [Back to the README](../README.md) This guide covers reusable cached functions, one-shot inline loaders, cache -identity, runtime policy, request-local and process-local behavior, and -cached-value ownership. For the shared remote layer, see +identity, runtime policy, coalescing policy, request-local and process-local +behavior, Redis payload-compression configuration, and cached-value ownership. +For the complete shared remote-layer contract, see [Redis and Valkey](redis.md). ## Defining cache operations @@ -68,9 +69,13 @@ 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. +`{ id, args }` key. By default, 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. + +A use case can explicitly set `coalesce: false` when its callers must execute +independently, but that does not make an incomplete cache key safe for settled +cache hits. Shadow work can run the loader later, after the caller has continued. Snapshot mutable arguments or captured state before invoking the operation so that the @@ -189,9 +194,11 @@ characters for Redis Cluster hash tags. name or value. - **Non-key inputs still reach the loader.** A database handle can be a normal function parameter ignored by `cacheKey` or a value captured by a - `getOrLoad()` loader. Concurrent same-key misses share the leader's - execution. Do not omit values such as `AbortSignal`, auth context, locale, or - other request-scoped inputs unless sharing one result is correct. + `getOrLoad()` loader. Concurrent same-key misses share the leader's execution + unless the resolved policy explicitly sets `coalesce: false`. Do not omit + values such as `AbortSignal`, auth context, locale, or other request-scoped + inputs unless both sharing in-flight work and reusing a settled cache value + are correct. - **Methods need a receiver.** Pass `obj.method.bind(obj)` or `(...args) => obj.method(...args)`; a bare `obj.method` reference loses `this`. @@ -220,7 +227,7 @@ Instance-wide behavior is set through the `DialCache` constructor: | `DialCacheConfig` option | Default | Description | | --- | --- | --- | | `namespace` | `"urn"` | Logical cache namespace and first key component. | -| `redis` | none | `{ client, readTimeoutMs?, serializer? }`; enables the [remote layer](redis.md). Remote reads default to a 50 ms deadline. | +| `redis` | none | `{ client, readTimeoutMs?, serializer?, compression? }`; enables the [remote layer](redis.md). Remote reads default to a 50 ms deadline, and Redis payload compression defaults to zstd level 3 at 4,096 serialized bytes. | | `localMaxSize` | `10_000` | Global process-local entry cap. `0` disables process-local storage. Must be a nonnegative safe integer. | | `cacheConfigProvider` | none | Resolves runtime config per enabled invocation as a sparse overlay on the operation's `defaultConfig`; `null` applies no overrides. | | `shadowMaxInFlight` | `1` | Maximum scheduled or running shadow jobs per instance. Must be a positive safe integer. There is no queue; excess jobs are dropped and measured. | @@ -228,10 +235,10 @@ Instance-wide behavior is set through the `DialCache` constructor: | `logger` | `console` | Receives operational cache failures and opted-in confirmed shadow mismatch warnings through `debug`, `warn`, and `error`. | Per-invocation policy is a `DialCacheKeyConfig`: per-layer `ttlSec` and `ramp` -maps keyed by `CacheLayer.LOCAL` and `CacheLayer.REMOTE`, a `requestLocal` -boolean, an optional `remoteReadTimeoutMs`, and an optional `shadow` group. -The root-exported `ShadowConfig` type defines that group's independent `ramp` -and default-off `logMismatches` leaves. +maps keyed by `CacheLayer.LOCAL` and `CacheLayer.REMOTE`, `requestLocal` and +`coalesce` booleans, an optional `remoteReadTimeoutMs`, and an optional +`shadow` group. The root-exported `ShadowConfig` type defines that group's +independent `ramp` and default-off `logMismatches` leaves. ### Baseline and overlay precedence @@ -246,7 +253,8 @@ runtime field -> defaultConfig field -> DialCache disabled baseline ``` The disabled baseline sets `requestLocal` to `false`, leaves the process-local -and remote TTLs unset, and leaves `shadow` absent. +and remote TTLs unset, and leaves `shadow` absent. Coalescing defaults to +`true`, but no flight exists while every cache layer is inactive. Either serving layer is disabled by policy when it has no effective TTL. With an effective TTL but no effective ramp, that layer defaults to a 100% ramp. @@ -264,24 +272,29 @@ runtime remoteReadTimeoutMs This value bounds how long DialCache waits for an active Redis or Valkey read. It can be tuned per use case at runtime, but it cannot be disabled. -`DialCacheKeyConfig` preserves an omitted `requestLocal` as `undefined`, so the -overlay can distinguish omission from an explicit `false`. Its effective value -still defaults to `false` after resolution. +`DialCacheKeyConfig` preserves omitted `requestLocal` and `coalesce` leaves as +`undefined`, so the overlay can distinguish omission from an explicit +`false`. Their effective defaults are `false` for request-local memoization and +`true` for coalescing. A provider result of `null`, or a defensive `undefined`, applies no overrides. An empty `DialCacheKeyConfig` and omitted runtime fields also inherit the baseline. -Overlay merging is sparse at each leaf. The local and remote entries inside -`ttlSec` and `ramp` merge independently, as do `shadow.ramp` and -`shadow.logMismatches`. For example, `shadow: { ramp: 0 }` disables inherited -shadow admission while preserving an inherited logging preference; -`shadow: { logMismatches: false }` suppresses warnings without changing the -inherited shadow cohort. +Overlay merging is sparse at each leaf. Top-level `requestLocal`, `coalesce`, +and `remoteReadTimeoutMs` leaves merge independently. The local and remote +entries inside `ttlSec` and `ramp` also merge independently. + +The `shadow.ramp` and `shadow.logMismatches` leaves follow the same rule. For +example, `shadow: { ramp: 0 }` disables inherited shadow admission while +preserving an inherited logging preference; `shadow: { logMismatches: false }` +suppresses warnings without changing the inherited shadow cohort. Use explicit values to replace inherited policy: - `requestLocal: false` disables request-local caching; +- `coalesce: false` gives each caller its own active layer reads, fallback + deadline, fallback execution, and cache writes; - a process-local or remote ramp of `0` disables that serving layer; - `shadow: { ramp: 0 }` disables new shadow work; and - `DialCacheKeyConfig.disabled()` turns request-local and shadow work off and @@ -291,10 +304,14 @@ The remote serving and shadow cohorts are independent. A remote ramp of `0` does not override an inherited nonzero `shadow.ramp`; set both to `0` when the runtime policy must stop new invocation-driven Redis reads and fills. -`DialCacheKeyConfig.disabled()` returns the complete overlay explicitly: -`requestLocal: false`, both serving ramps at `0`, `shadow.ramp: 0`, and -`shadow.logMismatches: false`. Its `ttlSec` map is empty, so inherited TTLs -remain available for a later ramp-up but inactive under this overlay. The kill +`DialCacheKeyConfig.disabled()` returns the complete cache-path overlay +explicitly: `requestLocal: false`, both serving ramps at `0`, +`shadow.ramp: 0`, and `shadow.logMismatches: false`. It intentionally leaves +`coalesce` unset. + +Its `ttlSec` map is empty, so inherited TTLs remain available for a later +ramp-up but inactive under this overlay. If runtime policy ramps a layer back +up, coalescing is on again unless another leaf explicitly opts out. The kill switch does not cancel already-admitted work or disable explicit maintenance operations such as `invalidateRemote()`. @@ -308,13 +325,15 @@ whenever `getOrLoad()` is invoked: - serving ramps and `shadow.ramp` must be finite percentages in the inclusive range `0` through `100`; - layer maps and `shadow` must be objects; -- `requestLocal` and `shadow.logMismatches` must be booleans when present; and +- `requestLocal`, `coalesce`, and `shadow.logMismatches` must be booleans when + present; and - remote-read deadlines must be positive safe integers no greater than 2,147,483,647 milliseconds. -Invalid instance `redis.readTimeoutMs` values throw during `DialCache` -construction, as does an invalid `shadowMaxInFlight`. Invalid defaults are -rejected when `cached()` registers a definition or `getOrLoad()` is invoked. +Invalid instance `redis.readTimeoutMs` or `redis.compression` values throw +during `DialCache` construction, as does an invalid `shadowMaxInFlight`. +Invalid defaults are rejected when `cached()` registers a definition or +`getOrLoad()` is invoked. `null`, zero, fractional, non-finite, string, and larger timeout values are invalid; remote reads have no unbounded escape hatch. @@ -333,8 +352,9 @@ valid default leaves: Invalid leaves also record a `config_resolution` error, distinguishing provider garbage from an intentional ramp-down. A malformed runtime config object, -layer-map or `shadow` shape, `requestLocal`, or `remoteReadTimeoutMs` value fails -config resolution for the whole invocation. DialCache records +layer-map or `shadow` shape, `requestLocal`, `coalesce`, or +`remoteReadTimeoutMs` value fails config resolution for the whole invocation. +DialCache records `config_resolution`, marks the no-layer path `config_error`, and runs the fallback without a Redis read or write. @@ -437,9 +457,12 @@ eligible key. `shadow.logMismatches` separately opts confirmed mismatches into byte-capped JSON warning fields; it does not enable shadow work and defaults to `false`. Review the data-handling contract before turning it on. -Shadowing requires a valid remote TTL, invalidation tracking, and a metrics -adapter with the shadow outcome hook. It can validate a served Redis hit or -exercise Redis while the remote serving ramp excludes the key. See +Shadowing requires a valid remote TTL and a metrics adapter with the shadow +outcome hook. Tracked and untracked Redis operations are both eligible and +keep their normal read and write mode. + +Shadow work can validate a served Redis hit or exercise Redis while the remote +serving ramp excludes the key. See [Shadow validation and Redis bootstrap](shadow-validation.md) for eligibility, clean-miss filling, deadlines, capacity, and rollout guidance. @@ -448,6 +471,29 @@ defaults to Node strict deep equality and receives borrowed decoded-cache and source values. A custom comparator must be synchronous, deterministic, side-effect-free, non-mutating, and bounded. +### Coalescing policy + +Coalescing is on unless the resolved policy explicitly sets +`coalesce: false`. The switch covers both request-local and instance-scoped +process flights. + +With it off, concurrent same-key callers each perform their own active layer +reads, receive a full independent remote-read and fallback budget, run their +own loader after a miss, and attempt their own writes. +Settled request-local memoization still serves later sequential calls. +Process-local and untracked Redis writes remain last-writer-wins, while tracked +Redis writes retain their watermark fence. + +Opt out when callers sharing one identity must not inherit another caller's +loader failure, timeout, or cancellation behavior. Doing so reintroduces +same-key fan-out to dependencies. + +It also suppresses coalesced-follower metrics and keeps those calls out of +`getCoalescingState()`; each caller emits its own request, miss, latency, and +error observations. See +[Coalescing and async liveness](coalescing.md) for flight scope, deadlines, +shadow scheduling, and observability details. + ### Provider key input `cacheConfigProvider` receives the fully constructed, read-only `DialCacheKey` @@ -483,6 +529,55 @@ the root exports: The namespace and hash-tag components reject `{` and `}` as described under [Identity rules](#identity-rules). +## Redis payload compression + +`RedisConfig.compression` is instance-wide write policy for the remote layer. +It is enabled by default when Redis is configured: + +```ts +import { DialCache, type CompressionConfig } from "dialcache"; + +const compression: CompressionConfig = { + thresholdBytes: 4_096, + level: 3, +}; + +const dialcache = new DialCache({ + redis: { + client: dialCacheRedisClient, + compression, + }, +}); +``` + +`thresholdBytes` must be a positive safe integer and defaults to `4_096`. +`level` must be an integer from `1` through `22` and defaults to `3`. +Passing `false` disables compression for new writes; `null`, other non-object +values, and invalid leaves throw during `DialCache` construction. Compression +is static instance configuration rather than per-use-case runtime policy. + +DialCache compresses a serialized payload only when it meets the threshold and +the zstd frame plus its marker is smaller than the raw stored form. Reads +always decode marked payloads, even when writes use `compression: false`, so +turning compression off does not orphan entries already written compressed. + +Raw binary serializer output beginning with an envelope byte is escaped on +every write, including when compression is disabled. + +Compression and decompression run synchronously on the Node.js event loop. +The exact package engine range is `>=22.15.0 <23.0.0 || >=23.8.0` so +`node:zlib` exposes zstd. + +Decompressed payloads are capped at 512 MiB, and the write side refuses to +compress values above the same ceiling. Start with the default level, watch +compression duration and ratio metrics, and treat higher levels as a +latency-sensitive production change. + +See [Redis payload compression](redis.md#compression) for the exact envelope, +mixed-version rollout and rollback behavior, binary-serializer migration, and +failure semantics. See [Observability](observability.md#compression-metrics) +for the bounded outcomes and pre- versus post-compression measurements. + ## Request-local cache Set `requestLocal: true` to memoize resolved values for the lifetime of the diff --git a/docs/invalidation.md b/docs/invalidation.md index ee80ebc..e964d3f 100644 --- a/docs/invalidation.md +++ b/docs/invalidation.md @@ -96,8 +96,11 @@ it. ## Read and write behavior -A tracked Redis value whose Redis-created timestamp is older than or equal to -the watermark is treated as stale and refreshed through fallback. +A tracked read obtains the value and watermark in one atomic `MGET`. Bundled +cluster adapters explicitly route it to the slot primary; a standalone +node-redis client must already target the authoritative endpoint. A readable +frame whose Redis-stamped creation time is older than or equal to the watermark +is treated as stale and refreshed through fallback. `invalidateRemote(keyType, id, futureBufferMs)` sets the watermark to the greater of: @@ -107,12 +110,15 @@ greater of: While that future window is active: -1. A tracked Redis read treats the covered value as a miss. +1. A tracked Redis read receives the covered value and watermark, then treats + the value as a miss. 2. The invocation runs its fallback. -3. If that fallback reaches the tracked Redis write before the window ends, - Redis rejects the write. -4. DialCache also suppresses the corresponding process-local population. -5. The fallback value still returns to its caller. +3. DialCache serializes and optionally compresses the fallback value, then a + native `SET` writes the complete payload as an unreadable placeholder. +4. A small stamp script compares Redis time with the watermark. If the window + is still active, it unlinks the placeholder and refuses publication. +5. DialCache suppresses the corresponding process-local population, while the + fallback value still returns to its caller. Request-local memoization remains unconditional. A ramped-out invocation without selected shadow work does not consult the watermark and is not fenced @@ -129,6 +135,27 @@ and process-local publication. This differs from a normal tracked miss, which can attempt the fenced Redis write. Untracked fallbacks may still populate process-local cache, and request-local memoization remains unconditional. +### Invalidated payload transfer and cleanup + +The atomic `MGET` transfers the complete Redis frame before the adapter can +compare its timestamp with the watermark. Large invalidated values can +therefore consume network bandwidth—and can repeatedly exceed the remote-read +deadline—even though DialCache will not serve them. + +A successful fallback that reaches the tracked stamp while the fence is active +partially mitigates this: its placeholder `SET` replaces the stale frame and +the stamp script unlinks the placeholder. Later reads then avoid transferring +the old payload. + +A read error or timeout skips the write entirely, so it cannot perform this +cleanup. A fallback or write failure can likewise leave cleanup for a later +successful attempt or the value TTL. + +The cleanup is not free. Every fenced write sends and temporarily stores the +complete serialized, possibly compressed payload before removing it. Include +that network transfer, Redis allocation, replication or AOF work, and stamp +round trip when estimating the load created by an oversized future buffer. + ### Shadow reads and fills [Shadow mode](shadow-validation.md) uses the same tracked protocol. A sampled @@ -211,9 +238,10 @@ value based on measured or conservatively bounded timings. Size it to cover: - the full remaining tail of any fallback that may already have observed the pre-mutation value; - `serializer.dump`; -- Redis client queue and network latency; -- Lua script execution; -- the Redis write itself; and +- synchronous compression or raw-payload escaping; +- Redis client queue and network latency for the full placeholder payload; +- the native placeholder `SET` and the tracked stamp script, including their + ordered dispatch and settlement; and - a safety margin. Include the remaining lifetime of any sampled shadow fill based on a source @@ -236,6 +264,14 @@ callers. ## Failure behavior and telemetry +The bundled adapters dispatch invalidation with `EVALSHA` and retry a rejected +dispatch once with the script source through `EVAL`. Because its monotonic +update only advances the watermark and widens its lifetime, duplicate +execution after an ambiguous first result is safe. A successful recovery is +internal to the adapter and produces no DialCache error or retry metric. See +[Mutation retries and ambiguity](redis.md#mutation-retries-and-ambiguity) for +adapter-specific error handling. + For a valid buffer, DialCache invokes the configured invalidation metric hook with `layer="remote"` before it checks the Redis prerequisite. @@ -246,6 +282,12 @@ invokes the configured error metric hook with `useCase="watermark"`, the original error. Logger and metrics callback failures are isolated and cannot replace that rejection. +A surfaced mutation failure is ambiguous: Redis may have advanced the +watermark before the client lost the reply. Do not interpret the rejection as +proof that nothing executed. Repeating `invalidateRemote()` after the source +mutation has committed is safe and advances or preserves the fence, but may +extend the future miss window. + ## In-memory layers remain local Targeted invalidation is remote-only. `invalidateRemote` does not evict existing diff --git a/docs/maintainers.md b/docs/maintainers.md index 7045011..f67dd86 100644 --- a/docs/maintainers.md +++ b/docs/maintainers.md @@ -18,9 +18,14 @@ uses Testcontainers and requires a working Docker-compatible container runtime for Redis, Valkey, and Redis Cluster. CI runs development and integration checks on Node.js 24, then switches to the -declared minimum Node.js 22.0.0 to test the packed package. Keep the consumer -floor separate from the development runtime so a new dependency or emitted -syntax cannot silently raise the published requirement. +exact 22.x consumer floor, Node.js 22.15.0, to prove both the packed package and +`node:zlib` zstd support. The published engine range is +`>=22.15.0 <23.0.0 || >=23.8.0`, because Node.js 23.0 through 23.7 do not expose +the required zstd API. + +Keep the consumer floor separate from the development runtime so a dependency, +emitted syntax, or runtime API cannot silently raise the published +requirement. Before changing a compatibility-sensitive surface, identify and extend the corresponding packed, unit, and integration assertions: @@ -32,17 +37,29 @@ corresponding packed, unit, and integration assertions: - deterministic serving- and shadow-ramp assignment, whose independent cohorts must not reshuffle across releases, plus nested shadow-policy snapshot, overlay, validation, and legacy `shadowRamp` rejection; -- the binary Redis frame, Lua arguments and reply domains, tracked - read/write/invalidation semantics, mixed-version serializer behavior, and the - ownership and immutability contract for retained string and `Buffer` - payloads; +- `coalesce` omission defaulting to enabled, sparse boolean overlays, explicit + opt-out in both request and process scopes, independent deadlines and writes, + settled request-local reuse, and malformed-value fail-open behavior; +- native `GET`/`MGET` reads, native untracked `SET` writes, and the ordered + tracked placeholder-`SET` plus stamp-script pair, including exact frame + encoders, script reply domains, the root-exported placeholder-loss error, + wrong-type behavior, Redis Cluster routing, and removed read/write-script + exports; +- tracked invalidation plus tracked and untracked shadow behavior, + mixed-version serializer behavior, and the ownership and immutability + contract for retained string and `Buffer` payloads; +- default-on zstd configuration and validation, binary envelope collisions, + decompression caps, raw fallback, mixed-version upgrades and rollbacks, + first-party and optional custom-adapter metrics, and the exact Node.js floor; - rejection and bounded error telemetry when `invalidateRemote()` is called without a configured Redis client; - shadow confirmation, clean-miss fill, capacity, deadline, detached work, and payload-release behavior, plus default-off confirmed-mismatch logging and its byte-capped native-JSON detail fields; - exhaustive public unions and packed exports, including `MetricLayer`, - `ShadowValidationOutcome`, `ShadowComparator`, and `ShadowConfig`; and + `ShadowValidationOutcome`, `ShadowComparator`, `ShadowConfig`, + `CompressionConfig`, compression metric types, and Redis protocol error + classes; and - bounded metrics names, labels, reasons, error categories, scopes, outcomes, units, and observer isolation from synchronous throws and rejected thenables. @@ -89,6 +106,38 @@ Override its work sizes with: - `DIALCACHE_BENCH_ITERATIONS`; and - `DIALCACHE_BENCH_FANOUT`. +## Redis write benchmark + +With a Redis server reachable at `REDIS_URL` (default +`redis://127.0.0.1:6379`), run: + +```bash +corepack pnpm benchmark:redis-write +``` + +The command builds `dist`, then measures eight sequential configurations: +tracked and untracked writes at 100 B, 10 KiB, 100 KiB, and 1 MiB. It reports +server-side command time per write from `INFO commandstats`, plus client-side +p50 and p95 latency. For tracked writes, the `EVALSHA` entry envelopes the +stamp script's internal command cost. + +This benchmark is a maintainer diagnostic and is not included in the published +package. It deliberately has no semantic assertion or timing threshold: +absolute results depend on the machine, Redis engine, payload, and ambient +load. + +Run it only against a dedicated disposable or development Redis. It writes +fixed `benchmark:write:*` keys and executes `CONFIG RESETSTAT` before every +sample, so the client needs that permission and the command erases the +server's accumulated command statistics. The script calls the semantic +adapter's `write()` method directly with prebuilt payloads; it measures neither +serializer nor compression cost. + +Compare implementations only with fresh alternating samples in the same +environment, and preserve correctness coverage in unit, packed-package, and +live integration tests. Scale every iteration count with +`DIALCACHE_BENCH_WRITE_SCALE`. + ## Releasing Publishing starts by manually running the `Release` workflow from current @@ -97,13 +146,18 @@ Publishing starts by manually running the `Release` workflow from current After the package checks pass, Semantic Release selects the next version from Conventional Commits since the highest stable `vX.Y.Z` tag: -- breaking changes bump major; +- while the package is pre-1.0, breaking changes bump minor and retain their + `BREAKING CHANGE:` footers for full release notes; - `feat` bumps minor; and - every other normal PR-title type bumps patch. Patch types are `fix`, `perf`, `docs`, `style`, `refactor`, `test`, `build`, `chore`, `ci`, and `revert`. The highest required bump wins. +Major bumps resume when the project cuts 1.0.0. `release.config.mjs` implements +this policy; change it and this guide together so the documented release table +cannot drift from automation. + The workflow opens a `release: ` pull request whose only change is the matching `package.json` version. `release` is a reserved Conventional Commit type configured not to request another release, so the version-control commit diff --git a/docs/observability.md b/docs/observability.md index 7c378b6..63f3744 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -64,10 +64,14 @@ The names below exclude the optional caller-selected prefix: | `dialcache_invalidation_counter` | Counter | `cache_namespace`, `key_type`, `layer` | Invalidation calls for the layers touched | | `dialcache_coalesced_counter` | Counter | `cache_namespace`, `use_case`, `key_type`, `scope` | Coalesced requests split by request-local or process scope | | `dialcache_shadow_validation_counter` | Counter | `cache_namespace`, `use_case`, `key_type`, `outcome` | Terminal outcomes for sampled Redis shadow jobs | +| `dialcache_compression_counter` | Counter | `cache_namespace`, `use_case`, `key_type`, `layer`, `outcome` | Bounded Redis payload compression and decompression outcomes | | `dialcache_get_timer` | Histogram | `cache_namespace`, `use_case`, `key_type`, `layer` | Cache get latency in seconds | | `dialcache_fallback_timer` | Histogram | `cache_namespace`, `use_case`, `key_type`, `layer` | Elapsed time until the wrapped fallback settles or timeout rejection is delivered | | `dialcache_serialization_timer` | Histogram | `cache_namespace`, `use_case`, `key_type`, `layer`, `operation` | Redis serializer dump/load latency in seconds | -| `dialcache_size_histogram` | Histogram | `cache_namespace`, `use_case`, `key_type`, `layer` | Serialized Redis payload size in bytes | +| `dialcache_size_histogram` | Histogram | `cache_namespace`, `use_case`, `key_type`, `layer` | Serialized Redis payload size in bytes, before compression | +| `dialcache_stored_size_histogram` | Histogram | `cache_namespace`, `use_case`, `key_type`, `layer` | Prepared Redis payload size in bytes, after compression and escaping | +| `dialcache_compression_ratio_histogram` | Histogram | `cache_namespace`, `use_case`, `key_type`, `layer` | Compressed-to-original payload size ratio for compressed writes | +| `dialcache_compression_timer` | Histogram | `cache_namespace`, `use_case`, `key_type`, `layer`, `operation` | Compression and decompression latency in seconds | The disabled reasons are: @@ -93,15 +97,16 @@ The `layer` label is: - `request_local`; - `local`, meaning process-local; - `remote`; -- `remote_shadow` for Redis reads, fills, serialization, and payload sizes - performed by detached shadow jobs; or +- `remote_shadow` for Redis reads, fills, serialization, compression, and + payload sizes performed by detached shadow jobs; or - `noop` for disabled-context, key-construction, and config-provider failures where no cache layer was reached. The bounded `scope` label on `dialcache_coalesced_counter` distinguishes `request_local` from `process`. `scope="process"` coordinates calls only within one `DialCache` instance; separate instances in the same process do not share -in-flight state. +in-flight state. A use case with `coalesce: false` emits no coalesced counter; +each caller instead emits its own request, miss, duration, and error metrics. ### Shadow outcomes @@ -115,8 +120,8 @@ outcomes through `dialcache.shadow.count`: | `mismatch` | They differed, and a confirmation read found the original Redis payload unchanged. | | `superseded` | They differed, but the Redis payload changed or disappeared before confirmation. | | `filled` | A clean shadow miss was populated successfully. | -| `fill_blocked` | An invalidation watermark blocked a clean-miss fill. | -| `fill_error` | Serializing or writing a clean-miss fill failed. | +| `fill_blocked` | An invalidation watermark blocked a tracked clean-miss fill; compliant untracked writes do not produce it. | +| `fill_error` | Preparing the payload (serialization or compression) or writing a clean-miss fill failed. | | `redis_error` | The initial detached Redis read failed. | | `source_error` | The source-of-truth read failed. | | `deserialization_error` | The retained Redis payload could not be deserialized for comparison. | @@ -131,6 +136,61 @@ keeps detached work separate from caller-serving `layer="remote"` telemetry. See [Shadow validation](shadow-validation.md) for the read, confirmation, fill, and deadline semantics behind these outcomes. +### Compression metrics + +Compression telemetry is bounded and uses `layer="remote"` for caller-serving +work or `layer="remote_shadow"` for detached shadow work. + +Write-side outcomes are: + +- `compressed`: zstd plus its envelope was smaller and selected for the + prepared Redis payload; +- `below_threshold`: the serialized payload did not reach the configured + threshold; +- `not_smaller`: compression ran, but the marked result was not smaller than + the raw stored form; and +- `write_over_limit`: the serialized value exceeded the 512 MiB decompression + ceiling and was kept raw for the attempted write. This is a capacity signal, + not an error. + +Read-side outcomes are: + +- `decompressed`: a marked zstd payload was restored; +- `fallback_raw`: a marked payload was not valid zstd and was passed unchanged + to the serializer; and +- `read_over_limit`: decompression would exceed the 512 MiB ceiling, so the + stored bytes were passed unchanged to the serializer. Treat this as a + corruption or integrity signal. + +Raw reads do not emit a compression outcome. With `compression: false`, new +writes are still escaped when necessary but emit no compression outcome; reads +continue to report marked values because disabling writes does not disable +decoding. + +`dialcache_size_histogram` measures serializer output before compression and is +the distribution to use when selecting `thresholdBytes`. +`dialcache_stored_size_histogram` measures the prepared bytes after compression +or binary-envelope escaping. DialCache records it before the shadow deadline +gate and before calling the Redis client, so it is not proof that a write was +dispatched or succeeded. The ratio histogram is emitted when compression +selects the smaller representation, at the same pre-write stage. + +Compression duration is observed when zstd runs and produces either +`compressed` or `not_smaller`; decompression duration is observed for each +marked payload that produces a read-side outcome. + +A zstd exception while preparing a write records `error="compression"` and +the cache write fails open. Decompression rejects neither the cache call nor +the observer path directly: an unreadable payload reaches the configured +serializer, whose rejection follows the existing refreshable-miss path and +records `serialization_load`. + +zstd work is synchronous on the Node.js event loop. Use the duration, ratio, +and pre/post-size series together when changing the threshold or level; a good +space ratio does not make an event-loop stall acceptable. See +[Redis payload compression](redis.md#compression) for the envelope, limits, +and mixed-version rollout contract. + ### Confirmed mismatch warnings Shadow metrics remain bounded and contain no cache ids or values. A use case can @@ -165,7 +225,7 @@ import { DialCache } from "dialcache"; import { createDatadogDialCacheMetrics } from "dialcache/datadog"; const dogStatsD = new StatsD({ - host: process.env.DD_AGENT_HOST, + host: process.env.DD_AGENT_HOST ?? "127.0.0.1", globalTags: { service: "users-api", env: process.env.DD_ENV ?? "development", @@ -209,8 +269,8 @@ hosts. Enable the desired distribution percentiles and aggregations in Datadog. Choose `"histogram"` when host-level histogram aggregation matches the existing -Datadog setup. The choice applies uniformly to all four duration and size -metrics. Both modes produce Datadog custom metrics. +Datadog setup. The choice applies uniformly to every duration, size, and ratio +observation emitted by the adapter. Both modes produce Datadog custom metrics. Distribution volume scales with unique tag-value combinations. Datadog counts five baseline aggregations per combination; enabling percentile aggregations @@ -257,10 +317,14 @@ and bytes without unit conversion: | `dialcache.invalidation.count` | Count | `cache_namespace`, `key_type`, `layer` | Invalidation calls for the layers touched | | `dialcache.coalesced.count` | Count | `cache_namespace`, `use_case`, `key_type`, `scope` | Coalesced requests by sharing scope | | `dialcache.shadow.count` | Count | `cache_namespace`, `use_case`, `key_type`, `outcome` | Terminal outcomes for sampled Redis shadow jobs | +| `dialcache.compression.count` | Count | `cache_namespace`, `use_case`, `key_type`, `layer`, `outcome` | Bounded Redis payload compression and decompression outcomes | | `dialcache.get.duration` | Distribution or histogram | `cache_namespace`, `use_case`, `key_type`, `layer` | Cache get latency in seconds | | `dialcache.fallback.duration` | Distribution or histogram | `cache_namespace`, `use_case`, `key_type`, `layer` | Elapsed time until the wrapped fallback settles or timeout rejection is delivered | | `dialcache.serialization.duration` | Distribution or histogram | `cache_namespace`, `use_case`, `key_type`, `layer`, `operation` | Redis serializer dump/load latency in seconds | -| `dialcache.serialization.size` | Distribution or histogram | `cache_namespace`, `use_case`, `key_type`, `layer` | Serialized Redis payload size in bytes | +| `dialcache.serialization.size` | Distribution or histogram | `cache_namespace`, `use_case`, `key_type`, `layer` | Serialized Redis payload size in bytes, before compression | +| `dialcache.stored.size` | Distribution or histogram | `cache_namespace`, `use_case`, `key_type`, `layer` | Prepared Redis payload size in bytes, after compression and escaping | +| `dialcache.compression.ratio` | Distribution or histogram | `cache_namespace`, `use_case`, `key_type`, `layer` | Compressed-to-original payload size ratio for compressed writes | +| `dialcache.compression.duration` | Distribution or histogram | `cache_namespace`, `use_case`, `key_type`, `layer`, `operation` | Compression and decompression latency in seconds | Client throws and rejected returned thenables are isolated by DialCache's fire-and-forget observer boundary. Buffered transport failures that happen @@ -279,9 +343,10 @@ thrown value's class or `Error.name`: | `config_resolution` | Runtime or layer configuration validation or resolution failed | | `cache_read` | A process-local read or non-timeout remote read failed | | `cache_read_timeout` | A remote read exceeded its effective DialCache deadline | -| `cache_write` | A process-local or remote cache write failed | +| `cache_write` | A process-local or remote cache write failed; native tracked writes include the observable lost-placeholder race described below | | `serialization_load` | Deserializing a Redis payload failed | | `serialization_dump` | Serializing a value for Redis failed | +| `compression` | zstd compression failed while preparing a Redis write | | `invalidation` | Writing an invalidation watermark failed | | `fallback` | The source loader failed or exceeded its DialCache deadline | | `unknown` | Reserved for a future failure site that cannot be classified otherwise | @@ -301,6 +366,18 @@ the wait. Coalesced followers do not multiply the timeout error. Deadline details remain out of labels and are available on the logged `RedisReadTimeoutError`. +A tracked native write first stores an unreadable placeholder and then stamps +that exact placeholder through the small mutation script. If another write +overwrites it, it expires, or a watermark-fenced write removes it before the +stamp, the adapter raises the root-exported +`DialCacheRedisPlaceholderLostError`. DialCache records one +`error="cache_write"`, suppresses publication of that write, and logs a warning. + +Same-key write contention can therefore create a benign, self-healing floor of +these errors around hot-key expiry. Keep the metric bounded, use the error +class in structured logs or direct adapter calls to distinguish the case, and +rate-limit the warning sink when that contention is expected. + Raw thrown values, error names, messages, cache ids, arguments, and Redis keys are never included in labels. When DialCache logs a cache-plumbing failure, the raw details remain available through the configured logger; not every metric @@ -326,18 +403,29 @@ Implement `DialCacheMetricsAdapter` and pass it through | `invalidation(labels)` | yes | One explicit remote invalidation call. | | `coalesced(labels)` | no | One follower that joined request-local or process-scoped work. | | `shadowValidation(labels)` | no | One terminal sampled-shadow outcome. This hook must be implemented for shadow jobs to execute. | +| `compression(labels)` | no | One bounded compression or decompression outcome. | | `observeGet(labels, seconds)` | yes | Cache-read duration in seconds. | | `observeFallback(labels, seconds)` | yes | Fallback duration in seconds. | | `observeSerialization(labels, seconds)` | yes | Serializer dump/load duration in seconds. | -| `observeSize(labels, bytes)` | yes | Serialized remote payload size in bytes. | +| `observeSize(labels, bytes)` | yes | Serialized remote payload size in bytes, before compression. | +| `observeStoredSize(labels, bytes)` | no | Prepared remote payload size in bytes, after compression and escaping; emitted before client dispatch. | +| `observeCompressionRatio(labels, ratio)` | no | Compressed-to-original size ratio when compression selects the prepared representation. | +| `observeCompression(labels, seconds)` | no | Compression or decompression duration with `operation="compress"` or `operation="decompress"`. | The root package exports `DialCacheMetricsAdapter` and every associated label, reason, error-kind, layer, scope, and shadow-outcome type, including -`ShadowValidationMetricLabels` and `ShadowValidationOutcome`. +`ShadowValidationMetricLabels`, `ShadowValidationOutcome`, +`CompressionMetricLabels`, `CompressionOperationMetricLabels`, and +`CompressionOutcome`. `shadowValidation` remains optional so existing custom adapters keep compiling, but DialCache does not admit shadow work when the configured adapter omits it. The Prometheus and Datadog adapters implement the hook. +The compression hooks are also optional for source compatibility with existing +custom adapters. They control observation only: omitting them does not disable +compression or decompression. The Prometheus and Datadog adapters implement +all four hooks. + Metrics and logger methods are typed `void` and invoked as fire-and-forget observers. DialCache also defensively consumes, but never awaits, a thenable returned at runtime. diff --git a/docs/redis.md b/docs/redis.md index e74e92a..4b79810 100644 --- a/docs/redis.md +++ b/docs/redis.md @@ -7,9 +7,10 @@ Redis Cluster. The application creates, connects, configures, drains, and closes the underlying client. DialCache borrows a client-independent `DialCacheRedisClient` adapter and does not own the connection lifecycle. -Sampled non-serving Redis reads and fills use the same adapter and tracked -protocol. See [Redis shadow validation](shadow-validation.md) for eligibility, -comparison, capacity, metrics, and rollout behavior. +Sampled non-serving Redis reads and fills use the same adapter and preserve the +operation's tracked or untracked mode. See +[Redis shadow validation](shadow-validation.md) for eligibility, comparison, +capacity, metrics, and rollout behavior. ## Install a client @@ -20,13 +21,13 @@ Choose one supported integration: npm install redis@~4.7.1 # or Valkey GLIDE -npm install @valkey/valkey-glide +npm install @valkey/valkey-glide@^2.0.0 ``` ## node-redis -Register DialCache's native scripts when creating the client, connect it, and -pass the DialCache-compatible adapter to `DialCache`: +Register DialCache's two mutation scripts when creating the client, connect +it, and pass the DialCache-compatible adapter to `DialCache`: ```ts import { createClient } from "redis"; @@ -37,7 +38,7 @@ import { } from "dialcache/node-redis"; const redisClient = createClient({ - url: process.env.REDIS_URL, + url: process.env.REDIS_URL ?? "redis://127.0.0.1:6379", scripts: dialcacheRedisScripts, disableOfflineQueue: true, commandsQueueMaxLength: 1_000, @@ -66,16 +67,23 @@ users should register the supplied scripts and wrap the connected client with instance-wide value or use `DialCacheKeyConfig.remoteReadTimeoutMs` for per-use-case static and runtime policy. +Use node-redis's promise-mode client; `legacyMode` is not supported. Treat +`dialcacheRedisScripts` as adapter wiring rather than a direct write API. Its +`dialcacheWriteTrackedStamp` method returns the raw `0 | 1 | 2` script reply; +direct callers must pass that reply through `resolveTrackedRedisWriteReply` +from `dialcache/redis-protocol` so reply `2` becomes a lost-placeholder error. + Local-only caching does not require a Redis client, but the explicit remote maintenance operation `invalidateRemote()` does. It rejects when Redis is not configured so a caller cannot mistake an absent watermark write for successful invalidation. See [Targeted invalidation](invalidation.md) for the complete contract. -The adapter computes each script's SHA, uses `EVALSHA`, and retries with `EVAL` -after `NOSCRIPT`. Its cluster client routes scripts by their first key and -performs that fallback on the selected shard. Tracked reads are deliberately -routed to primaries so a lagging replica cannot hide an invalidation watermark. +The registered scripts stamp tracked writes and advance invalidation +watermarks. Reads and untracked writes use native Redis commands. Node-redis +performs its normal script-cache recovery for the stamp script; the adapter's +additional invalidation recovery is described under +[Mutation retries and ambiguity](#mutation-retries-and-ambiguity). Deployments using tracked invalidation must also satisfy the [watermark durability](invalidation.md#watermark-durability) contract. @@ -110,19 +118,150 @@ const dialcache = new DialCache({ function shutdown(): void { // Drain cached calls and invalidations before releasing resources. - redisClient.dispose(); glideClient.close(); } ``` -DialCache uses the supplied namespace's `Script` constructor and -`Decoder.Bytes` value without importing a GLIDE runtime itself. Passing the same -module namespace that created the client prevents linked workspaces or -applications with another installed GLIDE version from mixing native script -handles. +DialCache uses the supplied namespace's `Batch`, `ClusterBatch`, client +constructors, and `Decoder.Bytes` value without importing a GLIDE runtime +itself. Passing the same module namespace that created the client prevents +linked workspaces or applications with another installed GLIDE version from +mixing native objects. + +Pass a direct `GlideClient` or `GlideClusterClient` instance. Wrappers should +implement `DialCacheRedisClient` directly. The returned adapter is stateless, +owns no script handles, and needs no disposal; the application closes the +underlying GLIDE client after its work drains. + +## Bundled Redis operations + +The node-redis and GLIDE adapters preserve the same semantic protocol while +using each client's native command and routing APIs. + +### Reads + +- An untracked read is one native `GET`. +- A tracked read is one atomic `MGET valueKey watermarkKey`. Cluster adapters + explicitly route it to the slot primary, even when replica reads are enabled, + so replica lag cannot hide an invalidation watermark. +- Missing, short, unsupported-version, or placeholder frames are clean misses. + A tracked read also misses when the watermark is missing or malformed, or + when `createdAt <= watermark`. + +The value and watermark must share a Redis Cluster slot; DialCache's generated +tracked keys do. Redis returns the complete value before the adapter compares +its timestamp with the watermark. A large invalidated value can therefore use +network bandwidth on every attempted read until a successful fallback write +removes it or its TTL expires. See +[Invalidated payload transfer and cleanup](invalidation.md#invalidated-payload-transfer-and-cleanup). + +GLIDE standalone sends tracked `MGET` through a one-command non-atomic batch +so the client routes it to the primary instead of applying its ordinary +one-key read preference. `MGET` itself remains the single atomic snapshot; the +batch is deliberately non-transactional and does not consume caller-owned +`WATCH` state. + +Node-redis standalone sends `MGET` to the endpoint the application configured; +the adapter cannot discover or reroute a standalone replica connection. Point +that client at the authoritative primary when relying on tracked invalidation. + +DialCache keys must remain application-owned strings. Native Redis type rules +are intentionally visible: `GET` rejects a wrong-type untracked value, while +`MGET` returns a missing member for a wrong-type tracked value or watermark. +A wrong-type tracked value can be replaced by the fallback write when its +watermark is valid. + +A wrong-type or malformed watermark makes reads miss, then causes the stamp to +fail after the placeholder `SET`; repeated calls therefore fail open and reload +until that watermark state is repaired. A valid-version frame with an +unsupported payload encoding is a typed payload error rather than a miss. + +### Writes + +An untracked write is one native command: + +```text +SET valueKey frame PX cacheTtlMs +``` -The GLIDE adapter uses GLIDE's native script lifecycle and byte decoder. GLIDE -routes scripts from their declared keys. +Its frame carries an informational client-clock timestamp. Untracked reads do +not consult that timestamp. + +A tracked write uses two commands, ordered on one connection without +`MULTI`/`EXEC`: + +1. `SET` writes the complete serialized payload in an unreadable version-0 + placeholder with a fresh nonce and the value TTL. +2. `WRITE_TRACKED_STAMP_SCRIPT` verifies that exact nonce, reads Redis time and + the watermark, and either promotes the placeholder to a readable frame, + unlinks it when the watermark fence is active, or reports that the + placeholder was lost. + +The nonce prevents a delayed stamp from publishing another writer's value. +The placeholder is a deliberate fail-safe: an interleaved or failed stamp is a +miss, not an unstamped cache hit. Because its `SET` replaces the prior frame, a +tracked write can briefly make a previously readable key miss while the stamp +settles. + +The pair is non-transactional so it does not consume caller-owned Redis +`WATCH` state. The bundled adapters enqueue or batch the pair in order. A +watermark fence returns `false`; DialCache returns the fallback value and does +not publish it process-locally. + +A missing, overwritten, or expired placeholder throws +`DialCacheRedisPlaceholderLostError`. Ordinary cached calls absorb that error +through the fail-open cache-write path, so same-key write contention can +produce benign bounded `cache_write` errors on hot keys. + +The adapter reports a failed `SET` as the write outcome even if the stamp also +settled. Because transport failures can be ambiguous, the `SET` may have +landed and the stamp may have promoted it despite the reported error. Never use +a cache-write rejection as proof that Redis was not mutated. + +### Mutation retries and ambiguity + +The bundled adapters dispatch invalidation with `EVALSHA`. If dispatch rejects, +they retry once with the monotonic, replay-safe script source through `EVAL`; +this also repairs a flushed script cache. A reply-domain violation is a +protocol error, not a retryable dispatch failure. If recovery fails, the retry +error surfaces. + +GLIDE attaches the original rejection as its `cause` when safe; node-redis does +not mutate the shared error objects it can use for disconnect failures. + +For the tracked stamp, node-redis uses its registered script's normal +`NOSCRIPT` recovery. GLIDE retries the stamp with `EVAL` only on `NOSCRIPT`, +because any other error may be an ambiguous result from a stamp that already +executed. The first tracked GLIDE write after a script-cache flush can +therefore pay one extra round trip. + +The retry is below the `DialCacheRedisClient` boundary. A successful recovery +is therefore not a DialCache error or retry metric, although Redis command +statistics can reveal the additional `EVAL`. + +Like any network mutation, a rejected write or invalidation can have executed +before the client reports failure. Do not add an outer `Promise.race` and +assume rejection proves non-execution; use finite client-native queue, +reconnect, dispatch, and response budgets. + +### Redis compatibility and ACLs + +The tracked stamp uses `UNLINK`, so the bundled protocol requires Redis 4 or a +compatible Valkey release. Redis Cluster deployments must allow multi-key +operations for keys in the same slot. + +At minimum, allow the client commands `GET`, `MGET`, `SET`, `EVALSHA`, and +`EVAL`. The scripts also invoke `TIME`, `GET`, `SET`, and `PTTL`; the tracked +stamp additionally invokes `PEXPIRE`, `UNLINK`, `GETRANGE`, and `SETRANGE`. +The bundled adapters do not require `SCRIPT LOAD`. + +Verify ACLs and proxy behavior before upgrading. A persistent stamp failure +still lets each placeholder `SET` replace the last readable value, while the +failed write suppresses process-local publication. Within one value-TTL +horizon, affected tracked keys can send all traffic to the source. Each lost +placeholder on a DialCache request path also records a bounded `cache_write` +error and emits a warning; size alerts and logger rate limits for expected +same-key contention. ## Lifecycle ownership @@ -137,8 +276,7 @@ The application owns the complete Redis lifecycle: Redis. 6. Drain or terminate client-native Redis work that may have outlived DialCache's caller-serving or shadow-read wait. -7. Dispose adapter-owned resources. -8. Close the underlying connection. +7. Close the underlying connection. DialCache has no `close()` or drain method. It never disposes or closes caller resources. @@ -152,13 +290,10 @@ shadow job can remain active during teardown. Stop new work before closing dependencies and use their native drain or termination controls. An already-dispatched shadow fill may have executed even if its outcome is lost. -The node-redis adapter owns no additional resources, so close the underlying -client after draining work. - -The GLIDE adapter owns five native `Script` handles but not the wrapped -connection. Call its idempotent `dispose()` after operations finish and before -closing GLIDE. Disposing while an adapter operation is in flight throws rather -than releasing a live script. A DialCache read timeout does not prove that the +Both bundled adapters are resource-free views over caller-owned clients. They +have no `dispose()` method and own no connection, batch, or script handle. +After DialCache and detached client work drain, close the underlying node-redis +or GLIDE client directly. A DialCache read timeout does not prove that the client-side invocation has settled. ## Remote-read deadlines and async liveness @@ -180,7 +315,7 @@ Each explicit value must be a positive safe integer no greater than Outside an enabled scope and on an earlier in-memory hit, DialCache creates no remote-read timer. A key ramped out of Redis serving creates no caller-serving timer, but an independently selected shadow job can create unreferenced timers -for its tracked `C0` and optional `C1` reads. +for its same-mode `C0` and optional `C1` reads. ### Caller-serving timeout and fail-open behavior @@ -205,8 +340,9 @@ publication because watermark safety was not established. Request-local memoization remains unconditional. Same-key callers in one request-local or process coalescing scope share the -leader's read, timer, and remaining budget. A later independent invocation may -start a new remote read even if the prior client operation is still settling. +leader's read, timer, and remaining budget. With `coalesce: false`, each caller +gets a full independent read budget and can start another remote read while a +prior client operation is still settling. The `fallbackTimeoutMs` timer is separate and starts only if and when the source loader begins. The remote-read timer covers neither config resolution, @@ -231,14 +367,17 @@ Custom adapters implement the complete client-independent read, write, and invalidate contract: ```ts -type Awaitable = T | Promise; +import type { + RedisCachePayload, + RedisInvalidationRequest, + RedisReadContext, + RedisReadRequest, + RedisWriteRequest, +} from "dialcache"; -interface RedisReadContext { - readonly timeoutMs: number; - readonly signal: AbortSignal; -} +type Awaitable = T | Promise; -interface DialCacheRedisClient { +interface DialCacheRedisClientContract { read( request: RedisReadRequest, context?: RedisReadContext, @@ -250,24 +389,32 @@ interface DialCacheRedisClient { #### `read` -- Return an operation-owned serialized `string` or `Buffer`, or `null` for a - miss. +- Decode native bulk-string replies with `decodeRedisFrame` or + `decodeTrackedRedisFrame` from `dialcache/redis-protocol`, or preserve their + exact behavior, and return the operation-owned serialized `string` or + `Buffer`, or `null` for a miss. - Keep the payload stable after settlement because DialCache can retain it for shadow work. An adapter that recycles response storage must return a dedicated `Buffer`. -- For a tracked request, compare the value timestamp and watermark atomically. - A missing watermark or a value at or behind it is a miss. +- For a tracked request, obtain the value and watermark atomically from one + authoritative primary snapshot. A missing or malformed watermark, or a + value at or behind it, is a miss. #### `write` -- Accept `cacheTtlMs` as a positive integer no greater than - `31_536_000_000` milliseconds (365 days), apply that TTL, and record server - time atomically. -- For a tracked request, create a missing baseline and retain it for at least - the value TTL plus one minute without shortening a longer or persistent - lifetime. -- Return `true` only when the value was written and `false` when publication is - rejected. +- Normalize `cacheTtlMs` with `ceilSupportedCacheTtlMs`; positive fractional + milliseconds round up, and the result may not exceed `31_536_000_000` + milliseconds (365 days). +- Use `encodeRedisFrame` for the one-command untracked path. For a tracked + request, preserve the exact placeholder-and-stamp behavior described above + with `encodeTrackedRedisPlaceholder`, `WRITE_TRACKED_STAMP_SCRIPT`, and + `resolveTrackedRedisWriteReply`. +- On a non-fenced tracked write, create a missing baseline watermark and retain + it for at least the value TTL plus one minute without shortening a longer or + persistent lifetime. +- Return `true` only when the value was published and `false` only when the + watermark fence rejected it. Surface a lost placeholder as + `DialCacheRedisPlaceholderLostError`, not as `false`. #### `invalidate` @@ -293,8 +440,8 @@ The bundled node-redis adapter forwards the signal in per-command options. This can remove queued work where supported, but aborting after dispatch cannot unsend a command or prove that Redis stopped executing it. -The GLIDE script API has no per-invocation signal, so a timed-out script -invocation may continue inside the adapter. Its configured +The GLIDE command API has no per-invocation signal, so a timed-out command may +continue inside the adapter. Its configured [`requestTimeout`](https://glide.valkey.io/languages/nodejs/api/interfaces/BaseClient.BaseClientConfiguration.html) and [`advancedConfiguration.connectionTimeout`](https://glide.valkey.io/languages/nodejs/api/interfaces/BaseClient.AdvancedBaseClientConfiguration.html) @@ -440,7 +587,96 @@ serializers must treat payloads and values as borrowed and immutable, return independent values from repeated loads, and copy a Buffer before mutating it. See [Data ownership and custom integrations](shadow-validation.md#data-ownership-and-custom-integrations). -### Advanced wire protocol +## Compression + +Redis payload compression is enabled by default and configured once per +`DialCache` instance: + +```ts +const dialcache = new DialCache({ + redis: { + client: dialCacheRedisClient, + compression: { + thresholdBytes: 4_096, + level: 3, + }, + }, +}); +``` + +`thresholdBytes` must be a positive safe integer and defaults to 4,096 bytes. +`level` must be an integer from 1 through 22 and defaults to 3. Pass +`compression: false` to disable compression on future writes. This is an +instance-level write policy, not a per-use-case runtime ramp. + +DialCache measures the serializer output in bytes, then compresses it with +zstd only when it meets the threshold and the marked compressed form is +smaller than the raw stored form. Compression and decompression are +synchronous and run on the Node.js event loop. Benchmark representative value +sizes and zstd levels under production-like concurrency before lowering the +threshold or raising the level. + +Default-on compression requires zstd-capable `node:zlib`; DialCache validates +that support during construction. The package's supported Node.js range starts +at 22.15.0 in the 22.x line and excludes 23.0 through 23.7. The exact published +engine range is `>=22.15.0 <23.0.0 || >=23.8.0`. + +Reads always decode marked payloads, even when write-side compression is +disabled. That makes `compression: false` a safe way to stop producing new +compressed entries without orphaning existing ones. Binary serializer output +whose first byte is `0x00`, `0x01`, or `0x02` is also escaped on every write, +including when compression is disabled, so current readers can distinguish it +from the compression envelope exactly. + +### Size limits and failure behavior + +DialCache caps decompressed output at 512 MiB, matching Redis's value limit. +Serializer output above that cap is left raw for the Redis write rather than +compressed. + +If a marked value cannot be decompressed or would exceed the cap, DialCache +passes the original marked bytes to `serializer.load`; a validating serializer +will normally reject it and trigger the existing self-healing miss path. A +permissive custom serializer can instead accept those bytes, so monitor +`fallback_raw` and `read_over_limit` as payload-integrity signals rather than +assuming they always become misses. + +A write-side zstd exception records `error="compression"`, skips the Redis +write, and follows DialCache's fail-open cache-write path. The fallback result +still returns. Decompression outcomes are reported before serializer loading; +if loading then fails, the same read can also record `serialization_load`. + +Compression-aware metrics adapters can implement the optional `compression`, +`observeStoredSize`, `observeCompressionRatio`, and `observeCompression` +hooks. `observeSize` remains the serializer-output size before compression or +escaping; `observeStoredSize` measures the prepared payload afterward, before +the shadow deadline gate and Redis write. It does not prove that a write was +dispatched or succeeded. See +[Observability](observability.md#compression-metrics) for bounded outcomes and +the bundled Prometheus and Datadog metric names. + +### Rolling deployments and binary serializers + +Current readers accept frames from older releases. Older readers, however, do +not understand newly compressed or escaped payloads and will usually reject +them during deserialization, causing temporary fallback and refill churn in a +mixed deployment. For string and JSON serializers, a low-noise rollout is: + +1. deploy the new release everywhere with `compression: false`; +2. allow old readers to drain; and +3. enable compression in a later rollout. + +Apply the same consideration when rolling back while compressed entries still +exist. + +Before the escape envelope existed, arbitrary binary output could already +begin with an envelope marker. A legacy payload beginning with `0x00` followed +by `0x00`–`0x02`, or with `0x01`/`0x02` followed by a valid zstd stream, can be +misinterpreted by a current reader until it expires. If a custom serializer +can emit those prefixes, change the operation's `useCase` or other key-version +component for the migration. + +## Advanced wire protocol The core Redis boundary is the client-independent `DialCacheRedisClient` interface. It exchanges serialized values as `string | Buffer` and does not @@ -449,43 +685,58 @@ expose client-specific commands or wire encodings. The `dialcache/redis-protocol` entry point exports the exact bundled protocol building blocks: -- `READ_CACHE_SCRIPT` and `READ_TRACKED_CACHE_SCRIPT`; -- `WRITE_CACHE_SCRIPT` and `WRITE_TRACKED_CACHE_SCRIPT`; -- `INVALIDATE_CACHE_SCRIPT`; and -- `REDIS_FRAME_VERSION`, `REDIS_ENCODING_UTF8`, and - `REDIS_ENCODING_BINARY`. +- `decodeRedisFrame` and `decodeTrackedRedisFrame` for native read replies; +- `encodeRedisFrame`, `encodeTrackedRedisPlaceholder`, and the + `TrackedRedisPlaceholder` type for native writes; +- `ceilSupportedCacheTtlMs` for adapter-level write TTLs; +- `WRITE_TRACKED_STAMP_SCRIPT` and `INVALIDATE_CACHE_SCRIPT`; +- `resolveTrackedRedisWriteReply`; and +- `validateRedisSetReply` and `validateRedisScriptInvalidationReply`. -The scripts implement the atomic read, publication, invalidation, server-time, -and derived-watermark-lifetime behavior required above. Custom adapters can -throw these root-exported error classes: +The payload bytes inside the Redis frame are opaque to this adapter-level +protocol. Compression and escaping sit above it in DialCache core. Custom +adapters must preserve those bytes exactly and must not decompress or rewrite +them. + +Custom adapters can throw these root-exported error classes: - `DialCacheRedisPayloadError`; -- `DialCacheRedisPayloadEncodingError`; and -- `DialCacheRedisProtocolError`. +- `DialCacheRedisPayloadEncodingError`; +- `DialCacheRedisProtocolError`; and +- `DialCacheRedisPlaceholderLostError`. -They distinguish malformed payloads, unsupported encodings, and invalid Lua -reply domains in logs. DialCache records bounded `cache_read`, -`cache_read_timeout`, `cache_write`, or `invalidation` metrics by failure site. +They distinguish invalid runtime payload or reply shapes, unsupported +encodings, and lost tracked placeholders. DialCache records bounded +`cache_read`, `cache_read_timeout`, `cache_write`, or `invalidation` metrics by +failure site. -Shadow validation adds no Redis protocol operation. It composes the same -tracked read and write requests: `C0` and `C1` are tracked reads, and a clean -miss can use one tracked write. +Shadow validation adds no Redis protocol operation. It composes the ordinary +request shapes for the operation: `C0`, `C1`, and any clean-miss fill remain +tracked or untracked together. -#### Binary frame +### Binary frame Redis values use a compact binary frame: ```text byte 1 format version -bytes 2-9 Redis-created timestamp in milliseconds (uint64, big-endian) +bytes 2-9 creation timestamp or placeholder nonce (eight-byte region) byte 10 payload encoding (0 = UTF-8, 1 = raw binary) -bytes 11... serialized payload +bytes 11... opaque post-serialization payload ``` -Redis's Lua `struct` library packs and unpacks the timestamp. Redis TTL is -authoritative, so expiry metadata is not duplicated in the frame. - -The payload comes from the cache operation's serializer or `JsonSerializer` by -default. Custom serializers can return `string` or `Buffer`. Strings are -stored as UTF-8; Buffers are stored byte-for-byte without base64 expansion. -Adapters restore the same representation before calling `serializer.load`. +Version 1 is readable. A tracked placeholder uses version 0 and stores its +eight-byte nonce in the stamp region, so neither read path can serve it. The +stamp script promotes only its matching placeholder by replacing version and +nonce with version 1 and Redis time. + +An untracked frame is version 1 from the start and carries an informational +client-clock timestamp that untracked reads ignore. Redis TTL is authoritative, +so expiry metadata is not duplicated in the frame. + +The payload region contains the serializer output after any compression or raw +binary escaping. The frame encoding preserves whether that region is a string +or `Buffer`; strings use UTF-8 and Buffers need no base64 expansion. After the +adapter decodes the frame, DialCache interprets the optional compression +envelope and restores the serializer's representation before calling +`serializer.load`. diff --git a/docs/shadow-validation.md b/docs/shadow-validation.md index 27c13ae..302ee01 100644 --- a/docs/shadow-validation.md +++ b/docs/shadow-validation.md @@ -2,22 +2,23 @@ [Back to the README](../README.md) -Shadow validation lets a service exercise and inspect tracked Redis behavior -without letting the shadow path choose the caller's result. It is useful for -validating warm entries against the source of truth and for bootstrapping clean -misses before increasing the Redis serving ramp. +Shadow validation lets a service exercise and inspect Redis behavior without +letting the shadow path choose the caller's result. It is useful for validating +warm entries against the source of truth and for bootstrapping clean misses +before increasing the Redis serving ramp. Shadow mode is an operational rollout tool, not a new serving layer or a correctness boundary. It adds source and Redis work, provides best-effort -evidence through bounded metrics, and relies on the same invalidation, -serialization, deadline, and client-lifecycle contracts as the remote layer. +evidence through bounded metrics, and preserves each key's ordinary tracked or +untracked Redis mode. It relies on the same serialization, deadline, consistency, +and client-lifecycle contracts as the remote layer. ## At a glance | Path reached by the caller | Caller receives | Selected shadow work | | --- | --- | --- | -| Tracked, serving Redis hit | The decoded Redis value | Read the source later, compare it with the retained Redis payload, and confirm a candidate mismatch with one more tracked Redis read. | -| Valid remote policy, but the key is ramped out of Redis serving | The normal source result | Read Redis later without serving it. Compare a hit, or fill a clean miss from the caller-accepted source result. | +| Serving Redis hit, tracked or untracked | The decoded Redis value | Compare a later source read with the retained payload, then confirm a mismatch candidate with one same-mode Redis read. | +| Valid remote policy, but ramped out of Redis serving | The normal source result | Read Redis later without serving it. Compare a hit, or fill a clean miss from the accepted source result. | | Serving Redis miss | The normal source result | None. The ordinary request path already performs fallback and fill. | | Request-local or process-local hit | The in-memory value | None. Normal traversal never reached Redis. | @@ -27,8 +28,9 @@ served, selected for both, or selected for neither. ## Configure a shadow cohort -Shadow validation requires a tracked operation, a valid remote TTL, a metrics -adapter with the optional shadow hook, and a positive `shadow.ramp`: +Shadow validation requires a valid remote TTL, a metrics adapter with the +optional shadow hook, and a positive `shadow.ramp`. Invalidation tracking is +optional and selects the Redis consistency mode rather than shadow eligibility: ```ts import { CacheLayer, DialCache, DialCacheKeyConfig } from "dialcache"; @@ -47,6 +49,7 @@ const getUser = dialcache.cached( keyType: "user_id", useCase: "GetUser", cacheKey: (userId) => userId, + // Optional for shadowing; adds watermark fencing to Redis reads and fills. trackForInvalidation: true, shadowComparator: (cached, source) => cached.id === source.id && cached.version === source.version, @@ -91,7 +94,6 @@ DialCache schedules shadow work only when all of these conditions hold: - the call began inside an enabled DialCache scope; - a Redis or Valkey adapter is configured; - normal traversal reaches the remote layer; -- the operation sets `trackForInvalidation: true`; - the resolved remote policy has a valid TTL; - the effective `shadow.ramp` is positive and selects the exact key; - the configured metrics adapter implements `shadowValidation`; and @@ -100,10 +102,9 @@ DialCache schedules shadow work only when all of these conditions hold: A remote serving ramp of `0` is eligible because it preserves a valid remote policy while excluding the key from serving. -Missing or invalid remote policy, provider failure, an untracked key, an -omitted metrics hook, an invalid or zero shadow ramp, cohort exclusion, an -earlier in-memory hit, or a disabled call does not start a shadow-only Redis -path. +Missing or invalid remote policy, provider failure, an omitted metrics hook, an +invalid or zero shadow ramp, cohort exclusion, an earlier in-memory hit, or a +disabled call does not start a shadow-only Redis path. An invalid runtime `shadow.ramp` does not disturb an otherwise valid caller-serving Redis hit. DialCache skips shadow work and records a @@ -121,6 +122,13 @@ DialCache validates this diagnostic leaf only after the metrics-hook, exact-key-cohort, and capacity gates; ineligible, cohort-excluded, and explicitly dropped work does not report that configuration error. +### Upgrade note for untracked keys + +Starting in `v0.15.0`, otherwise eligible untracked keys participate in shadow +work. A use case that already had a positive effective `shadow.ramp` can +therefore add source reads, Redis reads and fills, metrics, and opted-in logs. +Set its shadow ramp to `0` before upgrading if that work is not wanted. + See [Configuration and cache layers](configuration.md) for runtime-overlay precedence and policy validation. @@ -128,9 +136,9 @@ precedence and policy validation. ### Serving Redis hit -The request path performs its normal tracked Redis read and deserialization. -It returns that cached value without waiting for shadow work and retains the -exact serialized payload as `C0`. +The request path performs its normal Redis read and deserialization in the +key's tracked or untracked mode. It returns that cached value without waiting +for shadow work and retains the exact serialized payload as `C0`. On a later unreferenced event-loop turn, the shadow job: @@ -139,9 +147,11 @@ On a later unreferenced event-loop turn, the shadow job: 3. compares that snapshot with the source value `S`; and 4. performs a confirmation read only when the values differ. -The additional source call must be safe to run for observation. Process -coalescing means one serving Redis leader schedules at most one job for its -coalesced followers. +The additional source call must be safe to run for observation. With default +coalescing, one serving Redis leader schedules at most one job for its +followers. With `coalesce: false`, each caller can attempt scheduling; exact-key +shadow deduplication admits at most one concurrent job and reports the others +as `dropped`. ### Ramped down from Redis serving @@ -150,9 +160,9 @@ ramp is down, the caller runs and awaits the normal source loader. Shadow work reuses that same caller-owned promise as `S`; it does not invoke the loader a second time. -The detached job reads tracked Redis as `C0`. A hit is compared with `S`. A -clean miss can be filled from `S` using the invocation's resolved remote TTL -snapshot. +The detached job reads Redis as `C0` in the key's existing mode. A hit is +compared with `S`. A clean miss can be filled from `S` in that same mode using +the invocation's resolved remote TTL snapshot. The shadow Redis value never supplies the caller or populates request-local or process-local memory. If those in-memory layers are active, only the caller's @@ -162,20 +172,22 @@ When request-local and process-local caching are off and Redis serving is ramped down, caller invocations remain uncached and do not gain process coalescing merely because shadowing is enabled. Concurrent same-key callers can each run the source; shadow deduplication independently admits one shadow -job and reports the others as `dropped`. +job and reports the others as `dropped`. If an in-memory serving layer is +active, `coalesce: false` likewise keeps each caller's cache path independent. ## The `C0` / `S` / `C1` algorithm -`C0` is the original tracked Redis payload: either the payload that served the -caller or the result of the detached ramped-down read. `S` is the successfully -accepted source value. `C1` is an optional tracked confirmation read. +`C0` is the original Redis payload: either the payload that served the caller or +the result of the detached ramped-down read. `S` is the successfully accepted +source value. `C1` is an optional confirmation read in the same tracked or +untracked mode as `C0`. 1. Obtain `C0`. 2. If `C0` is `null`, follow the clean-miss fill path described below. 3. Otherwise, obtain `S`, deserialize a new snapshot from `C0`, and compare the cached and source values. 4. When they match, emit `match`; no confirmation read is needed. -5. When they differ, read tracked Redis again as `C1`, bypassing the +5. When they differ, read Redis again as `C1` in the same mode, bypassing the request-local and process-local caches. 6. If `C1` is missing or its bytes differ from `C0`, emit `superseded`. 7. If `C1` is byte-identical to `C0`, emit `mismatch`. @@ -186,32 +198,39 @@ confirmation. DialCache does not deserialize `C1`, compare it with `S`, or chase another version. `mismatch` therefore means that the exact observed Redis payload survived one -tracked confirmation read after a semantic disagreement. It is not a -cross-system atomic snapshot or a guarantee that the mismatch still exists. -`superseded` means only that the original observation could not be confirmed. +confirmation read after a semantic disagreement. It is not a cross-system +atomic snapshot or a guarantee that the mismatch still exists. For an +untracked key, it is also not proof of primary freshness or invalidation +safety. `superseded` means only that the original observation could not be +confirmed. No non-null `C0` is repaired, overwritten, invalidated, or given a refreshed TTL. That rule also applies when detached deserialization fails. ### Clean-miss fill -A clean miss means the tracked semantic Redis read returned `null`. It does -not include a non-null payload that the serializer cannot load. +A clean miss means the semantic Redis read returned `null`. It does not include +a non-null payload that the serializer cannot load. On the ramped-down path, DialCache can serialize the caller-accepted `S` and -attempt one ordinary tracked Redis write with the resolved TTL: +attempt one ordinary Redis write in the key's existing mode with the resolved +TTL: - `filled` means the client returned `true` before the shadow deadline; -- `fill_blocked` means the invalidation watermark returned `false`; and -- `fill_error` means serialization or the Redis write failed. +- `fill_blocked` means a tracked invalidation watermark returned `false`; and +- `fill_error` means preparing the payload (serialization or compression) or + writing it to Redis failed. A source rejection or caller fallback timeout never produces an accepted `S` and never starts the fill. Once serialization has finished, DialCache checks the whole-job deadline again before dispatching the write. -The `C0` read and fill are not atomic. The fill is a normal tracked overwrite, -not a compare-and-set or write-if-still-missing operation. Another writer can +The `C0` read and fill are not atomic. The fill is a normal overwrite, not a +compare-and-set or write-if-still-missing operation. Another writer can populate Redis after `C0` misses and then be overwritten by the shadow fill. +Tracked writes retain their watermark fence. An untracked fill has no fence and +uses ordinary TTL-based last-writer-wins publication, so an older accepted +source value can overwrite a concurrent newer value and remain until expiry. ## Comparison semantics @@ -374,23 +393,33 @@ off. See [Redis and Valkey](redis.md) for the complete custom-client, payload, deadline, and connection-lifecycle contracts. -## Invalidation and race boundaries - -Shadow mode is limited to invalidation-tracked keys. Both `C0` and `C1` use the -tracked read protocol, which atomically checks the value timestamp against the -watermark. Bundled adapters route tracked reads to the primary. - -A clean-miss fill uses the same serializer, Redis-time timestamp, value TTL, -and watermark-aware tracked write as an ordinary fill. A future watermark can -reject it as `fill_blocked`. Size `futureBufferMs` to cover the complete source, -serialization, client queue, network, script, and write interval if stale -publication protection matters. - -The watermark fences the tracked Redis write; it does not make the earlier -`C0` read and later fill atomic. It also does not synchronously invalidate -request-local or process-local entries. Shadow mode never evicts those layers. - -See [Targeted invalidation](invalidation.md) for the clock, durability, +## Consistency modes and race boundaries + +Shadowing preserves the operation's existing Redis mode: + +- **Tracked keys:** `C0` and `C1` use the watermark-aware read protocol, which + atomically checks the value timestamp against the invalidation watermark. + Bundled cluster adapters explicitly route these reads to the primary; a + standalone node-redis client must already target the authoritative endpoint. + A clean-miss fill uses the ordinary tracked write and can be rejected as + `fill_blocked` by a future watermark. +- **Untracked keys:** `C0` and `C1` use the ordinary one-key read route without a + watermark or shadow-specific primary guarantee. A clean-miss fill uses the + ordinary TTL write. Compliant untracked writes do not produce + `fill_blocked`. + +Both modes use the operation's serializer and value TTL. Tracked publication +receives a Redis-time timestamp from the stamp script; an untracked write uses +an informational client-clock timestamp that untracked reads never consult. + +Neither mode makes the initial `C0` read and later fill atomic. For tracked +keys, size `futureBufferMs` to cover the complete source, serialization, client +queue, network, and write interval when stale-publication protection matters. +The watermark fences only the tracked Redis write; it does not synchronously +invalidate request-local or process-local entries. Shadow mode never evicts +those layers. + +See [Targeted invalidation](invalidation.md) for the tracked clock, durability, retention, and future-buffer contracts. ### Command amplification @@ -398,10 +427,10 @@ retention, and future-buffer contracts. | Selected path | Added source work | Added Redis work | | --- | --- | --- | | Serving Redis hit, semantic match | One observational source read | None beyond the serving read | -| Serving Redis hit, mismatch candidate | One observational source read | One tracked confirmation read | -| Ramped-down Redis hit, semantic match | None beyond the caller's source read | One tracked `C0` read | -| Ramped-down Redis hit, mismatch candidate | None beyond the caller's source read | Tracked `C0` and `C1` reads | -| Ramped-down clean Redis miss | None beyond the caller's source read | One tracked `C0` read and at most one tracked write | +| Serving Redis hit, mismatch candidate | One observational source read | One same-mode confirmation read | +| Ramped-down Redis hit, semantic match | None beyond the caller's source read | One same-mode `C0` read | +| Ramped-down Redis hit, mismatch candidate | None beyond the caller's source read | Same-mode `C0` and `C1` reads | +| Ramped-down clean Redis miss | None beyond the caller's source read | One same-mode `C0` read and at most one write | | Serving Redis miss | None beyond the ordinary path | None beyond the ordinary read and fill | Capacity limits bound concurrent jobs, not total work over time. Measure source @@ -418,8 +447,8 @@ capacity cap, reports one bounded terminal outcome: | `mismatch` | They differed and byte-identical `C1` confirmed the original `C0`. | | `superseded` | They differed, but `C1` was missing or had different bytes. | | `filled` | A clean miss was populated successfully before the deadline. | -| `fill_blocked` | The invalidation watermark rejected the clean-miss fill. | -| `fill_error` | Serialization or the clean-miss Redis write failed. | +| `fill_blocked` | A tracked invalidation watermark rejected the clean-miss fill. | +| `fill_error` | Preparing the payload (serialization or compression) or writing the clean-miss fill failed. | | `redis_error` | The initial detached `C0` read failed or reached its read deadline. | | `source_error` | The source loader rejected without being the caller's own DialCache fallback timeout. | | `deserialization_error` | The retained non-null `C0` could not be deserialized. | @@ -459,7 +488,9 @@ Before increasing `shadow.ramp`: - confirm the source loader is safe to invoke observationally on serving hits; - use a valid remote TTL with the serving ramp at `0`; -- enable tracked invalidation and choose a defensible `futureBufferMs`; +- choose the Redis consistency mode deliberately: for tracked keys, configure a + defensible `futureBufferMs`; for untracked keys, accept TTL-based + last-writer-wins fills without invalidation or a primary-read guarantee; - verify serializer, comparator, Redis client, source, and telemetry budgets; - start with a small per-instance capacity and measure `dropped`; - account for the command amplification above; and @@ -493,8 +524,7 @@ During shutdown: 3. await request-path cache calls and invalidations; 4. use source, Redis-client, serializer, and telemetry-native controls to drain or terminate their work; -5. dispose adapter-owned resources; and -6. close underlying connections. +5. close underlying connections after their remaining work drains. Unreferenced shadow scheduling means the process may exit before an outcome is delivered. Already-started source reads, serializers, Redis commands, or From d8e268f02ecb17f6d35cc2329ba0f28171407217 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sat, 5 Sep 2026 21:59:45 -0700 Subject: [PATCH 08/31] docs: host searchable reference with VitePress and GitHub Pages --- .github/workflows/docs.yaml | 51 ++ .gitignore | 1 + README.md | 42 +- docs/.vitepress/config.mts | 62 ++ docs/maintainers.md | 50 +- package.json | 9 +- pnpm-lock.yaml | 1088 ++++++++++++++++++++++++++++++++++- tsconfig.json | 2 +- 8 files changed, 1273 insertions(+), 32 deletions(-) create mode 100644 .github/workflows/docs.yaml create mode 100644 docs/.vitepress/config.mts diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml new file mode 100644 index 0000000..370223c --- /dev/null +++ b/.github/workflows/docs.yaml @@ -0,0 +1,51 @@ +name: Documentation + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: docs-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + build: + name: Build documentation + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 + with: + version: 10.33.0 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + with: + node-version: "24" + cache: "pnpm" + - run: pnpm install --frozen-lockfile + - run: pnpm docs:build + - uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4 + if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' + with: + path: docs/.vitepress/dist + + deploy: + name: Deploy documentation + if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' + needs: build + runs-on: ubuntu-latest + permissions: + contents: read + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5 + - uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4 + id: deployment diff --git a/.gitignore b/.gitignore index a32c984..682a6bd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ node_modules/ dist/ coverage/ +docs/.vitepress/cache/ *.tgz .env .env.* diff --git a/README.md b/README.md index 7e06ed2..dc6516a 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,9 @@ Start with an in-memory cache. Add Redis or Valkey when you need a shared layer. Roll each use case out to a stable cohort of keys, observe the results, and adjust the policy while your service runs. Your loader stays the same. -[**Read the documentation →**](https://github.com/lan17/DialCache/blob/main/docs/index.md) -· [Getting started](https://github.com/lan17/DialCache/blob/main/docs/getting-started.md) -· [API reference](https://github.com/lan17/DialCache/blob/main/docs/api.md) +[**Read the documentation →**](https://lan17.github.io/DialCache/) +· [Getting started](https://lan17.github.io/DialCache/getting-started.html) +· [API reference](https://lan17.github.io/DialCache/api.html) ## Why DialCache? @@ -86,7 +86,7 @@ layer for every key inside the scope. The LRU holds at most 10,000 entries by default. In a service, place `enable()` around a read-request handler so nested readers inherit the same asynchronous scope. -Prefer an inline loader? [`getOrLoad()`](https://github.com/lan17/DialCache/blob/main/docs/api.md#getorload) +Prefer an inline loader? [`getOrLoad()`](https://lan17.github.io/DialCache/api.html#getorload) uses the same behavior with a direct key: ```ts @@ -100,7 +100,7 @@ const user = await dialcache.enable(() => ); ``` -[Continue the getting-started guide →](https://github.com/lan17/DialCache/blob/main/docs/getting-started.md) +[Continue the getting-started guide →](https://lan17.github.io/DialCache/getting-started.html) ## One reader, three cache layers @@ -122,7 +122,7 @@ from the lower chain can be memoized within the request. Tracked Redis reads have additional publication rules to keep a fallback from bypassing an invalidation fence. -[Understand the read path and freshness boundaries →](https://github.com/lan17/DialCache/blob/main/docs/concepts.md) +[Understand the read path and freshness boundaries →](https://lan17.github.io/DialCache/concepts.html) ## Turn the dial while your service runs @@ -166,8 +166,8 @@ sample reads and fills in shadow mode before allowing Redis to serve callers. Turning serving off does not stop shadow work; `disabled()` disables both for new invocations. -[Runtime configuration](https://github.com/lan17/DialCache/blob/main/docs/configuration.md) -· [Shadow validation](https://github.com/lan17/DialCache/blob/main/docs/shadow-validation.md) +[Runtime configuration](https://lan17.github.io/DialCache/configuration.html) +· [Shadow validation](https://lan17.github.io/DialCache/shadow-validation.html) ## Freshness is a policy you choose @@ -187,25 +187,27 @@ Good cache keys include every input that affects the result. Cached objects are shared references: treat them as immutable. Cache access fails open, while explicit invalidation failures reject so your application can handle them. -[Invalidation](https://github.com/lan17/DialCache/blob/main/docs/invalidation.md) -· [Stale-on-error](https://github.com/lan17/DialCache/blob/main/docs/stale-on-error.md) -· [Key design](https://github.com/lan17/DialCache/blob/main/docs/configuration.md#keys-ids-and-extra-dimensions) +[Invalidation](https://lan17.github.io/DialCache/invalidation.html) +· [Stale-on-error](https://lan17.github.io/DialCache/stale-on-error.html) +· [Key design](https://lan17.github.io/DialCache/configuration.html#keys-ids-and-extra-dimensions) ## Explore the reference -The [documentation home](https://github.com/lan17/DialCache/blob/main/docs/index.md) +The [documentation home](https://lan17.github.io/DialCache/) provides a guided reading order and a topic map. Each feature guide starts with its purpose and setup, then explains execution, edge cases, and API details. | I want to… | Read | | --- | --- | -| Add caching to a service | [Getting started](https://github.com/lan17/DialCache/blob/main/docs/getting-started.md) | -| Understand what runs on a hit, miss, or error | [How DialCache works](https://github.com/lan17/DialCache/blob/main/docs/concepts.md) | -| Look up methods, options, and exports | [API reference](https://github.com/lan17/DialCache/blob/main/docs/api.md) | -| Set keys, layers, TTLs, and rollout policy | [Configuration](https://github.com/lan17/DialCache/blob/main/docs/configuration.md) | -| Connect Redis or Valkey; customize serialization | [Redis and Valkey](https://github.com/lan17/DialCache/blob/main/docs/redis.md) | -| Understand shared work and deadlines | [Coalescing and liveness](https://github.com/lan17/DialCache/blob/main/docs/coalescing.md) | -| Build dashboards and diagnose misses | [Observability](https://github.com/lan17/DialCache/blob/main/docs/observability.md) | -| Upgrade, validate, or contribute | [Upgrading](https://github.com/lan17/DialCache/blob/main/docs/upgrading.md) · [Maintainer guide](https://github.com/lan17/DialCache/blob/main/docs/maintainers.md) | +| Add caching to a service | [Getting started](https://lan17.github.io/DialCache/getting-started.html) | +| Understand what runs on a hit, miss, or error | [How DialCache works](https://lan17.github.io/DialCache/concepts.html) | +| Look up methods, options, and exports | [API reference](https://lan17.github.io/DialCache/api.html) | +| Set keys, layers, TTLs, and rollout policy | [Configuration](https://lan17.github.io/DialCache/configuration.html) | +| Connect Redis or Valkey; customize serialization | [Redis and Valkey](https://lan17.github.io/DialCache/redis.html) | +| Understand shared work and deadlines | [Coalescing and liveness](https://lan17.github.io/DialCache/coalescing.html) | +| Build dashboards and diagnose misses | [Observability](https://lan17.github.io/DialCache/observability.html) | +| Upgrade, validate, or contribute | [Upgrading](https://lan17.github.io/DialCache/upgrading.html) · [Maintainer guide](https://lan17.github.io/DialCache/maintainers.html) | + +[Browse the reference as Markdown](https://github.com/lan17/DialCache/tree/main/docs). MIT licensed. See [LICENSE](https://github.com/lan17/DialCache/blob/main/LICENSE). diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts new file mode 100644 index 0000000..dc78ab4 --- /dev/null +++ b/docs/.vitepress/config.mts @@ -0,0 +1,62 @@ +import { defineConfig } from "vitepress"; + +// Preserve Vite 6's targets except Safari 14, whose destructuring target is +// unsupported by the repository's patched esbuild. Apply to build and dev. +const browserTargets = ["es2020", "chrome87", "edge88", "firefox78", "safari14.1"]; + +export default defineConfig({ + title: "DialCache", + description: + "Request-local, in-process, and Redis caching for TypeScript services. Learn the read path, configure runtime policies, and explore the API.", + lang: "en-US", + base: "/DialCache/", + vite: { + build: { target: browserTargets }, + optimizeDeps: { esbuildOptions: { target: browserTargets } }, + }, + themeConfig: { + nav: [ + { text: "Documentation", link: "/" }, + { text: "API reference", link: "/api" }, + { text: "npm", link: "https://www.npmjs.com/package/dialcache" }, + ], + sidebar: [ + { + text: "Start here", + items: [ + { text: "Overview", link: "/" }, + { text: "Getting started", link: "/getting-started" }, + { text: "How DialCache works", link: "/concepts" }, + ], + }, + { + text: "Features and behavior", + items: [ + { text: "Configuration", link: "/configuration" }, + { text: "Redis and Valkey", link: "/redis" }, + { text: "Targeted invalidation", link: "/invalidation" }, + { text: "Stale-on-error", link: "/stale-on-error" }, + { text: "Shadow validation", link: "/shadow-validation" }, + { text: "Coalescing and liveness", link: "/coalescing" }, + { text: "Observability", link: "/observability" }, + ], + }, + { + text: "Reference and operations", + items: [ + { text: "API reference", link: "/api" }, + { text: "Upgrading", link: "/upgrading" }, + { text: "Maintainer guide", link: "/maintainers" }, + ], + }, + ], + outline: [2, 3], + search: { provider: "local" }, + socialLinks: [ + { icon: "github", link: "https://github.com/lan17/DialCache" }, + ], + editLink: { + pattern: "https://github.com/lan17/DialCache/edit/main/docs/:path", + }, + }, +}); diff --git a/docs/maintainers.md b/docs/maintainers.md index 0bd6e90..6d76add 100644 --- a/docs/maintainers.md +++ b/docs/maintainers.md @@ -12,6 +12,7 @@ Use the repository's pinned pnpm through Corepack: ```bash corepack pnpm install --frozen-lockfile corepack pnpm check +corepack pnpm docs:build corepack pnpm test:integration ``` @@ -42,12 +43,55 @@ PR. Check defaults and bounds against source, and include any rollout or compatibility implications in `docs/upgrading.md`. Keep examples explicit about application-provided dependencies. -The npm tarball contains `README.md` but not `docs/`. README links to repository -files therefore use absolute GitHub URLs. Reference pages use relative Markdown -links so they work in a checkout, on GitHub, and in a static documentation build. +The npm tarball contains `README.md` but not `docs/`. README links to the hosted +reference therefore use absolute URLs. Reference pages use relative Markdown +links so they work in a checkout, on GitHub, and in the documentation site. Before publishing, check file/anchor targets and parse TypeScript examples; execute the self-contained getting-started example as well. +### Run the documentation site + +VitePress renders the Markdown in `docs/` with grouped navigation, page outlines, +syntax highlighting, and local search. Search runs in the browser using an index +built with the site; it needs no external service or credentials. + +```bash +corepack pnpm docs:dev +``` + +To check the production output, run: + +```bash +corepack pnpm docs:build +corepack pnpm docs:preview +``` + +Open the `/DialCache/` URL printed by the server. The build writes to +`docs/.vitepress/dist/` and fails on broken internal page links. Generated output +and the local build cache are ignored by Git. When adding a page, include it in +`docs/index.md` and the sidebar in `docs/.vitepress/config.mts`. + +The dependency overrides keep stable VitePress on patched Vite 6.4.x. The config +uses a Safari 14.1 target for builds and dependency optimization to remain +compatible with the repository's patched esbuild. Revisit this scoped override +and the targets when upgrading VitePress. + +### Publish to GitHub Pages + +The site is hosted at [lan17.github.io/DialCache](https://lan17.github.io/DialCache/). +In the repository's **Settings → Pages**, select **GitHub Actions** as the build +source. Keep `base: "/DialCache/"` in the VitePress config so links and assets work +under the project URL. + +The `Documentation` workflow builds every pull request. A push to `main` builds +and publishes the site through the `github-pages` environment; the workflow can +also be run manually from `main` to republish. Pull requests and manual runs from +other branches cannot upload a Pages artifact or deploy. The deployment job uses +GitHub's short-lived token and OIDC; no deployment secret is needed. + +The published reference follows `main` independently of npm releases. Use the +Markdown at a release tag when reading about an older installed version. + ## Cache-path benchmark From a repository checkout, run the semantic microbenchmark after installing diff --git a/package.json b/package.json index 4c85fbc..d4b6efa 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,9 @@ "benchmark:stale-on-error": "pnpm build && node scripts/benchmark-stale-on-error.mjs", "build": "tsup src/index.ts src/datadog.ts src/node-redis.ts src/prometheus.ts src/redis-protocol.ts src/valkey-glide.ts --format esm,cjs --dts --clean", "check": "pnpm typecheck && pnpm test && pnpm build && pnpm test:package", + "docs:dev": "vitepress dev docs", + "docs:build": "vitepress build docs", + "docs:preview": "vitepress preview docs", "typecheck": "tsc --noEmit", "test": "vitest run --coverage", "test:integration": "vitest run --config vitest.integration.config.ts", @@ -90,8 +93,9 @@ "packageManager": "pnpm@10.33.0", "pnpm": { "overrides": { - "brace-expansion@<=5.0.7": "5.0.8", - "esbuild@<0.28.1": "^0.28.1" + "brace-expansion@<5.0.9": "5.0.9", + "esbuild@<0.28.1": "^0.28.1", + "vitepress@1.6.4>vite": "^6.4.3" } }, "engines": { @@ -110,6 +114,7 @@ "testcontainers": "^12.0.4", "tsup": "^8.5.1", "typescript": "^5.9.3", + "vitepress": "1.6.4", "vitest": "^4.0.14" }, "peerDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8228b78..2a909dd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,8 +5,9 @@ settings: excludeLinksFromLockfile: false overrides: - brace-expansion@<=5.0.7: 5.0.8 + brace-expansion@<5.0.9: 5.0.9 esbuild@<0.28.1: ^0.28.1 + vitepress@1.6.4>vite: ^6.4.3 importers: @@ -43,12 +44,91 @@ importers: typescript: specifier: ^5.9.3 version: 5.9.3 + vitepress: + specifier: 1.6.4 + version: 1.6.4(@algolia/client-search@5.57.0)(@types/node@24.13.3)(lightningcss@1.33.0)(postcss@8.5.26)(search-insights@2.17.3)(typescript@5.9.3)(yaml@2.9.0) vitest: specifier: ^4.0.14 version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(yaml@2.9.0)) packages: + '@algolia/abtesting@1.23.0': + resolution: {integrity: sha512-j45MBISstltys9QyQ4xf6quRiN1g7vMuwQL9VM4dx8YuRZvCQ173b9royZAx6iAbRX3IB1VnG1z//NuwyQ8jpQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/autocomplete-core@1.17.7': + resolution: {integrity: sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q==} + + '@algolia/autocomplete-plugin-algolia-insights@1.17.7': + resolution: {integrity: sha512-Jca5Ude6yUOuyzjnz57og7Et3aXjbwCSDf/8onLHSQgw1qW3ALl9mrMWaXb5FmPVkV3EtkD2F/+NkT6VHyPu9A==} + peerDependencies: + search-insights: '>= 1 < 3' + + '@algolia/autocomplete-preset-algolia@1.17.7': + resolution: {integrity: sha512-ggOQ950+nwbWROq2MOCIL71RE0DdQZsceqrg32UqnhDz8FlO9rL8ONHNsI2R1MH0tkgVIDKI/D0sMiUchsFdWA==} + peerDependencies: + '@algolia/client-search': '>= 4.9.1 < 6' + algoliasearch: '>= 4.9.1 < 6' + + '@algolia/autocomplete-shared@1.17.7': + resolution: {integrity: sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg==} + peerDependencies: + '@algolia/client-search': '>= 4.9.1 < 6' + algoliasearch: '>= 4.9.1 < 6' + + '@algolia/client-abtesting@5.57.0': + resolution: {integrity: sha512-JVFFujiZUCguk5tz3LZr4fTQxqpIrj4/Jw3SI7kMljSqtfLxYn/s/TWH0J2s4iNfsDpxPhgFGMotCpmDI4kZ8w==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-analytics@5.57.0': + resolution: {integrity: sha512-6KqECK4ED3JJQEoDrQWnGPQzElA828xAD4qK5ceawNNyP/LcSvzAoLHjFkoTPksZ/kxj6VUtCRH+IHZesLltng==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-common@5.57.0': + resolution: {integrity: sha512-uqpGF3oXYsoCbQq5d7BzNrNTfIfuvJyGP1CKvSW27T9boUg7KOwyxsAw1AX0a3jSW2HrYEJ/NN+Z4MiGivbpeQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-insights@5.57.0': + resolution: {integrity: sha512-u5NboJVJXDEFplvNnqqX4CxkXPYysjJRj47hOSh9329H8kG5gFLKJBIiS5utMQ+GZm8xQl3Te7NInDk6elEADQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-personalization@5.57.0': + resolution: {integrity: sha512-uzc0b2LmHAK9/QID4xeo35OG84AkZl4YewkCqawqAOGLjT2eZpM/OZx45ESygMHG30Ws+ZTSdluPtMJcUnrbWQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-query-suggestions@5.57.0': + resolution: {integrity: sha512-dIAhnM6ue/ssa5PjgNfu4g8A4yTojl9ZOUzZU3wIaIKRerL2R/3Emuf9n/D6ICXXP167KC6XCeC7nliSw7cuSw==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-search@5.57.0': + resolution: {integrity: sha512-2TTPTTKSJmCptvhCm4Xf3bBYMqZni+Pgc2hVdqc4l9wsBpSJNVTVIKpnd10OubUgkGcmppVDj1XQqYaf6EnPSQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/ingestion@1.57.0': + resolution: {integrity: sha512-W4JseHKt+pzOxlFV+T3MWEG0h4Z2Se5zjoXUD0ewlw8aOWMG/yjRdopUdLQsXULepB/My2tDuZjkwk2sMfsUrQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/monitoring@1.57.0': + resolution: {integrity: sha512-BrxJVE0/eLinEPICCD7BKN/2xnt0nkjge70u8zzE2ISP3fuB3tjLgcwpanUycvlHBFLI4gK0l5ol54p6IYuR/Q==} + engines: {node: '>= 14.0.0'} + + '@algolia/recommend@5.57.0': + resolution: {integrity: sha512-Gc29jkeiLKlVfHvyrIgyUHHE+aYTdXEeLfK42rjr5/1TTVsYwUJz0XkvoIBIqfMjcDg6gXeHb1jTUZ0H+SYIlQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-browser-xhr@5.57.0': + resolution: {integrity: sha512-PIPnPN7MP3fp2VAi01BVXhCWmD366ZB2Hkq5TlYKtThd4KxUtMmaaNDpFgVCTXtSIqWVZLJntOHRvxg/sIPd8Q==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-fetch@5.57.0': + resolution: {integrity: sha512-AX3RlOudXMdTwtwUqdAf5hAVLvXfOZZH1FZh6ALDdrhVLT0TtAIe48N6nYcWcTnwoTxK/wDIQqZ19IMjK8zJAA==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-node-http@5.57.0': + resolution: {integrity: sha512-cWZc1dKb7wy9/wPpwMtL1y89gK2G7y2A47Coa7zwf1ydtIeJm4+S+XxoQ2b/ZRiQnrC1YHavLjYUPDCdnT7Khg==} + engines: {node: '>= 14.0.0'} + '@babel/helper-string-parser@7.29.7': resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} @@ -62,10 +142,19 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/types@7.29.7': resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + '@balena/dockerignore@1.0.2': resolution: {integrity: sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==} @@ -73,6 +162,29 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} + '@docsearch/css@3.8.2': + resolution: {integrity: sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ==} + + '@docsearch/js@3.8.2': + resolution: {integrity: sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ==} + + '@docsearch/react@3.8.2': + resolution: {integrity: sha512-xCRrJQlTt8N9GU0DG4ptwHRkfnSnD/YpdeaXe02iKfqs97TkZJv60yE+1eq/tjPcVnTW8dP5qLP7itifFVV5eg==} + peerDependencies: + '@types/react': '>= 16.8.0 < 19.0.0' + react: '>= 16.8.0 < 19.0.0' + react-dom: '>= 16.8.0 < 19.0.0' + search-insights: '>= 1 < 3' + peerDependenciesMeta: + '@types/react': + optional: true + react: + optional: true + react-dom: + optional: true + search-insights: + optional: true + '@emnapi/core@1.11.1': resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} @@ -252,6 +364,12 @@ packages: engines: {node: '>=6'} hasBin: true + '@iconify-json/simple-icons@1.2.94': + resolution: {integrity: sha512-l8UWzVxKaqZd9ABsE/M/9p6NyGkQnmCnOoZyhQmjlXCtY5PuL2rcWxOFk2l9pk7ux3ERMPkTLE4jl6kQpTkwxA==} + + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -585,6 +703,30 @@ packages: cpu: [x64] os: [win32] + '@shikijs/core@2.5.0': + resolution: {integrity: sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg==} + + '@shikijs/engine-javascript@2.5.0': + resolution: {integrity: sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w==} + + '@shikijs/engine-oniguruma@2.5.0': + resolution: {integrity: sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw==} + + '@shikijs/langs@2.5.0': + resolution: {integrity: sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w==} + + '@shikijs/themes@2.5.0': + resolution: {integrity: sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw==} + + '@shikijs/transformers@2.5.0': + resolution: {integrity: sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg==} + + '@shikijs/types@2.5.0': + resolution: {integrity: sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw==} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -606,6 +748,21 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/hast@3.0.5': + resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} + + '@types/linkify-it@5.0.0': + resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + + '@types/markdown-it@14.2.0': + resolution: {integrity: sha512-NoQ2yGlLWj4wpxMs+TYmRKk3thDrQ97agr7sFqfLsAlvoS8SNQuTrlObhFqG9iugdTtgOE9jpJ6FNM4ZGsa5xQ==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/mdurl@2.0.0': + resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + '@types/node@18.19.130': resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} @@ -621,6 +778,15 @@ packages: '@types/ssh2@1.15.5': resolution: {integrity: sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==} + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + + '@types/web-bluetooth@0.0.21': + resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + + '@ungap/structured-clone@1.4.0': + resolution: {integrity: sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==} + '@valkey/valkey-glide-darwin-arm64@2.0.0': resolution: {integrity: sha512-kiVne6nFqB/NetXarsmuuYyVB0fRf7fxfrLHW/cTh5u2eNCiti1BQqdIBPAW0q0PHsDIOvTB64dEjZnWQ8DjUQ==} cpu: [arm64] @@ -659,6 +825,13 @@ packages: resolution: {integrity: sha512-nJCeRCXgqb7fMEu2dmrdbqOAr/OVpkx2u3dfr+eJuv0zSb/vcanaIH1VZUJAEFMHfvP7WSUfsI6rxZVjJ+/xxQ==} engines: {node: '>=16'} + '@vitejs/plugin-vue@5.2.4': + resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==} + engines: {node: ^18.0.0 || >=20.0.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 + vue: ^3.2.25 + '@vitest/coverage-v8@4.1.10': resolution: {integrity: sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==} peerDependencies: @@ -697,6 +870,92 @@ packages: '@vitest/utils@4.1.10': resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + '@vue/compiler-core@3.5.42': + resolution: {integrity: sha512-2Ye1ilMtKXxl8qZUrQ5j0CdgenFp/HFQmta6rfRyfEsTG69L6Wk+tWuNoHYHMx9E8tF2Slvdg1FuwDvAXdy1LQ==} + + '@vue/compiler-dom@3.5.42': + resolution: {integrity: sha512-qbhQZEFmycr+ni/qyuccS4sucNN7VAbDfbkvNxWOX2VfgFm90MNs3/UhRNKoPMEIVn0F8gdlYjLPvqxHwHeQOA==} + + '@vue/compiler-sfc@3.5.42': + resolution: {integrity: sha512-fkCAFB4okcAANGMThboWnScp/gzWjU0ZSkVnjTIiplmMDq2uq0tIB3j+xVu4rhv5rvOgBySCysudmbMd6xRRqw==} + + '@vue/compiler-ssr@3.5.42': + resolution: {integrity: sha512-xmLk3wLkbizPAiLyomjgFFosf2ys9b5Ghb+oh/k2tnvipNz8OFrQOiTcWCzyK7MpBp9KkyGtfvgfLUivbmuGYA==} + + '@vue/devtools-api@7.7.10': + resolution: {integrity: sha512-KxtEpUOOpFz/qOGRrAwA36QF7DqIA+FXgCYit9mk9wjbaZt0sXOFz81ElOZtKA4HbWHUdwNjZHBFsFFyp5BZiA==} + + '@vue/devtools-kit@7.7.10': + resolution: {integrity: sha512-3WNi2Kq4tbpVbmhml7RiphmAt0279oh3fKNeWMQIrltfX8Q91b4i5PL8DtyNKdwmcsGrV4fg+erwWOmD05CLIw==} + + '@vue/devtools-shared@7.7.10': + resolution: {integrity: sha512-wOPslzB8vTvpxwdaOcR2qAbwmuSP0L+rhpoC6Cf56V3Jip+HWb7PQQXOUPgBNQARpXsbQX/+mvi8kKucmBGRwQ==} + + '@vue/reactivity@3.5.42': + resolution: {integrity: sha512-TzNNfKpb7hDxbQltwAut8VDQA5YP+BuRlxntHUuRjyKwlMvmAPbs3unhCvieijifY6vFfVBwsS7wG/C7uq+bEQ==} + + '@vue/runtime-core@3.5.42': + resolution: {integrity: sha512-9uACtuHs7vJGkm5Bp3xu4xRDLFTIYy5DgxpToVjqGIAhAEKwQfsaLvKINhM6nFVp6bZPRFGdDqd1g52MqKsotA==} + + '@vue/runtime-dom@3.5.42': + resolution: {integrity: sha512-rsCmhiWLaRxGltLwhlCWyYkFn7WAbKRh0q17eZ1A6Dq6eqc2ACQ61IIryxz0LrsvCzHSilLA9JHovVwM8CNE2g==} + + '@vue/server-renderer@3.5.42': + resolution: {integrity: sha512-2++5dUyYS4gvo7xQXSECUDhB7TS0aOl5SeVfC5qSq1Jgfhjvegw1zqhwTIR3imZ+QYPJQw9gfcFvXGAjGZ7ajQ==} + + '@vue/shared@3.5.42': + resolution: {integrity: sha512-2rPxex1jQf4jvl9MOHl6YaXCPcrNqz/FstMOEh3QWY+/OME9nQTvl9WYeCwhW7AFjaR0SnngZGlp/wkR6rkI6g==} + + '@vueuse/core@12.8.2': + resolution: {integrity: sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==} + + '@vueuse/integrations@12.8.2': + resolution: {integrity: sha512-fbGYivgK5uBTRt7p5F3zy6VrETlV9RtZjBqd1/HxGdjdckBgBM4ugP8LHpjolqTj14TXTxSK1ZfgPbHYyGuH7g==} + peerDependencies: + async-validator: ^4 + axios: ^1 + change-case: ^5 + drauu: ^0.4 + focus-trap: ^7 + fuse.js: ^7 + idb-keyval: ^6 + jwt-decode: ^4 + nprogress: ^0.2 + qrcode: ^1.5 + sortablejs: ^1 + universal-cookie: ^7 + peerDependenciesMeta: + async-validator: + optional: true + axios: + optional: true + change-case: + optional: true + drauu: + optional: true + focus-trap: + optional: true + fuse.js: + optional: true + idb-keyval: + optional: true + jwt-decode: + optional: true + nprogress: + optional: true + qrcode: + optional: true + sortablejs: + optional: true + universal-cookie: + optional: true + + '@vueuse/metadata@12.8.2': + resolution: {integrity: sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==} + + '@vueuse/shared@12.8.2': + resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==} + abort-controller@3.0.0: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} @@ -706,6 +965,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + algoliasearch@5.57.0: + resolution: {integrity: sha512-HpND7MBGctOAkd1GoQoDZCGoCpqNTS5NG1LuhElFet3RdLJkwnyTYZXZhXwtpAQPrI36fqQ3eT6KQrdKDTKu3A==} + engines: {node: '>= 14.0.0'} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -810,11 +1073,14 @@ packages: bintrees@1.0.2: resolution: {integrity: sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==} + birpc@2.9.0: + resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} + bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} - brace-expansion@5.0.8: - resolution: {integrity: sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==} + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} engines: {node: 20 || >=22} buffer-crc32@1.0.0: @@ -845,10 +1111,19 @@ packages: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -871,6 +1146,9 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} @@ -889,6 +1167,10 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + copy-anything@4.1.0: + resolution: {integrity: sha512-ufbM3smX/Jbnpk5wcQjzd1MgBpzmqfNETUAyZNrGwU9foRlyHoGzMMBBCRzEhQLBjZfFDE1W2ufPXX2vdWkV8Q==} + engines: {node: '>=18'} + core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} @@ -909,6 +1191,9 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -918,10 +1203,17 @@ packages: supports-color: optional: true + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + docker-compose@1.4.2: resolution: {integrity: sha512-rPHigTKGaEHpkUmfd69QgaOp+Os5vGJwG/Ry8lcr8W/382AmI+z/D7qoa9BybKIkqNppaIbs8RYeHSevdQjWww==} engines: {node: '>= 6.0.0'} @@ -937,6 +1229,9 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + emoji-regex-xs@1.0.0: + resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -946,6 +1241,10 @@ packages: end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + es-module-lexer@2.3.0: resolution: {integrity: sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==} @@ -958,6 +1257,9 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -994,6 +1296,9 @@ packages: fix-dts-default-cjs-exports@1.0.1: resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} + focus-trap@7.8.0: + resolution: {integrity: sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==} + foreground-child@3.3.1: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} @@ -1030,6 +1335,15 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + hookable@5.5.3: + resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + hot-shots@17.0.0: resolution: {integrity: sha512-d3URpxEO5b0HQfZsrU2qRJ+cUPr9WBrsgHCVnD1bRdFI7KIZokNVI8n1uthsl4WB2l50WWS9vTlwJSOF/6I12w==} engines: {node: '>=18.0.0'} @@ -1037,6 +1351,9 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -1194,6 +1511,27 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} + mark.js@8.11.1: + resolution: {integrity: sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==} + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + minimatch@5.1.9: resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} engines: {node: '>=10'} @@ -1206,6 +1544,12 @@ packages: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} + minisearch@7.2.0: + resolution: {integrity: sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==} + + mitt@3.0.1: + resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + mkdirp-classic@0.5.3: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} @@ -1246,6 +1590,9 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + oniguruma-to-es@3.1.1: + resolution: {integrity: sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==} + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -1260,6 +1607,9 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1300,6 +1650,14 @@ packages: resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} engines: {node: ^10 || ^12 || >=14} + preact@10.29.8: + resolution: {integrity: sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==} + peerDependencies: + preact-render-to-string: '>=5' + peerDependenciesMeta: + preact-render-to-string: + optional: true + process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} @@ -1318,6 +1676,9 @@ packages: resolution: {integrity: sha512-WPn+h9RGEExOKdu4bsF4HksG/uzd3cFq3MFtq8PsFeExPse5Ha/VOjQNyHhjboBFwGXGev6muJYTSPAOkROq2g==} engines: {node: '>=18'} + property-information@7.2.0: + resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + protobufjs@7.6.5: resolution: {integrity: sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==} engines: {node: '>=12.0.0'} @@ -1346,6 +1707,15 @@ packages: redis@4.7.1: resolution: {integrity: sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==} + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.1.0: + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -1358,6 +1728,9 @@ packages: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + rolldown@1.1.5: resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1377,6 +1750,9 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + search-insights@2.17.3: + resolution: {integrity: sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==} + semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} @@ -1390,6 +1766,9 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + shiki@2.5.0: + resolution: {integrity: sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -1408,6 +1787,13 @@ packages: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + speakingurl@14.0.1: + resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} + engines: {node: '>=0.10.0'} + split-ca@1.0.1: resolution: {integrity: sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==} @@ -1441,6 +1827,9 @@ packages: string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -1454,10 +1843,17 @@ packages: engines: {node: '>=16 || 14 >=14.17'} hasBin: true + superjson@2.2.6: + resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} + engines: {node: '>=16'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + tabbable@6.5.0: + resolution: {integrity: sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==} + tar-fs@2.1.5: resolution: {integrity: sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==} @@ -1516,6 +1912,9 @@ packages: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} @@ -1562,6 +1961,21 @@ packages: resolution: {integrity: sha512-YQ3WlbqjYMmNpdvDH64jAgLjxuAR9+649calDWhbshYaeQGO2bR4nI94ORJmwI3J9YhoKQnpyGOK+0zlWS5N5Q==} engines: {node: '>=22.19.0'} + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + unix-dgram@2.0.7: resolution: {integrity: sha512-pWaQorcdxEUBFIKjCqqIlQaOoNVmchyoaNAJ/1LwyyfK2XSxcBhgJNiSE8ZRhR0xkNGyk4xInt1G03QPoKXY5A==} engines: {node: '>=0.10.48'} @@ -1569,6 +1983,52 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + + vite@6.4.3: + resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + vite@8.1.4: resolution: {integrity: sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1612,6 +2072,18 @@ packages: yaml: optional: true + vitepress@1.6.4: + resolution: {integrity: sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg==} + hasBin: true + peerDependencies: + markdown-it-mathjax3: ^4 + postcss: ^8 + peerDependenciesMeta: + markdown-it-mathjax3: + optional: true + postcss: + optional: true + vitest@4.1.10: resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -1653,6 +2125,14 @@ packages: jsdom: optional: true + vue@3.5.42: + resolution: {integrity: sha512-4RyHQTbQvOPs3MfvUO1Sg0YRrKNnA0mAVtvpd12Tg1fKDN7OHBUl1IqSn8zGJjK9nI3NkNp8cgTpVrSZC5TTcA==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -1698,8 +2178,123 @@ packages: resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} engines: {node: '>= 14'} + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + snapshots: + '@algolia/abtesting@1.23.0': + dependencies: + '@algolia/client-common': 5.57.0 + '@algolia/requester-browser-xhr': 5.57.0 + '@algolia/requester-fetch': 5.57.0 + '@algolia/requester-node-http': 5.57.0 + + '@algolia/autocomplete-core@1.17.7(@algolia/client-search@5.57.0)(algoliasearch@5.57.0)(search-insights@2.17.3)': + dependencies: + '@algolia/autocomplete-plugin-algolia-insights': 1.17.7(@algolia/client-search@5.57.0)(algoliasearch@5.57.0)(search-insights@2.17.3) + '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.57.0)(algoliasearch@5.57.0) + transitivePeerDependencies: + - '@algolia/client-search' + - algoliasearch + - search-insights + + '@algolia/autocomplete-plugin-algolia-insights@1.17.7(@algolia/client-search@5.57.0)(algoliasearch@5.57.0)(search-insights@2.17.3)': + dependencies: + '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.57.0)(algoliasearch@5.57.0) + search-insights: 2.17.3 + transitivePeerDependencies: + - '@algolia/client-search' + - algoliasearch + + '@algolia/autocomplete-preset-algolia@1.17.7(@algolia/client-search@5.57.0)(algoliasearch@5.57.0)': + dependencies: + '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.57.0)(algoliasearch@5.57.0) + '@algolia/client-search': 5.57.0 + algoliasearch: 5.57.0 + + '@algolia/autocomplete-shared@1.17.7(@algolia/client-search@5.57.0)(algoliasearch@5.57.0)': + dependencies: + '@algolia/client-search': 5.57.0 + algoliasearch: 5.57.0 + + '@algolia/client-abtesting@5.57.0': + dependencies: + '@algolia/client-common': 5.57.0 + '@algolia/requester-browser-xhr': 5.57.0 + '@algolia/requester-fetch': 5.57.0 + '@algolia/requester-node-http': 5.57.0 + + '@algolia/client-analytics@5.57.0': + dependencies: + '@algolia/client-common': 5.57.0 + '@algolia/requester-browser-xhr': 5.57.0 + '@algolia/requester-fetch': 5.57.0 + '@algolia/requester-node-http': 5.57.0 + + '@algolia/client-common@5.57.0': {} + + '@algolia/client-insights@5.57.0': + dependencies: + '@algolia/client-common': 5.57.0 + '@algolia/requester-browser-xhr': 5.57.0 + '@algolia/requester-fetch': 5.57.0 + '@algolia/requester-node-http': 5.57.0 + + '@algolia/client-personalization@5.57.0': + dependencies: + '@algolia/client-common': 5.57.0 + '@algolia/requester-browser-xhr': 5.57.0 + '@algolia/requester-fetch': 5.57.0 + '@algolia/requester-node-http': 5.57.0 + + '@algolia/client-query-suggestions@5.57.0': + dependencies: + '@algolia/client-common': 5.57.0 + '@algolia/requester-browser-xhr': 5.57.0 + '@algolia/requester-fetch': 5.57.0 + '@algolia/requester-node-http': 5.57.0 + + '@algolia/client-search@5.57.0': + dependencies: + '@algolia/client-common': 5.57.0 + '@algolia/requester-browser-xhr': 5.57.0 + '@algolia/requester-fetch': 5.57.0 + '@algolia/requester-node-http': 5.57.0 + + '@algolia/ingestion@1.57.0': + dependencies: + '@algolia/client-common': 5.57.0 + '@algolia/requester-browser-xhr': 5.57.0 + '@algolia/requester-fetch': 5.57.0 + '@algolia/requester-node-http': 5.57.0 + + '@algolia/monitoring@1.57.0': + dependencies: + '@algolia/client-common': 5.57.0 + '@algolia/requester-browser-xhr': 5.57.0 + '@algolia/requester-fetch': 5.57.0 + '@algolia/requester-node-http': 5.57.0 + + '@algolia/recommend@5.57.0': + dependencies: + '@algolia/client-common': 5.57.0 + '@algolia/requester-browser-xhr': 5.57.0 + '@algolia/requester-fetch': 5.57.0 + '@algolia/requester-node-http': 5.57.0 + + '@algolia/requester-browser-xhr@5.57.0': + dependencies: + '@algolia/client-common': 5.57.0 + + '@algolia/requester-fetch@5.57.0': + dependencies: + '@algolia/client-common': 5.57.0 + + '@algolia/requester-node-http@5.57.0': + dependencies: + '@algolia/client-common': 5.57.0 + '@babel/helper-string-parser@7.29.7': {} '@babel/helper-validator-identifier@7.29.7': {} @@ -1708,15 +2303,49 @@ snapshots: dependencies: '@babel/types': 7.29.7 + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + '@babel/types@7.29.7': dependencies: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@balena/dockerignore@1.0.2': {} '@bcoe/v8-coverage@1.0.2': {} + '@docsearch/css@3.8.2': {} + + '@docsearch/js@3.8.2(@algolia/client-search@5.57.0)(search-insights@2.17.3)': + dependencies: + '@docsearch/react': 3.8.2(@algolia/client-search@5.57.0)(search-insights@2.17.3) + preact: 10.29.8 + transitivePeerDependencies: + - '@algolia/client-search' + - '@types/react' + - preact-render-to-string + - react + - react-dom + - search-insights + + '@docsearch/react@3.8.2(@algolia/client-search@5.57.0)(search-insights@2.17.3)': + dependencies: + '@algolia/autocomplete-core': 1.17.7(@algolia/client-search@5.57.0)(algoliasearch@5.57.0)(search-insights@2.17.3) + '@algolia/autocomplete-preset-algolia': 1.17.7(@algolia/client-search@5.57.0)(algoliasearch@5.57.0) + '@docsearch/css': 3.8.2 + algoliasearch: 5.57.0 + optionalDependencies: + search-insights: 2.17.3 + transitivePeerDependencies: + - '@algolia/client-search' + '@emnapi/core@1.11.1': dependencies: '@emnapi/wasi-threads': 1.2.2 @@ -1830,6 +2459,12 @@ snapshots: protobufjs: 7.6.5 yargs: 17.7.3 + '@iconify-json/simple-icons@1.2.94': + dependencies: + '@iconify/types': 2.0.0 + + '@iconify/types@2.0.0': {} + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -2047,6 +2682,46 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.62.2': optional: true + '@shikijs/core@2.5.0': + dependencies: + '@shikijs/engine-javascript': 2.5.0 + '@shikijs/engine-oniguruma': 2.5.0 + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 3.1.1 + + '@shikijs/engine-oniguruma@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + + '@shikijs/themes@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + + '@shikijs/transformers@2.5.0': + dependencies: + '@shikijs/core': 2.5.0 + '@shikijs/types': 2.5.0 + + '@shikijs/types@2.5.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + + '@shikijs/vscode-textmate@10.0.2': {} + '@standard-schema/spec@1.1.0': {} '@tybys/wasm-util@0.10.3': @@ -2074,6 +2749,23 @@ snapshots: '@types/estree@1.0.9': {} + '@types/hast@3.0.5': + dependencies: + '@types/unist': 3.0.3 + + '@types/linkify-it@5.0.0': {} + + '@types/markdown-it@14.2.0': + dependencies: + '@types/linkify-it': 5.0.0 + '@types/mdurl': 2.0.0 + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/mdurl@2.0.0': {} + '@types/node@18.19.130': dependencies: undici-types: 5.26.5 @@ -2095,6 +2787,12 @@ snapshots: dependencies: '@types/node': 18.19.130 + '@types/unist@3.0.3': {} + + '@types/web-bluetooth@0.0.21': {} + + '@ungap/structured-clone@1.4.0': {} + '@valkey/valkey-glide-darwin-arm64@2.0.0': optional: true @@ -2125,6 +2823,11 @@ snapshots: '@valkey/valkey-glide-linux-x64-gnu': 2.0.0 '@valkey/valkey-glide-linux-x64-musl': 2.0.0 + '@vitejs/plugin-vue@5.2.4(vite@6.4.3(@types/node@24.13.3)(lightningcss@1.33.0)(yaml@2.9.0))(vue@3.5.42(typescript@5.9.3))': + dependencies: + vite: 6.4.3(@types/node@24.13.3)(lightningcss@1.33.0)(yaml@2.9.0) + vue: 3.5.42(typescript@5.9.3) + '@vitest/coverage-v8@4.1.10(vitest@4.1.10)': dependencies: '@bcoe/v8-coverage': 1.0.2 @@ -2180,12 +2883,128 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 + '@vue/compiler-core@3.5.42': + dependencies: + '@babel/parser': 7.29.8 + '@vue/shared': 3.5.42 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.42': + dependencies: + '@vue/compiler-core': 3.5.42 + '@vue/shared': 3.5.42 + + '@vue/compiler-sfc@3.5.42': + dependencies: + '@babel/parser': 7.29.8 + '@vue/compiler-core': 3.5.42 + '@vue/compiler-dom': 3.5.42 + '@vue/compiler-ssr': 3.5.42 + '@vue/shared': 3.5.42 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.26 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.42': + dependencies: + '@vue/compiler-dom': 3.5.42 + '@vue/shared': 3.5.42 + + '@vue/devtools-api@7.7.10': + dependencies: + '@vue/devtools-kit': 7.7.10 + + '@vue/devtools-kit@7.7.10': + dependencies: + '@vue/devtools-shared': 7.7.10 + birpc: 2.9.0 + hookable: 5.5.3 + mitt: 3.0.1 + perfect-debounce: 1.0.0 + speakingurl: 14.0.1 + superjson: 2.2.6 + + '@vue/devtools-shared@7.7.10': + dependencies: + rfdc: 1.4.1 + + '@vue/reactivity@3.5.42': + dependencies: + '@vue/shared': 3.5.42 + + '@vue/runtime-core@3.5.42': + dependencies: + '@vue/reactivity': 3.5.42 + '@vue/shared': 3.5.42 + + '@vue/runtime-dom@3.5.42': + dependencies: + '@vue/reactivity': 3.5.42 + '@vue/runtime-core': 3.5.42 + '@vue/shared': 3.5.42 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.42': + dependencies: + '@vue/compiler-ssr': 3.5.42 + '@vue/runtime-dom': 3.5.42 + '@vue/shared': 3.5.42 + + '@vue/shared@3.5.42': {} + + '@vueuse/core@12.8.2(typescript@5.9.3)': + dependencies: + '@types/web-bluetooth': 0.0.21 + '@vueuse/metadata': 12.8.2 + '@vueuse/shared': 12.8.2(typescript@5.9.3) + vue: 3.5.42(typescript@5.9.3) + transitivePeerDependencies: + - typescript + + '@vueuse/integrations@12.8.2(focus-trap@7.8.0)(typescript@5.9.3)': + dependencies: + '@vueuse/core': 12.8.2(typescript@5.9.3) + '@vueuse/shared': 12.8.2(typescript@5.9.3) + vue: 3.5.42(typescript@5.9.3) + optionalDependencies: + focus-trap: 7.8.0 + transitivePeerDependencies: + - typescript + + '@vueuse/metadata@12.8.2': {} + + '@vueuse/shared@12.8.2(typescript@5.9.3)': + dependencies: + vue: 3.5.42(typescript@5.9.3) + transitivePeerDependencies: + - typescript + abort-controller@3.0.0: dependencies: event-target-shim: 5.0.1 acorn@8.17.0: {} + algoliasearch@5.57.0: + dependencies: + '@algolia/abtesting': 1.23.0 + '@algolia/client-abtesting': 5.57.0 + '@algolia/client-analytics': 5.57.0 + '@algolia/client-common': 5.57.0 + '@algolia/client-insights': 5.57.0 + '@algolia/client-personalization': 5.57.0 + '@algolia/client-query-suggestions': 5.57.0 + '@algolia/client-search': 5.57.0 + '@algolia/ingestion': 1.57.0 + '@algolia/monitoring': 1.57.0 + '@algolia/recommend': 5.57.0 + '@algolia/requester-browser-xhr': 5.57.0 + '@algolia/requester-fetch': 5.57.0 + '@algolia/requester-node-http': 5.57.0 + ansi-regex@5.0.1: {} ansi-regex@6.2.2: {} @@ -2284,13 +3103,15 @@ snapshots: bintrees@1.0.2: {} + birpc@2.9.0: {} + bl@4.1.0: dependencies: buffer: 5.7.1 inherits: 2.0.4 readable-stream: 3.6.2 - brace-expansion@5.0.8: + brace-expansion@5.0.9: dependencies: balanced-match: 4.0.4 @@ -2318,8 +3139,14 @@ snapshots: cac@6.7.14: {} + ccount@2.0.1: {} + chai@6.2.2: {} + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -2340,6 +3167,8 @@ snapshots: color-name@1.1.4: {} + comma-separated-tokens@2.0.3: {} + commander@4.1.1: {} compress-commons@6.0.2: @@ -2356,6 +3185,8 @@ snapshots: convert-source-map@2.0.0: {} + copy-anything@4.1.0: {} + core-util-is@1.0.3: {} cpu-features@0.0.10: @@ -2377,12 +3208,20 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + csstype@3.2.3: {} + debug@4.4.3: dependencies: ms: 2.1.3 + dequal@2.0.3: {} + detect-libc@2.1.2: {} + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + docker-compose@1.4.2: dependencies: yaml: 2.9.0 @@ -2409,6 +3248,8 @@ snapshots: eastasianwidth@0.2.0: {} + emoji-regex-xs@1.0.0: {} + emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} @@ -2417,6 +3258,8 @@ snapshots: dependencies: once: 1.4.0 + entities@7.0.1: {} + es-module-lexer@2.3.0: {} esbuild@0.28.1: @@ -2450,6 +3293,8 @@ snapshots: escalade@3.2.0: {} + estree-walker@2.0.2: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 @@ -2472,6 +3317,10 @@ snapshots: optionalDependencies: picomatch: 4.0.5 + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + file-uri-to-path@1.0.0: optional: true @@ -2481,6 +3330,10 @@ snapshots: mlly: 1.8.2 rollup: 4.62.2 + focus-trap@7.8.0: + dependencies: + tabbable: 6.5.0 + foreground-child@3.3.1: dependencies: cross-spawn: 7.0.6 @@ -2510,12 +3363,34 @@ snapshots: has-flag@4.0.0: {} + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.5 + + hookable@5.5.3: {} + hot-shots@17.0.0: optionalDependencies: unix-dgram: 2.0.7 html-escaper@2.0.2: {} + html-void-elements@3.0.0: {} + ieee754@1.2.1: {} inherits@2.0.4: {} @@ -2634,16 +3509,51 @@ snapshots: dependencies: semver: 7.8.5 + mark.js@8.11.1: {} + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.4.0 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-encode@2.0.1: {} + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + minimatch@5.1.9: dependencies: - brace-expansion: 5.0.8 + brace-expansion: 5.0.9 minimatch@9.0.9: dependencies: - brace-expansion: 5.0.8 + brace-expansion: 5.0.9 minipass@7.1.3: {} + minisearch@7.2.0: {} + + mitt@3.0.1: {} + mkdirp-classic@0.5.3: {} mkdirp@3.0.1: {} @@ -2678,6 +3588,12 @@ snapshots: dependencies: wrappy: 1.0.2 + oniguruma-to-es@3.1.1: + dependencies: + emoji-regex-xs: 1.0.0 + regex: 6.1.0 + regex-recursion: 6.0.2 + package-json-from-dist@1.0.1: {} path-key@3.1.1: {} @@ -2689,6 +3605,8 @@ snapshots: pathe@2.0.3: {} + perfect-debounce@1.0.0: {} + picocolors@1.1.1: {} picomatch@4.0.5: {} @@ -2716,6 +3634,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + preact@10.29.8: {} + process-nextick-args@2.0.1: {} process@0.11.10: {} @@ -2738,6 +3658,8 @@ snapshots: transitivePeerDependencies: - supports-color + property-information@7.2.0: {} + protobufjs@7.6.5: dependencies: '@protobufjs/aspromise': 1.1.2 @@ -2796,12 +3718,24 @@ snapshots: '@redis/search': 1.2.0(@redis/client@1.6.1) '@redis/time-series': 1.1.0(@redis/client@1.6.1) + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.1.0: + dependencies: + regex-utilities: 2.3.0 + require-directory@2.1.1: {} resolve-from@5.0.0: {} retry@0.12.0: {} + rfdc@1.4.1: {} + rolldown@1.1.5: dependencies: '@oxc-project/types': 0.139.0 @@ -2860,6 +3794,8 @@ snapshots: safer-buffer@2.1.2: {} + search-insights@2.17.3: {} + semver@7.8.5: {} shebang-command@2.0.0: @@ -2868,6 +3804,17 @@ snapshots: shebang-regex@3.0.0: {} + shiki@2.5.0: + dependencies: + '@shikijs/core': 2.5.0 + '@shikijs/engine-javascript': 2.5.0 + '@shikijs/engine-oniguruma': 2.5.0 + '@shikijs/langs': 2.5.0 + '@shikijs/themes': 2.5.0 + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + siginfo@2.0.0: {} signal-exit@3.0.7: {} @@ -2878,6 +3825,10 @@ snapshots: source-map@0.7.6: {} + space-separated-tokens@2.0.2: {} + + speakingurl@14.0.1: {} + split-ca@1.0.1: {} ssh-remote-port-forward@1.0.4: @@ -2926,6 +3877,11 @@ snapshots: dependencies: safe-buffer: 5.2.1 + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -2944,10 +3900,16 @@ snapshots: tinyglobby: 0.2.17 ts-interface-checker: 0.1.13 + superjson@2.2.6: + dependencies: + copy-anything: 4.1.0 + supports-color@7.2.0: dependencies: has-flag: 4.0.0 + tabbable@6.5.0: {} + tar-fs@2.1.5: dependencies: chownr: 1.1.4 @@ -3051,6 +4013,8 @@ snapshots: tree-kill@1.2.2: {} + trim-lines@3.0.1: {} + ts-interface-checker@0.1.13: {} tslib@2.8.1: @@ -3096,6 +4060,29 @@ snapshots: undici@8.10.1: {} + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + unix-dgram@2.0.7: dependencies: bindings: 1.5.0 @@ -3104,6 +4091,30 @@ snapshots: util-deprecate@1.0.2: {} + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + + vite@6.4.3(@types/node@24.13.3)(lightningcss@1.33.0)(yaml@2.9.0): + dependencies: + esbuild: 0.28.1 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + postcss: 8.5.26 + rollup: 4.62.2 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.13.3 + fsevents: 2.3.3 + lightningcss: 1.33.0 + yaml: 2.9.0 + vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(yaml@2.9.0): dependencies: lightningcss: 1.33.0 @@ -3117,6 +4128,59 @@ snapshots: fsevents: 2.3.3 yaml: 2.9.0 + vitepress@1.6.4(@algolia/client-search@5.57.0)(@types/node@24.13.3)(lightningcss@1.33.0)(postcss@8.5.26)(search-insights@2.17.3)(typescript@5.9.3)(yaml@2.9.0): + dependencies: + '@docsearch/css': 3.8.2 + '@docsearch/js': 3.8.2(@algolia/client-search@5.57.0)(search-insights@2.17.3) + '@iconify-json/simple-icons': 1.2.94 + '@shikijs/core': 2.5.0 + '@shikijs/transformers': 2.5.0 + '@shikijs/types': 2.5.0 + '@types/markdown-it': 14.2.0 + '@vitejs/plugin-vue': 5.2.4(vite@6.4.3(@types/node@24.13.3)(lightningcss@1.33.0)(yaml@2.9.0))(vue@3.5.42(typescript@5.9.3)) + '@vue/devtools-api': 7.7.10 + '@vue/shared': 3.5.42 + '@vueuse/core': 12.8.2(typescript@5.9.3) + '@vueuse/integrations': 12.8.2(focus-trap@7.8.0)(typescript@5.9.3) + focus-trap: 7.8.0 + mark.js: 8.11.1 + minisearch: 7.2.0 + shiki: 2.5.0 + vite: 6.4.3(@types/node@24.13.3)(lightningcss@1.33.0)(yaml@2.9.0) + vue: 3.5.42(typescript@5.9.3) + optionalDependencies: + postcss: 8.5.26 + transitivePeerDependencies: + - '@algolia/client-search' + - '@types/node' + - '@types/react' + - async-validator + - axios + - change-case + - drauu + - fuse.js + - idb-keyval + - jiti + - jwt-decode + - less + - lightningcss + - nprogress + - preact-render-to-string + - qrcode + - react + - react-dom + - sass + - sass-embedded + - search-insights + - sortablejs + - stylus + - sugarss + - terser + - tsx + - typescript + - universal-cookie + - yaml + vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.10 @@ -3146,6 +4210,16 @@ snapshots: transitivePeerDependencies: - msw + vue@3.5.42(typescript@5.9.3): + dependencies: + '@vue/compiler-dom': 3.5.42 + '@vue/compiler-sfc': 3.5.42 + '@vue/runtime-dom': 3.5.42 + '@vue/server-renderer': 3.5.42 + '@vue/shared': 3.5.42 + optionalDependencies: + typescript: 5.9.3 + which@2.0.2: dependencies: isexe: 2.0.0 @@ -3192,3 +4266,5 @@ snapshots: archiver-utils: 5.0.2 compress-commons: 6.0.2 readable-stream: 4.7.0 + + zwitch@2.0.4: {} diff --git a/tsconfig.json b/tsconfig.json index 5de5876..49c1d87 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,5 +14,5 @@ "types": ["node", "vitest/globals"], "skipLibCheck": true }, - "include": ["src", "test"] + "include": ["src", "test", "docs/.vitepress/config.mts"] } From bcec11bb02ea607a34cdba4e4ddd2ce85da303be Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sat, 5 Sep 2026 22:47:28 -0700 Subject: [PATCH 09/31] docs: clarify runtime contracts and verify onboarding examples --- README.md | 16 +- docs/api.md | 15 ++ docs/coalescing.md | 64 +++++- docs/concepts.md | 12 +- docs/configuration.md | 465 ++++++++++++--------------------------- docs/getting-started.md | 25 ++- docs/index.md | 20 +- docs/invalidation.md | 9 +- docs/maintainers.md | 8 +- docs/upgrading.md | 2 +- scripts/test-package.mjs | 41 +++- 11 files changed, 327 insertions(+), 350 deletions(-) diff --git a/README.md b/README.md index dc6516a..706816d 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,8 @@ npm install dialcache Requires Node.js `>=22.15.0 <23.0.0 || >=23.8.0`. Redis and telemetry clients are optional dependencies you install separately. -Create one `DialCache` instance and reuse the wrapped reader: +Save this as `example.mts`. It creates one `DialCache` instance and reuses the +wrapped reader: ```ts import { CacheLayer, DialCache, DialCacheKeyConfig } from "dialcache"; @@ -81,6 +82,15 @@ await dialcache.enable(async () => { await getUser("123"); // Outside enable(): loads from source again. ``` +Run it directly with Node: + +```bash +node --experimental-strip-types example.mts +``` + +You will see `Loading from source: 123` twice: once for the first enabled read, +then again for the uncached call. The second enabled read reuses the value. + This example uses only the process-local layer. A TTL with no ramp enables that layer for every key inside the scope. The LRU holds at most 10,000 entries by default. In a service, place `enable()` around a read-request handler so nested @@ -166,6 +176,10 @@ sample reads and fills in shadow mode before allowing Redis to serve callers. Turning serving off does not stop shadow work; `disabled()` disables both for new invocations. +Policy changes govern new invocations; they do not evict existing values or +cancel shared work. The reference explains +[how TTL changes affect each layer](https://lan17.github.io/DialCache/configuration.html#changing-policy-on-a-running-service). + [Runtime configuration](https://lan17.github.io/DialCache/configuration.html) · [Shadow validation](https://lan17.github.io/DialCache/shadow-validation.html) diff --git a/docs/api.md b/docs/api.md index b42bdb2..27008b7 100644 --- a/docs/api.md +++ b/docs/api.md @@ -39,6 +39,21 @@ Enable a scope **and** configure at least one layer to store values. See [Configuration](configuration.md) for validation and lifetime rules. +### `RedisConfig` + +Pass this object as the constructor's `redis` option: + +| Field | Default | Contract | +| --- | --- | --- | +| `client` | Required | Connected `DialCacheRedisClient`; the application owns connection and shutdown | +| `readTimeoutMs` | `50` | Positive safe integer up to `2_147_483_647` ms; a use case's `remoteReadTimeoutMs` takes precedence | +| `serializer` | `JsonSerializer` | Instance-level Redis serializer; an operation's serializer takes precedence | +| `compression` | `{ thresholdBytes: 4_096, level: 3 }` | `CompressionConfig` or `false`; threshold is a positive safe integer, level is an integer from `1` through `22` | + +Providing a client makes the remote layer available; each operation still needs +a remote TTL and an enabled scope. See [client setup](redis.md), +[serialization](redis.md#serialization), and [compression](redis.md#compression). + ## Scope methods | Method | Return | Behavior | diff --git a/docs/coalescing.md b/docs/coalescing.md index b4761aa..8cb3d96 100644 --- a/docs/coalescing.md +++ b/docs/coalescing.md @@ -2,9 +2,10 @@ [Documentation](index.md) · [API reference](api.md) -By default, DialCache shares same-key in-flight work within the lifetime of the -first active cache layer. A per-use-case policy can disable that sharing. Each -active remote read has a finite deadline, and a separate default deadline begins +By default, DialCache shares same-key in-flight work within a request or a +`DialCache` instance, according to the active layers. A per-use-case policy can +disable that sharing. Each active remote read has a finite deadline, and a +separate default deadline begins when an initially enabled invocation starts its fallback loader. These mechanisms reduce duplicate source work. Their deadlines help flights @@ -17,7 +18,18 @@ registry and capacity limit. It is not another coalescing scope. ## Request coalescing -DialCache has two sharing scopes. +DialCache has two sharing scopes. They can both participate in one call: + +| Active layers | Where same-key work is shared | +| --- | --- | +| Request-local only | Within one outermost `enable()` scope | +| Process-local or remote only | Within and across requests using one `DialCache` instance | +| Request-local plus a shared layer | Within each request first; each request-local miss can then join the instance's shared work | + +For example, two concurrent requests with both request-local and process-local +caching each perform their own request-local lookup. If both miss on the same +key, their lower-layer work can still coalesce into one process-local lookup +and one source call. Each request then memoizes the result in its own scope. ### Request-local scope @@ -59,6 +71,26 @@ cache write; followers await that result. For a process-local-only miss, followers share the leader's fallback and local write. This mitigates a thundering herd on one hot key within the instance. +### What followers inherit + +Each enabled invocation resolves its own runtime config before it can join a +flight. Once it joins, it awaits the leader's result: it does not restart the +Redis read, run its own loader, or apply a separate source deadline. The leader +controls the shared cache path, serialization, writes, and stale-recovery +decision. Followers can therefore receive the leader's failure or recovered +stale value as well as a fresh result. + +A runtime change does not cancel or replace a flight already in progress. A +later invocation that is still eligible to coalesce can join that flight even +if its TTL or timeout differs. Turning all layers off bypasses it; setting +`coalesce: false` starts an independent cache path. Neither action cancels the +leader or removes values it may publish. + +This also matters for `getOrLoad()` calls with different closures or operation +options under one key. Keep their value meaning and serialization consistent, +and make execution independent when inheriting another caller's deadline, +failure, or cancellation behavior would be incorrect. + ### Per-use-case opt-out `DialCacheKeyConfig.coalesce` is a sparse runtime boolean whose effective @@ -221,12 +253,24 @@ The timer starts only when the fallback begins: - calls that began outside an enabled context remain true pass-through and are not timed out, even when the operation has `fallbackTimeoutMs`. -The fallback deadline does not cover work that happens before fallback. An -active remote read has its own resolved -[remote-read deadline](redis.md#remote-read-deadlines-and-async-liveness), while -a pending config provider or serializer load does not. Serialization and a -Redis write after fallback also remain outside it. Give every injected -operation its own finite, resource-native budget. +### Application-owned budgets + +The source deadline is not a total-call timeout. An enabled miss can pass through +each of these stages before returning: + +| Stage | Settlement budget | +| --- | --- | +| Runtime config provider | Application-owned; neither read nor source timer has started | +| Semantic Redis read | Resolved `remoteReadTimeoutMs`; see [Remote-read deadlines](redis.md#remote-read-deadlines-and-async-liveness) | +| Deserialize a cached value | Application-owned; outside the semantic-read timer | +| Source loader | `fallbackTimeoutMs`, starting when the source runs | +| Serialize and write the replacement | Application-owned; the source timer has already finished | +| Explicit `invalidateRemote()` | Application-owned; independent of enabled scopes | + +A pending serializer or Redis write can therefore keep a coalesced flight open +after the source succeeds. Give injected operations finite, resource-native +budgets for queueing, retries, and settlement. A separate application timeout +on the overall request can stop waiting, but does not by itself cancel this work. ### Event-loop behavior diff --git a/docs/concepts.md b/docs/concepts.md index bd800ea..8ae4693 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -22,8 +22,11 @@ Invocation ``` Inactive layers are skipped. The first hit stops traversal, including any work -that would otherwise happen at lower layers. Before the first active layer, -same-key concurrent calls can join one in-flight execution. +that would otherwise happen at lower layers. Same-key concurrent calls can +share work within the request before request-local lookup, and within the +instance before shared-layer lookup. With both kinds of storage enabled, +each request-local miss can join the same instance-wide flight. See +[Coalescing scopes](coalescing.md#request-coalescing). An enabled invocation resolves one policy snapshot before lookup. Defaults belong to the reader; the runtime provider overrides individual fields. @@ -80,6 +83,11 @@ evict values already in request-local or process-local memory. A Redis hit can also warm the local layer with a full local TTL; the remote TTL is not an end-to-end maximum age across the chain. +Changing policy does not evict old entries. In particular, a shorter local TTL +applies to new writes; existing local values retain their insertion TTL. Redis +reads classify frame age using the current remote policy. See +[Policy changes and existing entries](configuration.md#changing-policy-on-a-running-service). + For reads that must consult an entity's invalidation fence, enable only tracked remote caching. The watermark contract additionally depends on bounded in-flight work, application clock skew, and preservation of watermark state. diff --git a/docs/configuration.md b/docs/configuration.md index 928b5ba..2a85017 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -18,81 +18,31 @@ The [API reference](api.md) collects the public signatures and defaults; ## Defining cache operations -### Reusable cached functions - -`cached(fn, options)` wraps a function; the wrapped callable has the same -parameters and always returns a `Promise`. - -| Option | Required | Description | -| --- | --- | --- | -| `keyType` | yes | The kind of id the key addresses, such as `"user_id"`. Together with the id, this is the invalidation unit for tracked entries. | -| `useCase` | yes | Identifies the individual cache. It is part of the stored key and a metrics label. | -| `cacheKey` | yes | Selects a bare id or `{ id, args }` from `fn`'s parameters. | -| `defaultConfig` | no | Provides the `DialCacheKeyConfig` baseline that runtime config overlays field by field. | -| `serializer` | when the return type is not statically JSON-compatible | Selects a per-function `Serializer` for Redis values; see [Serialization](redis.md#serialization). | -| `shadowComparator` | no | Defines synchronous application-level equality for [shadow validation](shadow-validation.md); Node strict deep equality is the default. | -| `trackForInvalidation` | no; default `false` | Opts this use case's Redis entries into watermark-based [targeted invalidation](invalidation.md). | -| `shouldAttemptStaleRecovery` | no; instance policy | Synchronous source-error classifier for [stale-on-error](stale-on-error.md); replaces the lower-precedence policy. | -| `fallbackTimeoutMs` | no; default `60_000` | Sets the fallback deadline in milliseconds, up to 2,147,483,647. `null` disables it; see [Fallback deadlines](coalescing.md#fallback-deadlines). | - -`useCase` is validated when the function is registered. A duplicate within one -`DialCache` instance throws `UseCaseIsAlreadyRegisteredError`, and the internal -name `watermark` throws `UseCaseNameIsReservedError`. - -### One-shot inline loaders - -`getOrLoad(load, options)` runs one zero-argument synchronous or asynchronous -loader through the same cache layers, runtime policy, coalescing, invalidation, -metrics, serialization, and deadline behavior as `cached()`. Cache-plumbing -failures fall through to the loader. A loader failure rejects unless an opted-in -[stale-on-error policy](stale-on-error.md) can serve a retained snapshot: - -```ts -const profile = await dialcache.enable(() => - 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`, `fallbackTimeoutMs`, the comparator, and the selected -stale-recovery classifier are validated and captured for each invocation. Outside an enabled scope, DialCache calls -`load` directly without constructing a key or resolving runtime policy. - -`getOrLoad()` does not register its `useCase` or detect duplicates, but it still -rejects the reserved internal name `"watermark"`. - -Repeated calls should reuse one stable, deployment-defined name such as -`"BuildProfile"`. Never derive it 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. By default, 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. - -A use case can explicitly set `coalesce: false` when its callers must execute -independently, but that does not make an incomplete cache key safe for settled -cache hits. - -Shadow work can run the loader later, after the caller has continued. Snapshot -mutable arguments or captured state before invoking the operation so that the -detached source read still represents the selected key. See -[Shadow validation and Redis bootstrap](shadow-validation.md). - -Prefer `cached()` for reusable loaders and `getOrLoad()` for calculations -intentionally local to one call site. +Use `cached(fn, options)` to register a reusable reader once per instance. +The wrapper preserves its parameters and always returns a `Promise`. Each +registration needs a unique `useCase`; duplicates throw +`UseCaseIsAlreadyRegisteredError`. + +Use `getOrLoad(load, options)` for a zero-argument loader that belongs at one call +site. It runs through the same cache path, but accepts a direct `key` instead +of a `cacheKey` selector and does not register the use case. Both APIs reject +the internal name `"watermark"` with `UseCaseNameIsReservedError`. + +The [operation options table](api.md#operation-options) covers serializers, +invalidation tracking, comparators, error classifiers, and source deadlines. +Prefer stable, deployment-defined use-case names such as `"BuildProfile"`. +Names are part of cache identity and metric labels; put user, request, and +entity dimensions in the key instead. + +For an inline loader, every captured value that can change the result belongs +in the key. All call sites for one identity must agree on value meaning and +serialization. With coalescing enabled, concurrent calls can also share one +caller's loader and its execution policy; see +[What followers inherit](coalescing.md#what-followers-inherit). + +Shadow work can run the loader after the caller has continued. Snapshot mutable +arguments or captured state before invoking the operation so that the detached +read still represents the selected key. ## Enable and disable scopes @@ -231,173 +181,90 @@ expire by TTL. ## Runtime config and ramp controls -Instance-wide behavior is set through the `DialCache` constructor: - -| `DialCacheConfig` option | Default | Description | -| --- | --- | --- | -| `namespace` | `"urn"` | Logical cache namespace and first key component. | -| `redis` | none | `{ client, readTimeoutMs?, serializer?, compression? }`; enables the [remote layer](redis.md). Remote reads default to a 50 ms deadline, and Redis payload compression defaults to zstd level 3 at 4,096 serialized bytes. | -| `localMaxSize` | `10_000` | Global process-local entry cap. `0` disables process-local storage. Must be a nonnegative safe integer. | -| `cacheConfigProvider` | none | Resolves runtime config per enabled invocation as a sparse overlay on the operation's `defaultConfig`; `null` applies no overrides. | -| `shadowMaxInFlight` | `1` | Maximum scheduled or running shadow jobs per instance. Must be a positive safe integer. There is no queue; excess jobs are dropped and measured. | -| `shouldAttemptStaleRecovery` | only `FallbackTimeoutError` | Synchronous instance-default source-error classifier; an operation override replaces it. | -| `metrics` | disabled | A `DialCacheMetricsAdapter`; see [Observability](observability.md). | -| `logger` | `console` | Receives operational cache failures and opted-in confirmed shadow mismatch warnings through `debug`, `warn`, and `error`. | - -Per-invocation policy is a `DialCacheKeyConfig`: per-layer `ttlSec` and `ramp` -maps keyed by `CacheLayer.LOCAL` and `CacheLayer.REMOTE`, `requestLocal` and -`coalesce` booleans, optional `remoteReadTimeoutMs` and -`staleOnErrorMaxAgeSec` fields, and an optional -`shadow` group. The root-exported `ShadowConfig` type defines that group's -independent `ramp` and default-off `logMismatches` leaves. +The constructor supplies shared resources and instance defaults. See +[`DialCacheConfig`](api.md#new-dialcache-options) for its options. +`DialCacheKeyConfig` supplies the baseline and per-invocation overlay: layer +TTLs and ramps, request-local caching, coalescing, remote-read timeout, +stale-recovery age, and shadow policy. ### Baseline and overlay precedence -Every cached definition or `getOrLoad()` invocation can provide an optional -per-use-case `defaultConfig`. That is the baseline policy. The -`cacheConfigProvider` result is a sparse field-level overlay on it. - -Enablement fields use this precedence: +Each operation can supply a `defaultConfig`. The `cacheConfigProvider` result +is a sparse overlay: each supplied leaf replaces the baseline independently. ```text -runtime field -> defaultConfig field -> DialCache disabled baseline +runtime field → defaultConfig field → DialCache disabled baseline ``` -The disabled baseline sets `requestLocal` to `false`, leaves the process-local -and remote TTLs unset, and leaves `shadow` absent. Coalescing defaults to -`true`, but no flight exists while every cache layer is inactive. +The disabled baseline has no local or remote TTL, request-local caching is off, +and shadow work is off. Coalescing defaults to `true`, but no flight exists +while all cache layers are inactive. A local or remote layer needs a TTL; once +it has one, omitting its ramp selects 100% of keys. + +A provider result of `null` (or defensive `undefined`), an empty config, and +omitted fields all inherit the baseline. Local and remote entries in `ttlSec` +and `ramp` merge separately. So do `shadow.ramp` and `shadow.logMismatches`: +`shadow: { ramp: 0 }` stops new shadow admission while preserving an inherited +logging preference. + +Use explicit values to turn inherited features off: -Either serving layer is disabled by policy when it has no effective TTL. With -an effective TTL but no effective ramp, that layer defaults to a 100% ramp. -Shadow work remains off unless `shadow.ramp` is explicitly greater than zero. +| Overlay | Effect on the new invocation | +| --- | --- | +| `requestLocal: false` | Bypass request-local lookup and storage | +| `ramp: { [CacheLayer.LOCAL]: 0 }` | Bypass process-local serving | +| `ramp: { [CacheLayer.REMOTE]: 0 }` | Bypass remote serving; shadow admission stays independent | +| `shadow: { ramp: 0 }` | Stop new shadow work | +| `staleOnErrorMaxAgeSec: 0` | Disable stale recovery | +| `coalesce: false` | Give the caller an independent cache path and source deadline | +| `DialCacheKeyConfig.disabled()` | Disable request-local, stale recovery, and mismatch logging; set both serving ramps and the shadow ramp to `0` | + +The disabled helper leaves TTLs and `coalesce` unset. Inherited TTLs remain +available but inactive under its zero ramps. A later ramp-up coalesces unless +another leaf explicitly opts out. This helper does not cancel admitted work or +disable explicit maintenance such as `invalidateRemote()`. -The remote-read deadline has two additional fallbacks: +The remote-read deadline has additional fallbacks: ```text runtime remoteReadTimeoutMs - -> defaultConfig.remoteReadTimeoutMs - -> redis.readTimeoutMs - -> 50 ms + → defaultConfig.remoteReadTimeoutMs + → redis.readTimeoutMs + → 50 ms ``` -This value bounds how long DialCache waits for an active Redis or Valkey read. -It can be tuned per use case at runtime, but it cannot be disabled. - -`DialCacheKeyConfig` preserves omitted `requestLocal` and `coalesce` leaves as -`undefined`, so the overlay can distinguish omission from an explicit -`false`. Their effective defaults are `false` for request-local memoization and -`true` for coalescing. - -A provider result of `null`, or a defensive `undefined`, applies no overrides. -An empty `DialCacheKeyConfig` and omitted runtime fields also inherit the -baseline. - -Overlay merging is sparse at each leaf. Top-level `requestLocal`, `coalesce`, -`remoteReadTimeoutMs`, and `staleOnErrorMaxAgeSec` leaves merge independently. The local and remote -entries inside `ttlSec` and `ramp` also merge independently. - -The `shadow.ramp` and `shadow.logMismatches` leaves follow the same rule. For -example, `shadow: { ramp: 0 }` disables inherited shadow admission while -preserving an inherited logging preference; `shadow: { logMismatches: false }` -suppresses warnings without changing the inherited shadow cohort. - -Use explicit values to replace inherited policy: - -- `requestLocal: false` disables request-local caching; -- `staleOnErrorMaxAgeSec: 0` disables inherited stale recovery; -- `coalesce: false` gives each caller its own active layer reads, fallback - deadline, fallback execution, and cache writes; -- a process-local or remote ramp of `0` disables that serving layer; -- `shadow: { ramp: 0 }` disables new shadow work; and -- `DialCacheKeyConfig.disabled()` turns request-local, stale recovery, and shadow work off and - ramps both serving layers to `0`. - -The remote serving and shadow cohorts are independent. A remote ramp of `0` -does not override an inherited nonzero `shadow.ramp`; set both to `0` when the -runtime policy must stop new invocation-driven Redis reads and fills. - -`DialCacheKeyConfig.disabled()` returns the complete cache-path overlay -explicitly: `requestLocal: false`, `staleOnErrorMaxAgeSec: 0`, both serving ramps at `0`, -`shadow.ramp: 0`, and `shadow.logMismatches: false`. It intentionally leaves -`coalesce` unset. - -Its `ttlSec` map is empty, so inherited TTLs remain available for a later -ramp-up but inactive under this overlay. If runtime policy ramps a layer back -up, coalescing is on again unless another leaf explicitly opts out. The kill -switch does not cancel already-admitted work or disable explicit maintenance -operations such as `invalidateRemote()`. +It bounds the semantic Redis read and cannot be disabled. It does not include +config resolution, deserialization, the source call, or Redis writes; see +[Deadlines and application-owned budgets](coalescing.md). ### Validation and snapshots -DialCache validates `defaultConfig` when `cached()` registers a definition and -whenever `getOrLoad()` is invoked: - -- TTLs must be positive safe integers no greater than `31_536_000` seconds - (365 days); -- serving ramps and `shadow.ramp` must be finite percentages in the inclusive - range `0` through `100`; -- layer maps and `shadow` must be objects; -- `requestLocal`, `coalesce`, and `shadow.logMismatches` must be booleans when - present; and -- remote-read deadlines must be positive safe integers no greater than - 2,147,483,647 milliseconds; and -- positive stale-recovery maximum ages must exceed the remote TTL and be at - most `31_536_000` seconds. Zero explicitly disables recovery. - -Invalid instance `redis.readTimeoutMs` or `redis.compression` values throw -during `DialCache` construction, as does an invalid `shadowMaxInFlight`. -Invalid defaults are rejected when `cached()` registers a definition or -`getOrLoad()` is invoked. -`null`, zero, fractional, non-finite, string, and larger timeout values are -invalid; remote reads have no unbounded escape hatch. - -Each registration or one-shot invocation captures an immutable internal -snapshot, including the nested `shadow` object. 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 rather than falling back to -valid default leaves: - -- an invalid TTL disables that layer with `invalid_ttl`; -- a serving ramp that is nonnumeric, non-finite, below `0`, or above `100` - disables that layer with `invalid_ramp`; values are never clamped; and -- other valid layers can continue to run. - -Invalid leaves also record a `config_resolution` error, distinguishing provider -garbage from an intentional ramp-down. A malformed runtime config object, -layer-map or `shadow` shape, `requestLocal`, `coalesce`, or -`remoteReadTimeoutMs` value fails config resolution for the whole invocation. -DialCache records -`config_resolution`, marks the no-layer path `config_error`, and runs the -fallback without a Redis read or write. - -An invalid runtime `staleOnErrorMaxAgeSec` records `config_resolution` and -disables recovery while preserving a valid ordinary remote layer. A positive -recovery age without a remote TTL is also a configuration error. Validation is -traversal-dependent: an earlier hit can avoid evaluation of lower-layer leaves. - -Unknown top-level and layer-map fields are generally ignored during runtime -merging. Do not depend on unknown-field rejection to catch a misspelling such -as `ramp.remtoe`; validate externally supplied policy against your schema. -Explicitly removed fields such as `shadowRamp` have dedicated rejection paths. - -Runtime shadow leaves are isolated from caller-serving policy: - -- An invalid `shadow.ramp` records remote `config_resolution` and skips shadow - work when an otherwise eligible Redis path evaluates it. DialCache does not - clamp the value or disable valid serving layers. -- An invalid `shadow.logMismatches` falls back to the default-off logging - policy while preserving the cache result and shadow work, and records one - remote `config_resolution` error for the admitted job. This diagnostic leaf is evaluated only after the - metrics hook, cohort, and capacity gates admit the job. - -Static invalid shadow leaves remain definition-time errors for `cached()` and -invocation-time errors for `getOrLoad()`. The former flat `shadowRamp` field is -removed rather than aliased: `DialCacheKeyConfig` and static defaults reject it -with `DialCacheKeyConfig.shadowRamp was replaced by "shadow.ramp"`; a runtime -provider result containing it fails config resolution for the whole invocation -and runs the loader uncached. +Invalid instance options throw during construction. Invalid `defaultConfig` +leaves throw when `cached()` registers a definition or `getOrLoad()` is invoked. +The [API reference](api.md#dialcachekeyconfig) lists field types and bounds. + +Each registration or inline invocation captures an immutable baseline snapshot, +including nested maps and shadow policy. Mutating the original config later +does not update that baseline. Use the provider for runtime changes. + +Invalid runtime policy fails open at the affected boundary: + +| Invalid input | Behavior | +| --- | --- | +| TTL or serving ramp leaf | Disable that layer with `invalid_ttl` or `invalid_ramp`; record `config_resolution`. Valid layers can continue. Values do not fall back to valid defaults and ramps are not clamped. | +| Config object, layer-map or shadow shape; `requestLocal`, `coalesce`, or `remoteReadTimeoutMs` | Fail resolution for the whole invocation; record `config_resolution` and `config_error`, then run the loader uncached. | +| `staleOnErrorMaxAgeSec` | Disable recovery and record `config_resolution`; a valid ordinary remote layer remains available. A positive age without a remote TTL is also an error. | +| `shadow.ramp` | Record remote `config_resolution` and skip shadow work when an eligible Redis path evaluates it; preserve valid serving layers. | +| `shadow.logMismatches` | Disable mismatch logging while preserving shadow work; record remote `config_resolution` only after the metrics hook, cohort, and capacity gates admit the job. | + +Validation of layer and shadow leaves depends on traversal: an earlier hit can +avoid evaluating lower-layer policy. Unknown runtime fields are generally +ignored, so validate external policy against your application's schema to catch +misspellings such as `ramp.remtoe`. + +The removed `shadowRamp` field is an exception. Static config rejects it with +`DialCacheKeyConfig.shadowRamp was replaced by "shadow.ramp"`; a provider result +containing it fails resolution for the whole invocation. ### Provider behavior @@ -409,6 +276,9 @@ 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 uncached. +This example assumes an application-provided `db` and a connected +`dialCacheRedisClient`; see [Redis setup](redis.md). + ```ts import { CacheLayer, DialCache, DialCacheKeyConfig } from "dialcache"; @@ -422,11 +292,6 @@ const dialcache = new DialCache({ return new DialCacheKeyConfig({ // Sparse override: inherit both TTLs and the local ramp. ramp: { [CacheLayer.REMOTE]: 25 }, - // Shadow leaves merge independently with defaultConfig.shadow. - shadow: { - // Inherit the baseline logMismatches: false. - ramp: 5, - }, // Per-use-case override of the instance's 75 ms read deadline. remoteReadTimeoutMs: 35, }); @@ -441,22 +306,19 @@ const getUser = dialcache.cached( keyType: "user_id", useCase: "GetUser", cacheKey: (userId) => userId, - trackForInvalidation: true, defaultConfig: new DialCacheKeyConfig({ // Omitted ramps default to 100% because these layers have TTLs. ttlSec: { [CacheLayer.LOCAL]: 30, [CacheLayer.REMOTE]: 300, }, - shadow: { - ramp: 0, - logMismatches: false, - }, }), }, ); ``` +### Stable key cohorts + Ramp values are thresholds from 0 to 100. `0` disables the layer, `100` enables it for every key, and an intermediate value selects keys whose DialCache-owned deterministic bucket for the full cache key and layer is below that threshold. @@ -472,52 +334,46 @@ DialCache keeps the assignment 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`. -Ramping down bypasses affected entries; it does not evict them, so a later -ramp-up can reuse entries that remain valid. - -`shadow.ramp` uses its own stable exact-key cohort, independent of both serving -ramps. Omitted and `0` disable shadow work; `100` selects every otherwise -eligible key. `shadow.logMismatches` separately opts confirmed mismatches into -byte-capped JSON warning fields; it does not enable shadow work and defaults -to `false`. Review the data-handling contract before turning it on. +`shadow.ramp` selects its own stable exact-key cohort, independent of both +serving ramps. Shadowing additionally needs a valid remote TTL and a metrics +adapter with the outcome hook. `shadow.logMismatches` controls diagnostic +warnings separately and defaults to `false`. +[Shadow validation](shadow-validation.md) explains eligibility, comparison, +clean-miss fills, capacity, and the data-handling contract. -Shadowing requires a valid remote TTL and a metrics adapter with the shadow -outcome hook. Tracked and untracked Redis operations are both eligible and -keep their normal read and write mode. +### Changing policy on a running service -Shadow work can validate a served Redis hit or exercise Redis while the remote -serving ramp excludes the key. See -[Shadow validation and Redis bootstrap](shadow-validation.md) for eligibility, -clean-miss filling, deadlines, capacity, and rollout guidance. +New invocations resolve the current policy. A change does not evict existing +values or rewrite their stored expiration times: -`shadowComparator` is stable operation behavior rather than runtime policy. It -defaults to Node strict deep equality and receives borrowed decoded-cache and -source values. A custom comparator must be synchronous, deterministic, -side-effect-free, non-mutating, and bounded. +| Change | Existing entries and work | +| --- | --- | +| Lower or raise the local TTL | Existing local entries keep the TTL assigned when inserted. The new TTL applies to subsequent writes. Reads do not refresh that TTL. | +| Lower or raise the remote TTL | A new Redis read classifies the frame's age using the current remote TTL. The key's physical expiration stays as written; a longer policy does not extend it or restore an expired key. | +| Change the stale-recovery maximum age | A new Redis read uses the new age policy. Existing keys keep their physical retention; shorter recovery policy restricts reuse without deleting the key. | +| Set a serving ramp to `0` | Bypass that layer without evicting its entries. A later ramp-up can reuse values that remain valid. | +| Set `requestLocal: false` | Bypass the current request's memoized values without deleting them. Re-enabling it in that scope can reuse them. | +| Change TTLs, deadlines, or recovery while a flight is active | An eligible follower can still join the existing flight and inherit its leader's execution; admitted work is not reconfigured. | +| Return `DialCacheKeyConfig.disabled()` | Stop new cache use and shadow admission. Existing flights and detached jobs can finish and publish. | + +For example, reducing a local TTL from 60 seconds to 5 seconds does not make a +20-second-old local entry miss: it keeps its original 60-second lifetime. A +Redis frame of the same age is no longer fresh under a new 5-second remote TTL, +although a configured recovery policy may still admit it after a source failure. + +When an immediate freshness boundary matters, account for every active layer. +A local hit bypasses the new remote age policy and the invalidation watermark. +See [Freshness boundaries](concepts.md#freshness-boundaries) and +[What followers inherit](coalescing.md#what-followers-inherit). ### Coalescing policy -Coalescing is on unless the resolved policy explicitly sets -`coalesce: false`. The switch covers both request-local and instance-scoped -process flights. - -With it off, concurrent same-key callers each perform their own active layer -reads, receive a full independent remote-read and fallback budget, run their -own loader after a miss, and attempt their own writes. -Settled request-local memoization still serves later sequential calls. -Publication remains last-writer-wins. All Redis writes use one complete-frame -`SET`; tracked reads subsequently apply their watermark fence. A tracked path -that reached Redis suppresses direct process-local fallback publication. - -Opt out when callers sharing one identity must not inherit another caller's -loader failure, timeout, or cancellation behavior. Doing so reintroduces -same-key fan-out to dependencies. - -It also suppresses coalesced-follower metrics and keeps those calls out of -`getCoalescingState()`; each caller emits its own request, miss, latency, and -error observations. See -[Coalescing and async liveness](coalescing.md) for flight scope, deadlines, -shadow scheduling, and observability details. +Coalescing defaults to `true` for both request-local and process-scoped work. +Set `coalesce: false` when callers sharing a value identity need independent +execution, deadlines, failures, or cancellation behavior. Cache hits and settled +request-local memoization still apply. The opt-out increases dependency load +and permits concurrent writes; see +[Coalescing and liveness](coalescing.md#per-use-case-opt-out) for the full contract. ### Provider key input @@ -556,52 +412,15 @@ The namespace and hash-tag components reject `{` and `}` as described under ## Redis payload compression -`RedisConfig.compression` is instance-wide write policy for the remote layer. -It is enabled by default when Redis is configured: - -```ts -import { DialCache, type CompressionConfig } from "dialcache"; - -const compression: CompressionConfig = { - thresholdBytes: 4_096, - level: 3, -}; - -const dialcache = new DialCache({ - redis: { - client: dialCacheRedisClient, - compression, - }, -}); -``` - -`thresholdBytes` must be a positive safe integer and defaults to `4_096`. -`level` must be an integer from `1` through `22` and defaults to `3`. -Passing `false` disables compression for new writes; `null`, other non-object -values, and invalid leaves throw during `DialCache` construction. Compression -is static instance configuration rather than per-use-case runtime policy. - -DialCache compresses a serialized payload only when it meets the threshold and -the zstd frame plus its marker is smaller than the raw stored form. Reads -always decode marked payloads, even when writes use `compression: false`, so -turning compression off does not orphan entries already written compressed. - -Raw binary serializer output beginning with an envelope byte is escaped on -every write, including when compression is disabled. - -Compression and decompression run synchronously on the Node.js event loop. -The exact package engine range is `>=22.15.0 <23.0.0 || >=23.8.0` so -`node:zlib` exposes zstd. - -Decompressed payloads are capped at 512 MiB, and the write side refuses to -compress values above the same ceiling. Start with the default level, watch -compression duration and ratio metrics, and treat higher levels as a -latency-sensitive production change. +Compression is instance-wide write policy under `redis.compression`, rather +than a runtime use-case setting. The default uses zstd level 3 for serialized +payloads of at least 4,096 bytes, and selects compression only when it saves +space. `false` disables compression for new writes; reads still decode existing +compressed frames. -See [Redis payload compression](redis.md#compression) for the exact envelope, -mixed-version rollout and rollback behavior, binary-serializer migration, and -failure semantics. See [Observability](observability.md#compression-metrics) -for the bounded outcomes and pre- versus post-compression measurements. +See [Compression](redis.md#compression) for options, validation, synchronous +CPU cost, binary escaping, size limits, and mixed-version compatibility. The +[API table](api.md#redisconfig) provides the defaults in one place. ## Request-local cache @@ -634,7 +453,8 @@ The outermost `enable()` call owns the request-local lifetime; nested `enable()` calls reuse the same scope. State is allocated lazily, so scopes that use only process-local or remote caching do not allocate it. -Wrap the complete Node HTTP handler so the scope matches the request: +Wrap the complete Node HTTP handler so the scope matches the request. Here, +`readUserId` and `handleRequestError` are application-provided functions: ```ts import { createServer } from "node:http"; @@ -659,7 +479,8 @@ jobs into smaller scopes. The process-local layer, `CacheLayer.LOCAL`, uses one LRU per `DialCache` instance. It keeps at most 10,000 entries by default across all use cases while -retaining each entry's configured TTL. +retaining each entry's insertion TTL. Reading an entry updates its LRU +position but does not extend its TTL. Set `localMaxSize` to a nonnegative safe integer to change the global entry cap. `0` disables process-local storage: diff --git a/docs/getting-started.md b/docs/getting-started.md index 044122e..3e0afe1 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -18,7 +18,7 @@ The package provides ESM and CommonJS entry points and TypeScript declarations. Create one long-lived instance for the service and register reusable readers once. The function you wrap is the source loader: DialCache invokes it whenever -the active cache layers cannot supply a value. +the active cache layers cannot supply a value. Save this as `example.mts`: ```ts import { CacheLayer, DialCache, DialCacheKeyConfig } from "dialcache"; @@ -50,8 +50,23 @@ await getUser("123"); console.log(sourceReads); // 2: caching is off outside enable(). ``` -Save this as `example.ts` and run it with your project's TypeScript runner. -Replace `fetchUser` with the real read when integrating it into your service. +Run it directly with Node; no TypeScript runner is needed: + +```bash +node --experimental-strip-types example.mts +``` + +It prints: + +```text +1 +2 +``` + +The `.mts` extension selects ESM, so top-level `await` works even in a project +that otherwise uses CommonJS. The flag removes TypeScript annotations; use your +project's TypeScript compiler for typechecking. Replace `fetchUser` with the +real read when integrating the example into your service. The wrapper preserves the input parameters and always returns a `Promise`. `keyType` identifies the entity kind; `useCase` identifies the operation. @@ -138,6 +153,10 @@ cohort of keys and inherits the 60-second TTL. It is not a traffic percentage. The disabled overlay stops new cache use and shadow admission; it does not cancel work already in flight. +Changing a TTL also has different effects on existing local and Redis entries. +Read [Changing policy on a running service](configuration.md#changing-policy-on-a-running-service) +before using a runtime change to tighten freshness. + ## Add shared caching when needed Install a supported client, connect it, and pass its DialCache adapter in diff --git a/docs/index.md b/docs/index.md index f8a2911..5582c27 100644 --- a/docs/index.md +++ b/docs/index.md @@ -33,10 +33,22 @@ and integration contracts. | [Upgrading](upgrading.md) | Protocol cutovers, longer Redis retention, serializer compatibility, and metric migrations | | [Maintainer guide](maintainers.md) | Local validation, documentation, benchmarks, and releases | -These pages describe the code on this branch, based on `v0.23.2`. Documentation -on `main` follows the repository; use the matching -[release tag](https://github.com/lan17/DialCache/tags) when checking an older -installation. The package's TypeScript declarations are the exact type source. +## Find an answer + +| Question | Start here | +| --- | --- | +| Why is my loader still running? | [Enabled scopes](configuration.md#enable-and-disable-scopes), [layer policy](configuration.md#baseline-and-overlay-precedence), and [miss reasons](observability.md#miss-reasons) | +| Why did changing a TTL leave an old value in cache? | [Policy changes and existing entries](configuration.md#changing-policy-on-a-running-service) | +| Why can I still see a value after invalidation? | [In-memory publication](invalidation.md#in-memory-publication) and [recovery races](stale-on-error.md) | +| Why are callers sharing a timeout or result? | [What followers inherit](coalescing.md#what-followers-inherit) | +| What can still wait after the source deadline? | [Application-owned budgets](coalescing.md#application-owned-budgets) | +| Why is shadow validation doing no work? | [Shadow eligibility](shadow-validation.md#eligibility) and [custom metrics hooks](observability.md#custom-adapters) | + +The published site follows `main`, which may be ahead of the npm package. For an +installed version, consult its [release notes](https://github.com/lan17/DialCache/releases) +and the README or reference at the matching +[release tag](https://github.com/lan17/DialCache/tags). The package's TypeScript +declarations are the exact type source. [Project overview](https://github.com/lan17/DialCache#readme) · [npm package](https://www.npmjs.com/package/dialcache) diff --git a/docs/invalidation.md b/docs/invalidation.md index a4c5121..a358035 100644 --- a/docs/invalidation.md +++ b/docs/invalidation.md @@ -95,9 +95,12 @@ carry a valid watermark and suppress a refill. A `watermark_fenced` miss can later refill if the timestamp advances beyond that watermark. A miss without an observed fence follows the normal write path. -These checks never delay the returned fallback value. They do not establish a -transaction with a later invalidation: the watermark can advance after the read -and fence an admitted write. +A fenced refill is skipped immediately; the call does not wait for the watermark +to pass. An admitted refill still awaits serialization and the Redis write +before returning the fallback value, so those operations need +[application-owned budgets](coalescing.md#application-owned-budgets). +The checks do not establish a transaction with a later invalidation: the +watermark can advance after the read and fence an admitted write. ### In-memory publication diff --git a/docs/maintainers.md b/docs/maintainers.md index 6d76add..3a36a7a 100644 --- a/docs/maintainers.md +++ b/docs/maintainers.md @@ -46,8 +46,12 @@ application-provided dependencies. The npm tarball contains `README.md` but not `docs/`. README links to the hosted reference therefore use absolute URLs. Reference pages use relative Markdown links so they work in a checkout, on GitHub, and in the documentation site. -Before publishing, check file/anchor targets and parse TypeScript examples; -execute the self-contained getting-started example as well. +Before publishing, check file/anchor targets and parse TypeScript examples. +`test:package` extracts the first TypeScript block from both the README and +getting-started guide, typechecks it against the installed tarball, and executes +it with the documented Node command. CI also runs this check at Node.js 22.15.0. +Keep those blocks self-contained; when their demonstrated output changes, +update the expectation in `scripts/test-package.mjs`. ### Run the documentation site diff --git a/docs/upgrading.md b/docs/upgrading.md index f53f2c1..d545cb2 100644 --- a/docs/upgrading.md +++ b/docs/upgrading.md @@ -6,7 +6,7 @@ Check the installed version and the behavior being enabled before sharing a Redis namespace across releases. A compatible frame layout does not by itself make two implementations safe to run together. -This guide covers the transitions relevant to the current `v0.23.2` reference. +This guide covers the transitions relevant to the implementation on `main`. Use the [release notes](https://github.com/lan17/DialCache/releases) and matching tagged source for the versions in your fleet. diff --git a/scripts/test-package.mjs b/scripts/test-package.mjs index 026f77f..0fb7789 100644 --- a/scripts/test-package.mjs +++ b/scripts/test-package.mjs @@ -1,5 +1,5 @@ import { execFile } from "node:child_process"; -import { mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -12,6 +12,20 @@ const fallbackTimeoutMarker = "dialcache-fallback-timeout-delivered"; const nodeInvalidationMarker = "dialcache-node-invalidation-retry-verified"; const observerIsolationMarker = "dialcache-observer-rejections-isolated"; const shadowPayloadReleaseMarker = "dialcache-shadow-payload-released"; +// Run the actual first TypeScript block from each onboarding page, without a +// separately maintained copy that could keep passing after the docs break. +const documentationExamples = [ + { + source: "README.md", + filename: "readme-example.mts", + stdout: "Loading from source: 123\nLoading from source: 123\n", + }, + { + source: "docs/getting-started.md", + filename: "getting-started-example.mts", + stdout: "1\n2\n", + }, +]; const packedInvalidationCheckSource = String.raw` function createPackedNodeRedisInvalidationAdapter(nodeRedis, dispatch, label) { const client = { @@ -995,8 +1009,20 @@ try { writeFile(join(workspace, "root-consumer.cts"), rootConsumer), writeFile( join(workspace, "tsconfig.root.json"), - typescriptConfig(["root-consumer.mts", "root-consumer.cts"]), + typescriptConfig([ + "root-consumer.mts", + "root-consumer.cts", + ...documentationExamples.map((example) => example.filename), + ]), ), + ...documentationExamples.map(async (example) => { + const markdown = await readFile(join(root, example.source), "utf8"); + const code = markdown.match(/^```ts\r?\n([\s\S]*?)^```\s*$/m)?.[1]; + if (code === undefined) { + throw new Error(`${example.source} has no runnable TypeScript example`); + } + await writeFile(join(workspace, example.filename), code); + }), ]); const { stdout: esmRootRuntimeOutput } = await exec( @@ -1750,6 +1776,17 @@ void (async () => { { cwd: workspace }, ); + for (const example of documentationExamples) { + const { stdout } = await exec( + process.execPath, + ["--experimental-strip-types", example.filename], + { cwd: workspace, timeout: 10_000 }, + ); + if (stdout !== example.stdout) { + throw new Error(`${example.source} produced unexpected output: ${JSON.stringify(stdout)}`); + } + } + await exec( "npm", [ From a22c85490e343616e387c9880e77ad6f7675302c Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sat, 5 Sep 2026 22:50:29 -0700 Subject: [PATCH 10/31] docs: distinguish operation budgets from the read path --- docs/coalescing.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/coalescing.md b/docs/coalescing.md index 8cb3d96..52e7e96 100644 --- a/docs/coalescing.md +++ b/docs/coalescing.md @@ -4,9 +4,9 @@ By default, DialCache shares same-key in-flight work within a request or a `DialCache` instance, according to the active layers. A per-use-case policy can -disable that sharing. Each active remote read has a finite deadline, and a -separate default deadline begins -when an initially enabled invocation starts its fallback loader. +disable that sharing. Each active remote read has a finite deadline. A separate +default deadline begins when an initially enabled invocation starts its fallback +loader. These mechanisms reduce duplicate source work. Their deadlines help flights settle, but eventual cleanup still requires finite application-owned budgets @@ -255,8 +255,8 @@ The timer starts only when the fallback begins: ### Application-owned budgets -The source deadline is not a total-call timeout. An enabled miss can pass through -each of these stages before returning: +The source deadline is not a total-call timeout. Each operation has its own +settlement boundary: | Stage | Settlement budget | | --- | --- | From 9c3d69469a81ff710021be5abfc361cc78dd2244 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sat, 5 Sep 2026 23:19:29 -0700 Subject: [PATCH 11/31] docs: correct invalidation freshness and shared anchors --- README.md | 9 +++-- docs/api.md | 10 +++-- docs/coalescing.md | 6 +++ docs/concepts.md | 9 +++-- docs/configuration.md | 2 +- docs/invalidation.md | 40 ++++++++++++++++---- docs/stale-on-error.md | 5 ++- test/dialcache-coalescing.test.ts | 62 ++++++++++++++++++++++++++++++- 8 files changed, 122 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 706816d..ebb9ff2 100644 --- a/README.md +++ b/README.md @@ -186,10 +186,11 @@ cancel shared work. The reference explains ## Freshness is a policy you choose For mutable data, opt a reader into **targeted Redis invalidation** and advance -its entity watermark after the source mutation commits. The next tracked Redis -read checks the value and watermark together. Existing in-memory values have -their own lifetimes, so use the remote layer alone when reads must observe that -fence. +its entity watermark after the source mutation commits. A tracked Redis read +checks the value and watermark together. In-memory hits and coalesced callers +can reuse an earlier observation. The +[invalidation guide](https://lan17.github.io/DialCache/invalidation.html#independent-fence-checks) +shows how to give each invocation its own fence check. For selected source failures, **stale-on-error** can return a retained Redis snapshot within a maximum age. It is off by default; when enabled, its built-in diff --git a/docs/api.md b/docs/api.md index 27008b7..70b1bd9 100644 --- a/docs/api.md +++ b/docs/api.md @@ -20,9 +20,10 @@ package's declarations provide the full generic signatures. Optional integrations use their own import paths. The application installs and owns the corresponding client or metrics registry. -## `new DialCache(options?)` +## Constructor -Construct one instance for each intended local-cache and coalescing boundary. +`new DialCache(options?)` constructs one instance for each intended local-cache +and coalescing boundary. With no options, it supports in-memory caching and uses a disabled baseline. Enable a scope **and** configure at least one layer to store values. @@ -169,7 +170,10 @@ stringified and the buffer is a nonnegative safe integer, at most `31_536_000_000` milliseconds. It affects tracked Redis entries across use cases and argument variants in the -same namespace. It does not evict in-memory or untracked Redis values. Missing +same namespace. It does not evict in-memory or untracked Redis values, revoke +acquired snapshots, or clear in-flight work. See +[Independent fence checks](invalidation.md#independent-fence-checks) when each +invocation must observe invalidation separately. Missing Redis configuration and mutation failures reject; the method works outside an enabled scope. Choose the buffer from the [clock and in-flight-work contract](invalidation.md#choosing-futurebufferms). diff --git a/docs/coalescing.md b/docs/coalescing.md index 52e7e96..0655dda 100644 --- a/docs/coalescing.md +++ b/docs/coalescing.md @@ -91,6 +91,12 @@ options under one key. Keep their value meaning and serialization consistent, and make execution independent when inheriting another caller's deadline, failure, or cancellation behavior would be incorrect. +`invalidateRemote()` does not clear existing flights. A caller arriving after +invalidation can join a leader that read Redis before invalidation, even when +only tracked remote caching is enabled. Use `coalesce: false` when each caller +needs its own watermark observation; see +[Independent fence checks](invalidation.md#independent-fence-checks). + ### Per-use-case opt-out `DialCacheKeyConfig.coalesce` is a sparse runtime boolean whose effective diff --git a/docs/concepts.md b/docs/concepts.md index 8ae4693..37e30b7 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -88,9 +88,12 @@ applies to new writes; existing local values retain their insertion TTL. Redis reads classify frame age using the current remote policy. See [Policy changes and existing entries](configuration.md#changing-policy-on-a-running-service). -For reads that must consult an entity's invalidation fence, enable only tracked -remote caching. The watermark contract additionally depends on bounded -in-flight work, application clock skew, and preservation of watermark state. +For each invocation to make its own invalidation-fence check, enable only tracked +remote caching and set `coalesce: false`. Otherwise, a caller can join work that +read Redis before invalidation and reuse that earlier observation. Invalidation +does not cancel existing flights. The watermark contract additionally depends +on bounded in-flight work, application clock skew, and preservation of watermark +state; see [Independent fence checks](invalidation.md#independent-fence-checks). Stale-on-error deliberately permits reuse of a snapshot acquired before the source attempt. Later invalidation does not revoke that retained snapshot. diff --git a/docs/configuration.md b/docs/configuration.md index 2a85017..f191dd0 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -182,7 +182,7 @@ expire by TTL. ## Runtime config and ramp controls The constructor supplies shared resources and instance defaults. See -[`DialCacheConfig`](api.md#new-dialcache-options) for its options. +[`DialCacheConfig`](api.md#constructor) for its options. `DialCacheKeyConfig` supplies the baseline and per-invocation overlay: layer TTLs and ramps, request-local caching, coalescing, remote-read timeout, stale-recovery age, and shadow policy. diff --git a/docs/invalidation.md b/docs/invalidation.md index a358035..fcfbc9a 100644 --- a/docs/invalidation.md +++ b/docs/invalidation.md @@ -6,9 +6,11 @@ Use targeted invalidation when a source mutation should invalidate every tracked Redis result for an entity. A single entity watermark covers all its tracked use cases and argument variants in the same namespace, without scanning keys. -Invalidation is remote-only. For reads that must consult the watermark, keep -request-local and process-local caching disabled. Already-cached in-memory -values do not consult Redis and cannot be revoked by this operation. +Invalidation is remote-only. In-memory hits and callers joining an existing +flight can reuse a value without a new Redis read. For an independent watermark +observation on each invocation, disable request-local and process-local caching, +set `coalesce: false`, and leave stale recovery off. See +[Independent fence checks](#independent-fence-checks) for the exact boundary. ## Configure a tracked use case @@ -32,6 +34,7 @@ const getUser = dialcache.cached( trackForInvalidation: true, defaultConfig: new DialCacheKeyConfig({ ttlSec: { [CacheLayer.REMOTE]: 300 }, + coalesce: false, // Each invocation performs its own tracked read. }), }, ); @@ -238,9 +241,32 @@ and cannot shorten a longer/persistent marker. A rejected dispatched mutation can have executed, so an error does not prove absence of a watermark change. See [Redis retries](redis.md#invalidation-retries-and-ambiguity). +## Independent fence checks + +Invalidation changes what a subsequent tracked Redis read can accept. It does +not revoke a snapshot already read or cancel a caller-path flight. This matters +even with both in-memory layers and stale recovery disabled: + +1. A tracked Redis read acquires a valid cached value, then waits in an + asynchronous serializer. +2. A source mutation commits and `invalidateRemote()` completes. +3. A new same-key invocation joins that existing flight and receives the earlier + value without another Redis read. + +To keep later invocations from joining such work, set `coalesce: false` as in +the example above. With tracked remote caching active, request-local and +process-local caching off, and stale recovery off, a call starting after +invalidation performs its own tracked read or falls back to the source on cache +failure. Keep those effective settings in runtime overlays as well as defaults. + +Already-started invocations can still finish with their acquired snapshots. +The source must supply authoritative reads, and the clock, buffer, and watermark +durability requirements still apply. This policy does not cancel work or create +a transaction between the source mutation and Redis. + ## In-memory layers remain local -For a strict remote read-after-invalidation policy, turn off both earlier layers -and leave stale recovery disabled. If local reuse or recovery is acceptable, -choose its scope and lifetime explicitly: neither remote invalidation nor -`disable()` revokes a value already held in memory. +If local reuse or recovery is acceptable, choose its scope and lifetime +explicitly: neither remote invalidation nor `disable()` revokes a value already +held in memory. Default coalescing also trades independent observations for +shared work, as described above. diff --git a/docs/stale-on-error.md b/docs/stale-on-error.md index 1234dc9..caecf5d 100644 --- a/docs/stale-on-error.md +++ b/docs/stale-on-error.md @@ -149,8 +149,9 @@ the underlying data's own last-update time. Clock skew affects the comparisons; see the [application clock contract](invalidation.md#application-clock-contract). Earlier local layers retain their own lifetimes. A nearly expired Redis hit can -warm process-local storage with a full local TTL. Disable both earlier layers -when the remote frame-age policy must govern every lookup. +warm process-local storage with a full local TTL. For each invocation to make a +new remote frame-age check, disable both earlier layers and set `coalesce: false`. +Otherwise, a follower can reuse the leader's earlier age check and snapshot. Coalesced callers share one initial read, raw candidate, source attempt, and recovery decision. With `coalesce: false`, each caller retains its own bytes and diff --git a/test/dialcache-coalescing.test.ts b/test/dialcache-coalescing.test.ts index 30b11fd..491752d 100644 --- a/test/dialcache-coalescing.test.ts +++ b/test/dialcache-coalescing.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import { CacheLayer, DialCache, DialCacheKeyConfig, type DialCacheMetricsAdapter } from "../src/index.js"; +import { CacheLayer, DialCache, DialCacheKeyConfig, JsonSerializer, type DialCacheMetricsAdapter } from "../src/index.js"; import { FakeRedis } from "./fake-redis.js"; interface Deferred { @@ -338,6 +338,66 @@ describe("DialCache request coalescing", () => { ]); }); + it.each([true, false])("uses coalescing policy for calls after tracked invalidation (coalesce=%s)", async (coalesce) => { + const redis = new FakeRedis(); + const loadStarted = deferred(); + const loadGate = deferred(); + const { metrics, coalesced } = spyMetrics(); + const json = new JsonSerializer<{ id: string; version: number }>(); + const dialcache = new DialCache({ redis: { client: redis, readTimeoutMs: 1_000 }, metrics }); + let version = 1; + const source = vi.fn(async (id: string) => ({ id, version })); + const getUser = dialcache.cached(source, { + keyType: "user_id", + useCase: "TrackedInvalidationFlight", + cacheKey: (id) => id, + trackForInvalidation: true, + defaultConfig: new DialCacheKeyConfig({ + ttlSec: { [CacheLayer.REMOTE]: 60 }, + // Exercise the default as well as the explicit opt-out. + ...(coalesce ? {} : { coalesce: false }), + }), + serializer: { + dump: (value) => json.dump(value), + load: async (payload) => { + const value = await json.load(payload); + loadStarted.resolve(); + await loadGate.promise; + return value; + }, + }, + }); + + // Warm Redis, then hold a validated old snapshot inside deserialization. + await dialcache.enable(() => getUser("1")); + source.mockClear(); + redis.mGetCalls = 0; + const beforeInvalidation = dialcache.enable(() => getUser("1")); + await loadStarted.promise; + + // A separate request starts only after the source change and invalidation. + version = 2; + await dialcache.invalidateRemote("user_id", "1", 5_000); + const afterInvalidation = dialcache.enable(() => getUser("1")); + const results = Promise.all([beforeInvalidation, afterInvalidation]); + try { + await tick(); + expect(redis.mGetCalls).toBe(coalesce ? 1 : 2); + expect(coalesced).toHaveBeenCalledTimes(coalesce ? 1 : 0); + } finally { + loadGate.resolve(); + } + + await expect(results).resolves.toEqual([ + { id: "1", version: 1 }, + { id: "1", version: coalesce ? 1 : 2 }, + ]); + expect(source).toHaveBeenCalledTimes(coalesce ? 0 : 1); + + // Once the earlier flight settles, both policies observe the watermark. + await expect(dialcache.enable(() => getUser("1"))).resolves.toEqual({ id: "1", version: 2 }); + }); + it("keeps different keys isolated while coalescing concurrent misses", async () => { const gate = deferred(); const dialcache = new DialCache(); From fa8352040401ab9bed5ef23a617b13dc86f00f83 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sat, 5 Sep 2026 23:29:47 -0700 Subject: [PATCH 12/31] docs: specify the legacy binary escape prefix --- docs/upgrading.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/upgrading.md b/docs/upgrading.md index d545cb2..0d0adc6 100644 --- a/docs/upgrading.md +++ b/docs/upgrading.md @@ -108,8 +108,8 @@ Legacy binary output can collide with envelope markers: - A legacy `0x01`/`0x02` prefix whose remaining bytes happen to be valid zstd can be decoded as compressed data. -- A legacy payload whose first two bytes are in `0x00`–`0x02` can lose its first - byte to the escape rule. +- A legacy payload beginning with `0x00` followed by `0x00`, `0x01`, or `0x02` + can lose its first byte to the escape rule. - New writers escape colliding raw binary prefixes even with compression off. Version an identity dimension, such as the use case, when a custom binary From d31acd06d7eadb93922d4a415bdca36825c07771 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sat, 5 Sep 2026 23:58:28 -0700 Subject: [PATCH 13/31] docs: surface serializer requirements during onboarding --- README.md | 4 ++++ docs/getting-started.md | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/README.md b/README.md index ebb9ff2..65248d5 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,10 @@ layer for every key inside the scope. The LRU holds at most 10,000 entries by default. In a service, place `enable()` around a read-request handler so nested readers inherit the same asynchronous scope. +Results containing `Date`, `bigint`, or other non-JSON-compatible values need an +explicit [typed serializer](https://lan17.github.io/DialCache/redis.html#typed-serializer-requirement), +even when you cache only in memory. + Prefer an inline loader? [`getOrLoad()`](https://lan17.github.io/DialCache/api.html#getorload) uses the same behavior with a direct key: diff --git a/docs/getting-started.md b/docs/getting-started.md index 3e0afe1..26fdfee 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -68,6 +68,11 @@ that otherwise uses CommonJS. The flag removes TypeScript annotations; use your project's TypeScript compiler for typechecking. Replace `fetchUser` with the real read when integrating the example into your service. +If that read returns `Date`, `bigint`, or other non-JSON-compatible values, +provide a [typed serializer](redis.md#typed-serializer-requirement). This is +required even when caching only in local memory; the linked example shows how +to preserve a `Date` through serialization. + The wrapper preserves the input parameters and always returns a `Promise`. `keyType` identifies the entity kind; `useCase` identifies the operation. `cacheKey` selects the result's identity. Include every input that can change From 9f7c9dc5622e3739cb93928e75980ede4116e82d Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sun, 6 Sep 2026 19:33:01 -0700 Subject: [PATCH 14/31] docs: complete public behavior and helper contracts --- docs/api.md | 30 +++++++------ docs/coalescing.md | 4 ++ docs/concepts.md | 9 ++++ docs/configuration.md | 89 +++++++++++++++++++++++++++++++-------- docs/invalidation.md | 12 ++++-- docs/observability.md | 56 +++++++++++++++++++----- docs/redis.md | 77 ++++++++++++++++++++++++--------- docs/shadow-validation.md | 16 ++++--- docs/stale-on-error.md | 5 ++- docs/upgrading.md | 22 +++++++++- 10 files changed, 249 insertions(+), 71 deletions(-) diff --git a/docs/api.md b/docs/api.md index 70b1bd9..6077007 100644 --- a/docs/api.md +++ b/docs/api.md @@ -67,8 +67,10 @@ a remote TTL and an enabled scope. See [client setup](redis.md), Callbacks may return synchronously or asynchronously. Scope state is per instance and asynchronous call chain. Nested scopes restore prior state. The -outermost `enable()` owns request-local state; detached work becomes pass-through -after that scope closes. +outermost `enable()` owns request-local state. New invocations become pass-through +after it closes; already admitted work can finish. An invocation still awaiting +its configuration provider bypasses caching after closure but retains its enabled +fallback deadline. See [Scope lifetime](configuration.md#enable-and-disable-scopes). `DialCacheContext` is the lower-level root export with `enable`, `disable`, and `isEnabled`. A separately constructed context does not enable another @@ -128,8 +130,11 @@ uncached. values are string, number, bigint, boolean, `null`, or `undefined`; undefined arguments are omitted. See [Key design](configuration.md#keys-ids-and-extra-dimensions). -Static defaults, fallback timeout, comparator, and stale-recovery classifier are -validated/captured when registering `cached()` or invoking `getOrLoad()`. +Static defaults, fallback timeout, and stale-recovery classifier are validated +and captured when registering `cached()` or invoking `getOrLoad()`. +The comparator is captured then; its execution and synchronous boolean result +are checked only when shadow comparison runs, with failures reported as +`comparison_error`. Runtime policy is resolved per enabled invocation. These guarantees do not make the entire caller-owned options object deeply immutable; keep definitions stable. @@ -145,7 +150,7 @@ overlay. Omission inherits; it does not turn an inherited field off. | `requestLocal` | `false` | Boolean; no TTL or ramp | | `coalesce` | `true` | Boolean; affects request-local and process flights | | `remoteReadTimeoutMs` | Instance setting, then `50` | Positive safe-integer milliseconds, at most `2_147_483_647`; cannot be unbounded | -| `staleOnErrorMaxAgeSec` | Off | `0` disables; positive maximum age must exceed remote TTL and be at most `31_536_000` seconds | +| `staleOnErrorMaxAgeSec` | Off | Nonnegative safe-integer seconds; `0` disables; positive age must exceed remote TTL and be at most `31_536_000` | | `shadow.ramp` | Off | Independent finite percentage from `0` through `100` | | `shadow.logMismatches` | `false` | Boolean; controls diagnostic warning output | @@ -198,17 +203,18 @@ See [Coalescing state](coalescing.md#inspecting-process-scoped-flights). | Export | Purpose | | --- | --- | -| `DialCacheKey`, `DialCacheKeyInit` | Construct the read-only identity passed to configuration providers | +| `DialCacheKey`, `DialCacheKeyInit` | Construct an identity from string components and ordered string argument pairs; `toString()` returns its precomputed `urn` | | `normalizeArgs(record)` | Drop undefined arguments, stringify scalar values, and sort names | -| `invalidationPrefix(namespace, keyType, id)` | Build an encoded tracked-entity prefix | -| `redisClusterHashTag(value)` | Validate and wrap a Redis Cluster hash tag | +| `invalidationPrefix(namespace, keyType, id)` | Build an encoded tracked-entity prefix without braces | +| `redisClusterHashTag(value)` | Reject embedded braces and wrap the value in braces without encoding | | `Serializer` | `dump(value)` returns `string \| Buffer`; `load(payload)` returns `T`; either may return a Promise | -| `JsonSerializer` | Default JSON codec, including top-level undefined support | +| `JsonSerializer` | Default JSON codec, including top-level undefined support; both methods return Promises | `CachedValue` exposes a function's resolved result type. `ShadowComparator` and `StaleRecoveryPredicate` name the corresponding synchronous callbacks. -See [Serialization](redis.md#serialization) for the compile-time guard and -runtime round-trip limitations. +See [Direct key construction](configuration.md#constructing-keys-directly) for +defaults, encoding, and validation, and [Serialization](redis.md#serialization) +for direct codec behavior, the compile-time guard, and round-trip limitations. ## Errors @@ -218,7 +224,7 @@ runtime round-trip limitations. | `UseCaseIsAlreadyRegisteredError` | Duplicate `cached()` registration on an instance | | `UseCaseNameIsReservedError` | Either operation API uses `"watermark"` | | `FallbackTimeoutError` | Enabled source deadline; exposes `useCase` and `timeoutMs` | -| `RedisReadTimeoutError` | Remote wait deadline; logged/counted before falling back; exposes `useCase` and `timeoutMs` | +| `RedisReadTimeoutError` | Remote wait deadline; exposes `useCase` and `timeoutMs`; serving reads log/count it before fallback, while shadow reads report a job outcome | | `DialCacheRedisPayloadError` | Invalid raw Redis reply shape | | `DialCacheRedisPayloadEncodingError` | Unsupported payload encoding in a frame | | `DialCacheRedisProtocolError` | Invalid semantic mutation reply | diff --git a/docs/coalescing.md b/docs/coalescing.md index 0655dda..2b24589 100644 --- a/docs/coalescing.md +++ b/docs/coalescing.md @@ -169,6 +169,10 @@ Coalescing applies only when at least one cache layer is active and the resolved An initially enabled all-disabled call still receives the fallback deadline described below. +Layer activity follows resolved TTL/ramp policy. `localMaxSize: 0` disables +storage but does not bypass an otherwise active local layer, so concurrent calls +can still share a process flight. See [Process-local cache](configuration.md#process-local-cache). + The full constructed cache key always defines cached-value identity. Include locale, auth context, or any other input that can change the returned value, regardless of the coalescing policy. diff --git a/docs/concepts.md b/docs/concepts.md index 37e30b7..5906583 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -60,6 +60,10 @@ Successful results travel back through the layers that participated: | Redis read failure or timeout | Calls the loader without a Redis refill; only untracked keys can publish the fallback locally | | Stale-on-error recovery | Returns the retained snapshot without Redis or process-local publication | +Successful `null`, `undefined`, `false`, `0`, and `""` results are cacheable values +in every layer, not misses. Redis still requires a serializer that can round-trip +the value; the default JSON codec supports all five. + Tracked refills can be skipped when the initial read observed a watermark that already fences the replacement timestamp. That optimization still returns the loader result. It is explained in [Targeted invalidation](invalidation.md). @@ -105,6 +109,11 @@ Cache-key, configuration, and cache I/O failures generally fall through to the loader. Loader errors still reject unless an opted-in stale-recovery policy can serve a retained value. Explicit `invalidateRemote()` failures reject. +Synchronous loader throws and rejected loader promises are not memoized. Once a +failed flight settles, a later invocation can retry, including within the same +request-local scope. Successful stale recovery is the exception: it supplies a +value that can be memoized as described above. + Fail-open describes error handling; it does not provide a deadline for every dependency. DialCache bounds semantic Redis reads and enabled source fallbacks separately. Configuration providers, serializers, Redis writes, and invalidation diff --git a/docs/configuration.md b/docs/configuration.md index f191dd0..4cdb037 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -22,6 +22,8 @@ Use `cached(fn, options)` to register a reusable reader once per instance. The wrapper preserves its parameters and always returns a `Promise`. Each registration needs a unique `useCase`; duplicates throw `UseCaseIsAlreadyRegisteredError`. +Invalid static defaults or source timeouts fail before registration, so fixing +them and retrying can reuse the same name. Use `getOrLoad(load, options)` for a zero-argument loader that belongs at one call site. It runs through the same cache path, but accepts a direct `key` instead @@ -66,9 +68,13 @@ restore the previous state when their callbacks settle, and a nested `enable()` inside `disable()` can opt a smaller read region back in. Enabled state follows Node's `AsyncLocalStorage`; it is not a process-global -flag. Once the outermost `enable()` callback settles, detached asynchronous work -that inherited the old context becomes pass-through and cannot repopulate its -closed request-local state. +flag. Once the outermost `enable()` callback settles, new invocations in detached +work that inherited the old context are pass-through. Closure does not cancel +already admitted cache operations: they can finish and publish to shared layers +under their normal policy and deadline rules, but cannot repopulate the closed +request-local state. An invocation still awaiting its configuration provider +when the scope closes skips cache lookup and runs its loader with the fallback +deadline it acquired while enabled. The root-exported `DialCacheContext` exposes the lower-level `enable()`, `disable()`, and `isEnabled()` context primitive. It does not attach @@ -143,6 +149,12 @@ characters for Redis Cluster hash tags. request-local or process-local entries. - **`args` are part of the cache key.** Different arguments produce different entries, but targeted invalidation is by id rather than by argument. +- **Components are encoded with `encodeURIComponent`.** Delimiters inside an + id, argument, or use case do not become structural separators. Namespace braces + always throw `TypeError`; tracked `keyType` and `id` also reject `{` and `}` + with `Error`. Untracked `keyType` and `id` may contain braces, which are encoded. + Automatic key-construction failures follow the normal + [fail-open path](concepts.md#fail-open-and-liveness). - **Scalar equality is string-based.** For matching surrounding dimensions: - numeric `1`, string `"1"`, and bigint `1n` identify the same key; and - argument values `null` and `"null"` match, `-0` matches `0`, and an @@ -162,6 +174,50 @@ characters for Redis Cluster hash tags. `(...args) => obj.method(...args)`; a bare `obj.method` reference loses `this`. +### Constructing keys directly + +`cached()` and `getOrLoad()` stringify ids and normalize argument records for +you. Custom integrations can construct the same public shape with +`new DialCacheKey(init)`: + +| `DialCacheKeyInit` field | Default or requirement | +| --- | --- | +| `keyType`, `id`, `useCase` | Required strings | +| `namespace` | `"urn"` | +| `args` | Empty array; otherwise ordered, read-only `[string, string]` pairs | +| `defaultConfig`, `serializer` | `null` | +| `trackForInvalidation` | `false` | + +The direct constructor uses argument pairs in the supplied order. It does not +normalize or sort them. Use `normalizeArgs(record)` to omit undefined values, +convert the remaining scalar values with `String`, and sort names by JavaScript +string comparison: + +```ts +import { DialCacheKey, normalizeArgs } from "dialcache"; + +const key = new DialCacheKey({ + namespace: "app:prod", + keyType: "user_id", + id: "a/b", + useCase: "Read#User", + args: normalizeArgs({ z: 2, a: 1, omitted: undefined }), + trackForInvalidation: true, +}); + +key.prefix; // "{app%3Aprod:user_id:a%2Fb}" +key.toString(); // "{app%3Aprod:user_id:a%2Fb}?a=1&z=2#Read%23User" +``` + +`prefix` and `urn` are computed once; `toString()` returns `urn`. The constructor +retains supplied argument, config, and serializer references. Read-only types +do not deep-freeze these inputs; treat the key and its inputs as immutable. + +`invalidationPrefix(namespace, keyType, id)` validates the same tracked identity +components and returns the encoded prefix **without** braces. +`redisClusterHashTag(value)` rejects embedded braces and adds a literal pair of +braces; it does not encode the value. Neither helper adds arguments or a use case. + ### Changing a namespace Changing `namespace` intentionally creates a cold-cache boundary across every @@ -243,6 +299,12 @@ Invalid instance options throw during construction. Invalid `defaultConfig` leaves throw when `cached()` registers a definition or `getOrLoad()` is invoked. The [API reference](api.md#dialcachekeyconfig) lists field types and bounds. +`new DialCacheKeyConfig(...)` first validates object/map/group shapes, +`requestLocal`, `coalesce`, and `remoteReadTimeoutMs`, and copies the supplied +maps and shadow group. TTL, ramp, recovery-age, and shadow leaves are validated +later, at static-default capture or runtime resolution. Constructing a config +object alone therefore does not establish that all its leaves are valid. + Each registration or inline invocation captures an immutable baseline snapshot, including nested maps and shadow policy. Mutating the original config later does not update that baseline. Use the provider for runtime changes. @@ -396,19 +458,9 @@ Use the identity fields to select policy; do not derive policy names or metric dimensions from unbounded user input. The provider result remains a sparse overlay and must not mutate the key. -Most applications do not construct keys directly. Custom integrations can use -the root exports: - -- `new DialCacheKey(init)` to build the same public key shape; -- `normalizeArgs(record)` to omit `undefined`, stringify scalar values, and - sort argument names; -- `invalidationPrefix(namespace, keyType, id)` to build the encoded tracked - identity; and -- `redisClusterHashTag(value)` to wrap a validated value in a Redis Cluster hash - tag. - -The namespace and hash-tag components reject `{` and `}` as described under -[Identity rules](#identity-rules). +See [Constructing keys directly](#constructing-keys-directly) for the public +helpers and the difference between normalized provider keys and manually +supplied argument pairs. ## Redis payload compression @@ -483,7 +535,10 @@ retaining each entry's insertion TTL. Reading an entry updates its LRU position but does not extend its TTL. Set `localMaxSize` to a nonnegative safe integer to change the global entry cap. -`0` disables process-local storage: +`0` disables process-local storage. With a valid local TTL and selected ramp, +that path still records misses and can coalesce concurrent calls within the +instance; sequential calls still miss this layer. Set the local ramp to `0` to +bypass the layer, or use `coalesce: false` to prevent in-flight sharing: ```ts const dialcache = new DialCache({ localMaxSize: 25_000 }); diff --git a/docs/invalidation.md b/docs/invalidation.md index fcfbc9a..e78032c 100644 --- a/docs/invalidation.md +++ b/docs/invalidation.md @@ -209,11 +209,16 @@ max(existing remaining TTL, watermark − invalidatedAtMs + 1 hour + 1 minute) ``` -An existing persistent watermark stays persistent. Reads and value writes do -not extend it. Under the clock and in-flight-work contract, the marker outlives +An existing persistent string watermark stays persistent. Reads and value writes +do not extend it. Under the clock and in-flight-work contract, the marker outlives every value it can fence. The fixed minute is retention slack; it does not replace a complete `Dmax` bound. +Invalidation repairs malformed string watermarks from a zero baseline while +preserving a longer remaining TTL or persistence. A wrong-type key is instead +treated as absent and replaced with a finite, derived TTL, even if that key was +persistent. Other Redis read errors surface without replacing the prior state. + Changing the tracked-value cap or watermark floor requires another coordinated protocol transition: new constants cannot extend markers an older invalidator already wrote. See [Upgrading](upgrading.md#tracked-protocol-cutover). @@ -237,7 +242,8 @@ rethrown. The operation metric uses `keyType` and namespace; it does not attach an entity id to labels. Adapter retries reuse the original invalidation timestamp, preserve monotonicity, -and cannot shorten a longer/persistent marker. A rejected dispatched mutation +and cannot shorten a longer/persistent string marker. Wrong-type repair follows +the exception above. A rejected dispatched mutation can have executed, so an error does not prove absence of a watermark change. See [Redis retries](redis.md#invalidation-retries-and-ambiguity). diff --git a/docs/observability.md b/docs/observability.md index 253761d..14fa4f2 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -80,13 +80,27 @@ app.get("/metrics", async (_req, res) => { The adapter requires a caller-owned `Registry`. It never uses the global default registry, and it does not clear or otherwise own the registry -lifecycle. +lifecycle. `prefix` defaults to `""` and is concatenated literally with each +metric name; include any desired separator yourself. Multiple adapters with the same registry and prefix reuse existing collectors when their type, help, labels, histogram buckets, and exemplar mode match. Adapter construction fails before registering anything if a same-name collector has an incompatible schema. Use a unique prefix or separate registry -to resolve a collision. +to resolve a collision. DialCache's collectors do not enable exemplars, so an +exemplar-enabled collector with the same name is incompatible. + +### Histogram buckets + +Bucket boundaries are fixed; the adapter has no bucket customization option: + +| Metric family | Unit | Finite bucket boundaries | +| --- | --- | --- | +| All timers | Seconds | `0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10` | +| Serialized and stored sizes | Bytes | `100, 1000, 10000, 100000, 1000000, 10000000` | +| Compression ratio | Ratio | `0.05, 0.1, 0.2, 0.3, 0.5, 0.7, 0.9, 1` | +| Shadow and recovery value ages | Seconds | `1, 5, 15, 60, 300, 900, 3600, 10800, 43200, 86400, 259200, 604800` | +| Future timestamp offsets | Seconds | `0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 5, 15, 60, 300, 900, 3600, 10800, 43200` | ### Prometheus metrics @@ -191,7 +205,9 @@ function shutdown(): void { ``` `hot-shots` is the supported and tested client, but the adapter depends only on -the exported `DatadogDogStatsDClient` structural interface. +the exported `DatadogDogStatsDClient` structural interface. Construction requires +all three methods, `increment`, `histogram`, and `distribution`, to be functions, +regardless of the selected observation mode. DialCache does not: @@ -269,11 +285,13 @@ and bytes without unit conversion: | `dialcache.compression.ratio` | Distribution or histogram | `cache_namespace`, `use_case`, `key_type`, `layer` | Compressed-to-original payload size ratio for compressed writes | | `dialcache.compression.duration` | Distribution or histogram | `cache_namespace`, `use_case`, `key_type`, `layer`, `operation` | Payload compression and decompression latency in seconds | -Client throws and rejected returned thenables are isolated by DialCache's -fire-and-forget observer boundary. Buffered transport failures that happen -after the client call returns remain outside that boundary. Configure the -DogStatsD client's error handling and shutdown behavior as part of application -ownership. +Synchronous client throws are isolated when DialCache invokes the adapter. +Core also consumes thenables returned by adapter hooks, but this adapter does +not forward every client return value: only `shadowValidation` and +`staleRecovery` return the counter call's result. A custom DogStatsD client must +handle its own asynchronous delivery failures, including rejected promises. +Direct adapter calls do not have core's observer guard. Configure client error +handling and shutdown as part of application ownership. ## Shadow outcomes @@ -324,12 +342,20 @@ emits no recovery outcome. See [Stale-on-error](stale-on-error.md). Shadow value age is reported only for `match` and confirmed `mismatch`, at verdict time. Recovery age is reported only for `served`, at return time. Both use the observing application's epoch clock minus the frame's writer timestamp. +Shadow age uses the original `C0` timestamp, even if confirmation finds identical +payload bytes with a newer timestamp. It clamps to zero after clock rollback and +skips nonfinite age observations. The future-offset histogram records a positive offset for valid decoded frames ahead of the observer clock. Ordinary and initial-shadow reads then miss; confirmation can retain the frame only for comparison. Invalid timestamps never enter histogram sums. Repeated reads can observe the same future frame. +For direct adapter callers, Prometheus additionally discards nonfinite or +nonpositive `observeFutureTimestampOffset` values. Datadog forwards those +observations without that extra guard; normal core calls supply positive finite +offsets to both. + Use external fleet clock monitoring as well: workload observations cannot detect every skew direction or determine which node is wrong. Its dedicated histogram buckets cover millisecond-scale through multi-hour faults. @@ -435,12 +461,18 @@ invalidation attempt: DialCache records `dialcache_invalidation_counter` (or `error="invalidation"`, and rejects with the original focused `TypeError`. Invalid `futureBufferMs` input is rejected before these observers run. -Remote-read timeouts use `layer="remote"` and `in_fallback="false"`. They are +Caller-serving remote-read timeouts use `layer="remote"` and +`in_fallback="false"`. They are errors rather than misses, and the remote get-duration observation includes the wait. Coalesced followers do not multiply the timeout error. Deadline details remain out of labels and are available on the logged `RedisReadTimeoutError`. +Detached initial and confirmation reads attribute their operational metrics to +`remote_shadow`. Read failures, including read timeouts, can report `redis_error` +or `confirmation_error` without a matching error log. The overall shadow deadline +instead reports `timeout`; see [Shadow outcomes](#shadow-outcomes). + Raw thrown values, error names, messages, cache ids, arguments, and Redis keys are never included in labels. When DialCache logs a cache-plumbing failure, the raw details remain available through the configured logger; not every metric @@ -498,8 +530,10 @@ Metrics and logger methods are typed `void` and invoked as fire-and-forget observers. DialCache also defensively consumes, but never awaits, a thenable returned at runtime. -Synchronous throws and asynchronous rejections are isolated so telemetry -cannot change cache correctness, fallback results, or shadow outcomes. +Synchronous throws and rejections of those returned thenables are isolated so +telemetry cannot change cache correctness, fallback results, or shadow outcomes. +This guard applies when core invokes the observer, not to direct calls to an +adapter or to asynchronous work whose promise the hook does not return. A custom adapter may buffer or transmit asynchronously, but it owns delivery, flushing, resources, and shutdown after the call returns. Keep diff --git a/docs/redis.md b/docs/redis.md index ab932db..96f703d 100644 --- a/docs/redis.md +++ b/docs/redis.md @@ -228,6 +228,13 @@ be omitted; undefined array elements and non-finite numbers can become `null`. Dates lose their type, maps and sets lose their structure, and bigint or cycles can fail serialization. Reference sharing and prototypes are not preserved. +Direct `JsonSerializer.dump(value)` calls return `Promise`; +`load(string | Buffer)` returns `Promise`, decoding Buffer input as UTF-8. +Malformed JSON rejects with `SyntaxError`. Top-level functions or symbols reject +with `Error` because native JSON produces no payload; bigint and cycles normally +reject with native `TypeError`. The generic `T` is a caller assertion, not schema +validation. + A fresh frame whose `load` fails becomes a refreshable miss: core records `serialization_load`, calls the source, and attempts replacement. The default codec validates JSON syntax, not your application schema. For incompatible @@ -305,10 +312,13 @@ levels trade CPU and latency for size reduction. Use the size, ratio, and duration [metrics](observability.md#compression-metrics) to evaluate that tradeoff. Decompressed output is capped at 512 MiB. Writes above the same ceiling remain -raw (`write_over_limit`). Corrupt marked input or output above the read limit -is handed to the serializer as raw input (`fallback_raw` or `read_over_limit`); -a permissive custom binary serializer must not mistake that data for a valid -application value. A compression exception fails the write open. +raw (`write_over_limit`). When native zstd rejects marked input, core hands the +original bytes to the serializer (`fallback_raw`, or `read_over_limit` when the +output limit caused rejection). Native decoder acceptance is not corruption +validation: it can accept empty or truncated bodies as empty output and ignore +trailing bytes. A custom serializer must validate the application value it +receives, whether decompressed or raw. A compression exception fails the write +open. See [Upgrading](upgrading.md#compression-and-value-schemas) for legacy binary collisions and readers-first deployment of the envelope. @@ -355,16 +365,16 @@ write/invalidation deadlines. The protocol subpath exports: -| Export | Role | +| Export | Contract | | --- | --- | -| `encodeRedisFrame(payload, createdAtMs)` | Encode a complete version-1 frame | -| `decodeRedisReadResult(raw)` | Decode an untracked bulk-string reply into a frame or classified miss | -| `decodeTrackedRedisReadResult(raw, rawWatermark)` | Decode the atomic tracked pair and preserve a valid observed fence on misses | -| `isRedisReadMiss(result)` | Discriminate a semantic miss | -| `INVALIDATE_CACHE_SCRIPT` | Source of the only Lua operation | -| `validateRedisSetReply(reply)` | Accept the native `OK` reply domain | -| `validateRedisScriptInvalidationReply(reply)` | Require integer `1` | -| `ceilSupportedCacheTtlMs(value)` | Round a positive fractional millisecond TTL up, rejecting unsupported values | +| `encodeRedisFrame(payload, createdAtMs)` | Copy a `string \| Buffer` into a new version-1 Buffer; timestamp must be a nonnegative safe-integer number or it throws `RangeError` | +| `decodeRedisReadResult(raw)` | Decode one `Buffer \| null` reply into a frame or classified miss | +| `decodeTrackedRedisReadResult(raw, rawWatermark)` | Decode an atomic pair of `Buffer \| null` replies and preserve a valid observed fence on misses | +| `isRedisReadMiss(result)` | Test for a non-null object with `kind === "miss"`; does not validate its reason or watermark | +| `INVALIDATE_CACHE_SCRIPT` | Lua source; one watermark key and arguments `[futureBufferMs, invalidatedAtMs]`; returns numeric `1` | +| `validateRedisSetReply(reply)` | Accept exactly `"OK"` or a Buffer decoding to `"OK"`; return void, otherwise throw `DialCacheRedisProtocolError` | +| `validateRedisScriptInvalidationReply(reply)` | Accept and return numeric `1` only; otherwise throw `DialCacheRedisProtocolError` | +| `ceilSupportedCacheTtlMs(value)` | Accept a number whose ceiling is in `1..31_536_000_000` ms; return that ceiling, otherwise throw `RangeError` | `CacheMissReason`, `DecodedRedisFrame`, `RedisReadMiss`, and `RedisReadResult` are also exported as types from this subpath. @@ -374,15 +384,44 @@ A stored value has a ten-byte header followed by payload: | Bytes | Meaning | | --- | --- | | `0` | Version `1` | -| `1..8` | Big-endian unsigned 64-bit application epoch timestamp, within the JavaScript safe-integer domain | +| `1..8` | Big-endian unsigned 64-bit application epoch timestamp; writers must stay within the JavaScript safe-integer domain | | `9` | Encoding: `0` UTF-8 string, `1` binary | | `10..` | Payload, possibly a compression envelope | -Short or unsupported frames miss. Invalid bulk-string reply types and -unsupported payload encodings are typed errors. For tracked values, missing -watermarks mean zero; malformed present watermark metadata makes a present -frame an `unclassified` miss. Only an otherwise supported positive-timestamp -frame rejected at or below a valid watermark is `watermark_fenced`. +### Read decoding and validation order + +Both decoders reject invalid raw reply types, including JavaScript strings, with +`DialCacheRedisPayloadError`. The tracked decoder validates both reply types +before classifying either value. Binary payloads are views into the input frame; +copy them if the backing Buffer may be mutated or reused. + +After reply validation, a null value is `value_absent`; a short frame or unknown +version is `unclassified`. Either tracked miss can preserve a valid paired +watermark. Watermark text must contain decimal digits only and represent a value +from zero through `Number.MAX_SAFE_INTEGER`. Zero and leading zeros are accepted; +signs, whitespace, fractions, and exponent notation are not. A missing watermark +uses a zero baseline and does not attach `observedWatermarkMs`. + +For a supported tracked frame, malformed present watermark text produces +`unclassified`. A zero frame timestamp also produces `unclassified`. A positive +timestamp at or below a valid watermark produces `watermark_fenced`. These +checks precede payload decoding, so even an unknown encoding can be hidden by +one of these misses. An otherwise eligible frame with an unsupported encoding +throws `DialCacheRedisPayloadEncodingError`. + +The untracked decoder accepts a zero timestamp. Both decoders convert the raw +uint64 to a JavaScript number without rejecting unsafe values, which can lose +precision. Core separately rejects unsafe timestamps and applies its +[age and clock rules](observability.md#value-ages-and-clock-offsets); the codecs +alone do not establish that a decoded frame is fresh or safe to serve. + +### Invalidation script and payload envelope + +The script requires digit-only decimal arguments in the nonnegative safe-integer +domain. The buffer must be at most `31_536_000_000` ms, and timestamp plus buffer +must remain safe. Invalid arguments return Redis errors before any mutation. +Its repair and retention rules are covered under +[Watermark lifetime](invalidation.md#watermark-lifetime). The binary payload envelope uses `0x00` to escape raw marker-prefixed bytes, `0x01` for compressed string output, and `0x02` for compressed binary output. diff --git a/docs/shadow-validation.md b/docs/shadow-validation.md index 987bb57..031959a 100644 --- a/docs/shadow-validation.md +++ b/docs/shadow-validation.md @@ -82,12 +82,15 @@ capacity, lifetime, and earlier-hit gates still apply. On a served hit, core retains the serialized frame that supplied the caller as `C0`, returns the normal decoded result, then starts detached source work. -The comparator later receives an independently deserialized cached value. +That loader runs with caching disabled for this `DialCache` instance, so nested +readers through the instance bypass caching. The comparator later receives an +independently deserialized cached value. On a ramped-down path, the caller starts and awaits its normal source loader exactly once. Detached work reads `C0` and shares that caller-accepted source result, `S`; it does not launch another loader. A source rejection or timeout -never becomes an accepted fill value. +never becomes an accepted fill value. This foreground loader retains the caller's +context; it is not rerun inside the served-hit branch's disabled scope. ## The `C0` / `S` / `C1` algorithm @@ -247,8 +250,9 @@ serving layers are disabled. ## Confirmed mismatch logging `shadow.logMismatches: true` adds one warning only after confirmed `mismatch`. -It is default-off and independent of sampling. The warning includes namespace, -use case, key type, outcome, and three bounded fields: +It is default-off and independent of sampling. The logger receives the message +`"DialCache shadow validation mismatch"` and an object with `cacheNamespace`, +`useCase`, `keyType`, `outcome: "mismatch"`, and three bounded detail fields: | Field | Content | UTF-8 cap | | --- | --- | --- | @@ -258,7 +262,9 @@ use case, key type, outcome, and three bounded fields: Clipped fields end with `...[truncated]` inside the cap. JSON failure or undefined output makes that side `null`; the other side is still attempted. Logging does -not compute a diff or reuse the Redis serializer. +not compute a diff or reuse the Redis serializer. If detail construction fails, +core still attempts the warning with the four metadata fields; all three detail +fields can be absent. Truncation is not redaction. Keys and values can include sensitive application data. Native JSON may execute getters or `toJSON`, and the byte caps apply only diff --git a/docs/stale-on-error.md b/docs/stale-on-error.md index caecf5d..34af85d 100644 --- a/docs/stale-on-error.md +++ b/docs/stale-on-error.md @@ -50,8 +50,9 @@ rejection. The built-in classifier accepts `FallbackTimeoutError` only. | `M` | `staleOnErrorMaxAgeSec` | Exclusive recovery age ceiling, measured from the same frame timestamp | `M` is total age, not extra time after `F`. Positive configuration must satisfy -`0 < F < M <= 31_536_000` seconds. Omission leaves recovery off, or inherits it -in a sparse runtime overlay. Explicit `0` disables inherited recovery. +`0 < F < M <= 31_536_000` seconds. Both ages must be safe-integer numbers. +Omission leaves recovery off, or inherits it in a sparse runtime overlay. +Explicit `0` disables inherited recovery. Invalid static defaults throw. Invalid runtime recovery policy records `config_resolution`, disables only recovery, and preserves valid ordinary Redis diff --git a/docs/upgrading.md b/docs/upgrading.md index 0d0adc6..1444754 100644 --- a/docs/upgrading.md +++ b/docs/upgrading.md @@ -44,6 +44,23 @@ are part of this protocol relationship. Raising the cap or shrinking the floor requires another coordinated transition because old markers cannot be extended by deploying new constants alone. +## Removed configuration fields + +Remove these legacy properties entirely; construction rejects their own-property +presence even when the value is `undefined`: + +| Older field | Replacement | +| --- | --- | +| `DialCacheConfig.urnPrefix`, `DialCacheKeyInit.urnPrefix` | `namespace` | +| `DialCacheConfig.rampSampler` | Built-in deterministic key-and-layer ramp assignment; no injected sampler | +| `DialCacheKeyConfig.shadowRamp` | `shadow.ramp` | +| `RedisConfig.keyPrefix` | The instance's `namespace` | +| `RedisConfig.createClient` | Create and connect the client in the application, then pass the semantic `client` | +| `RedisConfig.watermarkTtlSec` | Remove it; DialCache derives watermark retention | + +See [Runtime validation](configuration.md#validation-and-snapshots) for how an +obsolete field in a provider result differs from invalid static configuration. + ## Custom Redis adapters Migrate against the current [semantic interface](redis.md#custom-client-contract): @@ -106,8 +123,9 @@ misinterpret foreign bytes instead of rejecting them. Legacy binary output can collide with envelope markers: -- A legacy `0x01`/`0x02` prefix whose remaining bytes happen to be valid zstd can - be decoded as compressed data. +- A legacy `0x01`/`0x02` prefix whose remaining bytes are accepted by native zstd + can be decoded as compressed data. Acceptance does not guarantee a complete + valid stream; empty/truncated bodies or trailing bytes may also be accepted. - A legacy payload beginning with `0x00` followed by `0x00`, `0x01`, or `0x02` can lose its first byte to the escape rule. - New writers escape colliding raw binary prefixes even with compression off. From f3d1648c9ec50916afaf5fd05700bb6a72b97196 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sun, 6 Sep 2026 19:41:46 -0700 Subject: [PATCH 15/31] docs: clarify serializer limits and edge-case telemetry --- docs/api.md | 2 +- docs/observability.md | 20 +++++++++++--------- docs/redis.md | 16 ++++++++++++---- 3 files changed, 24 insertions(+), 14 deletions(-) diff --git a/docs/api.md b/docs/api.md index 6077007..2089bad 100644 --- a/docs/api.md +++ b/docs/api.md @@ -120,7 +120,7 @@ uncached. | `useCase` | Required | Stable operation name, cache-key component, and metric label; `"watermark"` is reserved | | `cacheKey` / `key` | Required | Selector for `cached`, direct value for `getOrLoad`; returns/accepts `CacheKeySpec` | | `defaultConfig` | Absent | Baseline `DialCacheKeyConfig`, snapshotted at registration or inline invocation | -| `serializer` | Effective JSON default | Typed `Serializer` required for statically non-JSON-compatible results, even for local-only declarations | +| `serializer` | Effective JSON default | Typed `Serializer` required when the [JSON type guard](redis.md#typed-serializer-requirement) cannot establish compatibility, even for local-only declarations | | `trackForInvalidation` | `false` | Use watermark-aware Redis reads for this operation | | `fallbackTimeoutMs` | `60_000` | Positive safe integer up to `2_147_483_647` ms; `null` disables the source deadline | | `shadowComparator` | Node strict deep equality | Synchronous, bounded `(cached, source) => boolean`; must not mutate its inputs | diff --git a/docs/observability.md b/docs/observability.md index 14fa4f2..febb613 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -370,18 +370,18 @@ Write-side outcomes are: - `compressed`: zstd plus its envelope was smaller and selected for the prepared Redis payload; - `below_threshold`: the serialized payload did not reach the configured - threshold; + threshold; this check runs before the size ceiling; - `not_smaller`: compression ran, but the marked result was not smaller than the raw stored form; and -- `write_over_limit`: the serialized value exceeded the 512 MiB decompression - ceiling and was kept raw for the attempted write. This is a capacity signal, - not an error. +- `write_over_limit`: the serialized value reached the threshold but exceeded + the 512 MiB decompression ceiling and was kept raw for the attempted write. + This is a capacity signal, not an error. Read-side outcomes are: - `decompressed`: a marked zstd payload was restored; -- `fallback_raw`: a marked payload was not valid zstd and was passed unchanged - to the serializer; and +- `fallback_raw`: native zstd rejected a marked payload for a reason other than + the output limit, so it was passed unchanged to the serializer; and - `read_over_limit`: decompression would exceed the 512 MiB ceiling, so the stored bytes were passed unchanged to the serializer. Treat this as a corruption or integrity signal. @@ -405,9 +405,11 @@ marked payload that produces a read-side outcome. A zstd exception while preparing a write records `error="compression"` and the cache write fails open. Decompression rejects neither the cache call nor -the observer path directly: an unreadable payload reaches the configured -serializer, whose rejection follows the existing refreshable-miss path and -records `serialization_load`. +the observer path directly: rejected marked bytes reach the configured +serializer. If `load` rejects, it records `serialization_load`. An ordinary fresh +read becomes a refreshable miss; shadow comparison reports +`deserialization_error` without repair, and retained recovery preserves the +original source rejection. See [Serialization](redis.md#serialization). zstd work is synchronous on the Node.js event loop. Use the duration, ratio, and pre/post-size series together when changing the threshold or level; a good diff --git a/docs/redis.md b/docs/redis.md index 96f703d..8bec611 100644 --- a/docs/redis.md +++ b/docs/redis.md @@ -282,6 +282,9 @@ The guard rejects known incompatible shapes including `Date`, `Map`, `Set`, bigint, functions, symbols, Buffers, typed arrays, method-bearing classes, required nested undefined, `unknown`, and `any`. It is conservative and cannot prove runtime data has no cycles, non-finite numbers, getters, or `toJSON` hooks. +The structural check stops at eight property/array-element steps, so deeply +nested or recursive JSON types can also require a serializer. When ordinary JSON +correctly round-trips those values, supply an explicit `new JsonSerializer()`. Supplying a typed serializer is a trusted assertion, not an extra round-trip validation performed by DialCache. @@ -312,9 +315,11 @@ levels trade CPU and latency for size reduction. Use the size, ratio, and duration [metrics](observability.md#compression-metrics) to evaluate that tradeoff. Decompressed output is capped at 512 MiB. Writes above the same ceiling remain -raw (`write_over_limit`). When native zstd rejects marked input, core hands the -original bytes to the serializer (`fallback_raw`, or `read_over_limit` when the -output limit caused rejection). Native decoder acceptance is not corruption +raw. With compression enabled, `below_threshold` takes precedence; +`write_over_limit` records an oversized payload that also reaches the threshold. +When native zstd rejects marked input, core hands the original bytes to the +serializer (`fallback_raw`, or `read_over_limit` when the output limit caused +rejection). Native decoder acceptance is not corruption validation: it can accept empty or truncated bodies as empty output and ignore trailing bytes. A custom serializer must validate the application value it receives, whether decompressed or raw. A compression exception fails the write @@ -345,7 +350,10 @@ Use `decodeRedisReadResult` or `decodeTrackedRedisReadResult` from `observedWatermarkMs` only from the same valid tracked snapshot. Cause and fence are independent: an absent value can carry a fence. Core validates the fence, discards it for untracked keys, and maps unknown results/reasons to -`unclassified` misses. Use `isRedisReadMiss(result)` instead of a null comparison. +`unclassified` misses. A `watermark_fenced` claim also becomes `unclassified` if +its observation is absent, invalid, or discarded for an untracked key. +Normalizing an unknown reason does not discard an otherwise valid tracked +observation. Use `isRedisReadMiss(result)` instead of a null comparison. `write` receives `valueKey`, `value`, `cacheTtlMs`, and optional `createdAtMs`. If present, that timestamp is the final value core admitted against an observed From 5273ab7c25360abf1d83f7375db36605cee79133 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sun, 6 Sep 2026 19:48:24 -0700 Subject: [PATCH 16/31] docs: qualify native JSON serialization failures --- docs/redis.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/redis.md b/docs/redis.md index 8bec611..cdd1337 100644 --- a/docs/redis.md +++ b/docs/redis.md @@ -230,9 +230,10 @@ can fail serialization. Reference sharing and prototypes are not preserved. Direct `JsonSerializer.dump(value)` calls return `Promise`; `load(string | Buffer)` returns `Promise`, decoding Buffer input as UTF-8. -Malformed JSON rejects with `SyntaxError`. Top-level functions or symbols reject -with `Error` because native JSON produces no payload; bigint and cycles normally -reject with native `TypeError`. The generic `T` is a caller assertion, not schema +Malformed JSON rejects with `SyntaxError`. After handling top-level `undefined`, +`dump` rejects with `Error` when `JSON.stringify` returns undefined, as it does +for ordinary top-level functions or symbols. Bigint and cycles normally reject +with native `TypeError`. The generic `T` is a caller assertion, not schema validation. A fresh frame whose `load` fails becomes a refreshable miss: core records From 6c1e0bc2b2764e5e380be842a6f67faac1ad1dd2 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sun, 6 Sep 2026 22:35:39 -0700 Subject: [PATCH 17/31] docs: make the README more direct and less promotional --- README.md | 161 ++++++++++++++++++++++++------------------------------ 1 file changed, 72 insertions(+), 89 deletions(-) diff --git a/README.md b/README.md index 65248d5..8fdc1c3 100644 --- a/README.md +++ b/README.md @@ -4,52 +4,26 @@ [![Codecov](https://codecov.io/gh/lan17/DialCache/branch/main/graph/badge.svg)](https://codecov.io/gh/lan17/DialCache) [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/lan17/DialCache/badge)](https://scorecard.dev/viewer/?uri=github.com/lan17/DialCache) -**Speed up reads. Stay in control.** +DialCache is a TypeScript caching library for Node.js. It wraps a function and +caches its result within a request, in a process-local LRU, or in Redis or Valkey. -DialCache brings request-local, in-process, and Redis caching to the TypeScript -functions you already use. Wrap a reader once, then decide where caching runs, -which keys use it, and how results stay fresh. +Caching is off by default. Outside an `enable()` scope, the wrapped function +just calls its loader. Inside the scope, each use case's configuration decides +which cache layers to use. TTLs and rollout settings can change at runtime +without rewriting the reader. -Start with an in-memory cache. Add Redis or Valkey when you need a shared layer. -Roll each use case out to a stable cohort of keys, observe the results, and -adjust the policy while your service runs. Your loader stays the same. - -[**Read the documentation →**](https://lan17.github.io/DialCache/) +[Documentation](https://lan17.github.io/DialCache/) · [Getting started](https://lan17.github.io/DialCache/getting-started.html) · [API reference](https://lan17.github.io/DialCache/api.html) -## Why DialCache? - -A cache changes more than latency: it changes how often your source runs, what -concurrent callers share, and when a reader sees a mutation. DialCache makes -those choices explicit: - -- **Choose the boundary.** Caching runs only inside `enable()`. Outside that - scope, your reader goes straight to its source. -- **Choose the layers.** Memoize within one request, reuse values across - requests with a bounded LRU, or share them across instances through Redis. -- **Roll out gradually.** Set TTLs and independent local, remote, and shadow - ramps per use case through a runtime configuration provider. -- **Handle hot keys and slow dependencies.** Concurrent same-key reads share - in-flight work by default. Redis reads and source fallbacks have separate - deadlines; cache failures fall back to your loader. -- **Check freshness.** Invalidate tracked Redis entries by entity, or compare - cached values with the source using sampled, detached shadow validation. -- **See what happens.** Prometheus, Datadog, and custom adapters report cache - requests, miss reasons, errors, latency, and feature outcomes. - -DialCache is a library for Node.js services. You supply the data loader and, if -needed, a connected Redis client and a runtime policy source. It fits database -lookups, service reads, and reusable computations whose results can be cached. - -## Try it +## Usage ```bash npm install dialcache ``` Requires Node.js `>=22.15.0 <23.0.0 || >=23.8.0`. -Redis and telemetry clients are optional dependencies you install separately. +Redis and telemetry clients are installed separately if you use them. Save this as `example.mts`. It creates one `DialCache` instance and reuses the wrapped reader: @@ -88,35 +62,24 @@ Run it directly with Node: node --experimental-strip-types example.mts ``` -You will see `Loading from source: 123` twice: once for the first enabled read, +This prints `Loading from source: 123` twice: once for the first enabled read, then again for the uncached call. The second enabled read reuses the value. This example uses only the process-local layer. A TTL with no ramp enables that layer for every key inside the scope. The LRU holds at most 10,000 entries by -default. In a service, place `enable()` around a read-request handler so nested +default. In a service, place `enable()` around a request's reads so nested readers inherit the same asynchronous scope. Results containing `Date`, `bigint`, or other non-JSON-compatible values need an explicit [typed serializer](https://lan17.github.io/DialCache/redis.html#typed-serializer-requirement), even when you cache only in memory. -Prefer an inline loader? [`getOrLoad()`](https://lan17.github.io/DialCache/api.html#getorload) -uses the same behavior with a direct key: +For a loader defined at the call site, +[`getOrLoad()`](https://lan17.github.io/DialCache/api.html#getorload) takes a +zero-argument function and a direct key. It uses the same cache behavior without +registering a reusable reader. -```ts -const user = await dialcache.enable(() => - dialcache.getOrLoad(() => fetchUser("456"), { - keyType: "user_id", - useCase: "InlineGetUser", - key: "456", - defaultConfig: new DialCacheKeyConfig({ requestLocal: true }), - }), -); -``` - -[Continue the getting-started guide →](https://lan17.github.io/DialCache/getting-started.html) - -## One reader, three cache layers +## Cache layers When an enabled call reaches an active layer, a hit returns immediately. A miss continues down the chain: @@ -128,20 +91,27 @@ request-local → process-local → Redis / Valkey → your loader | Layer | Shares values across | Lifetime | Typical use | | --- | --- | --- | --- | | Request-local | Calls in one outer `enable()` scope | Until that scope settles | Avoid repeated reads within a request | -| Process-local | Requests using one `DialCache` instance | TTL, bounded by LRU capacity | Keep hot values close to your code | -| Remote | Application instances using the same Redis keyspace | TTL, with optional invalidation tracking | Reuse reads across a service fleet | +| Process-local | Requests using one `DialCache` instance | TTL, bounded by LRU capacity | Avoid repeated reads between requests | +| Remote | Application instances using the same Redis keyspace | TTL, with optional invalidation tracking | Reuse reads across processes | Use any combination. Redis hits can warm an active process-local cache; results from the lower chain can be memoized within the request. Tracked Redis reads have additional publication rules to keep a fallback from bypassing an -invalidation fence. +invalidation fence. The [read-path guide](https://lan17.github.io/DialCache/concepts.html) +describes what gets stored after each kind of hit or miss. -[Understand the read path and freshness boundaries →](https://lan17.github.io/DialCache/concepts.html) +When a cache layer is active, concurrent calls with the same key share in-flight +work by default. That sharing is scoped to a request or a `DialCache` instance, +depending on the active layers. +Use `coalesce: false` when callers need independent executions. The +[coalescing guide](https://lan17.github.io/DialCache/coalescing.html) covers which +results, errors, and deadlines a follower inherits. -## Turn the dial while your service runs +## Runtime configuration -Keep a baseline next to each reader and supply a sparse runtime override through -`cacheConfigProvider`. This example starts a local cache at zero: +A reader's `defaultConfig` is its baseline. `cacheConfigProvider` can override +individual fields on each enabled invocation. For example, register a reader +with local caching ramped to zero, then change the ramp while the instance runs: ```ts const policies = new Map(); @@ -159,7 +129,7 @@ const readUser = cache.cached(fetchUser, { }), }); -// Admit a stable 10% key cohort. The baseline TTL is inherited. +// Use a 10% ramp and keep the baseline TTL. policies.set("ReadUser", new DialCacheKeyConfig({ ramp: { [CacheLayer.LOCAL]: 10 }, })); @@ -170,15 +140,15 @@ await cache.enable(() => readUser("123")); policies.set("ReadUser", DialCacheKeyConfig.disabled()); ``` -The map illustrates the integration point; your application can populate policy -from its existing configuration system. Ramps select **keys**, so a 10% cohort -can account for more or less than 10% of traffic. Increasing a ramp adds keys to -the same cohort. Decreasing it removes keys without reshuffling the rest. +The map can be populated from your application's existing configuration system. +A ramp selects a stable set of keys, so a 10% cohort can account for more or +less than 10% of traffic. Increasing a ramp adds keys to the same cohort. +Decreasing it removes keys without reshuffling the rest. -With Redis configured, serving and shadow ramps work independently. You can -sample reads and fills in shadow mode before allowing Redis to serve callers. -Turning serving off does not stop shadow work; `disabled()` disables both for -new invocations. +With Redis configured, shadow validation can sample reads, compare cached values +with the source, and fill misses while Redis serving is off. Serving and shadow +ramps are independent: turning serving off does not stop shadow work. +`disabled()` disables both for new invocations. Policy changes govern new invocations; they do not evict existing values or cancel shared work. The reference explains @@ -187,36 +157,51 @@ cancel shared work. The reference explains [Runtime configuration](https://lan17.github.io/DialCache/configuration.html) · [Shadow validation](https://lan17.github.io/DialCache/shadow-validation.html) -## Freshness is a policy you choose +## Freshness and invalidation -For mutable data, opt a reader into **targeted Redis invalidation** and advance -its entity watermark after the source mutation commits. A tracked Redis read -checks the value and watermark together. In-memory hits and coalesced callers -can reuse an earlier observation. The +For mutable data, a reader can track Redis invalidation by entity. Advance the +entity's watermark with `invalidateRemote()` after the source mutation commits. +A tracked Redis read checks the value and watermark together. In-memory hits +and coalesced callers can reuse an earlier observation. The [invalidation guide](https://lan17.github.io/DialCache/invalidation.html#independent-fence-checks) shows how to give each invocation its own fence check. -For selected source failures, **stale-on-error** can return a retained Redis -snapshot within a maximum age. It is off by default; when enabled, its built-in -error policy admits only `FallbackTimeoutError`. The reference explains how to -choose a classifier and what a snapshot means when invalidation races with a -source call. +Stale-on-error can return a retained Redis snapshot after selected source +failures, subject to a maximum age. It is off by default. When enabled, its +built-in error policy accepts only `FallbackTimeoutError`; applications can +supply a different classifier. Later invalidation does not revoke a snapshot +already retained for recovery. -Good cache keys include every input that affects the result. Cached objects are -shared references: treat them as immutable. Cache access fails open, while -explicit invalidation failures reject so your application can handle them. +Include every input that affects the result in the cache key. In-memory values +and coalesced results are shared references, so copy an object before modifying +it. [Invalidation](https://lan17.github.io/DialCache/invalidation.html) · [Stale-on-error](https://lan17.github.io/DialCache/stale-on-error.html) · [Key design](https://lan17.github.io/DialCache/configuration.html#keys-ids-and-extra-dimensions) -## Explore the reference +## Failures and metrics + +Cache access fails open. A failed Redis read falls back to the loader; a failed +cache write does not discard a successful loader result. Source errors still +reject unless stale recovery serves a value. Explicit invalidation failures +also reject. + +Redis reads and source fallbacks have separate deadlines. Providers, serializers, +writes, and the underlying clients need application-owned time limits; see +[liveness](https://lan17.github.io/DialCache/coalescing.html#application-owned-budgets). -The [documentation home](https://lan17.github.io/DialCache/) -provides a guided reading order and a topic map. Each feature guide starts with -its purpose and setup, then explains execution, edge cases, and API details. +Metrics are optional. The [Prometheus and Datadog adapters](https://lan17.github.io/DialCache/observability.html) +report requests, miss reasons, errors, latency, and shadow and recovery outcomes. +Custom backends can implement the same adapter interface. -| I want to… | Read | +## Reference + +The [reference](https://lan17.github.io/DialCache/) covers setup, behavior, APIs, +and operational details. It can also be +[read as Markdown on GitHub](https://github.com/lan17/DialCache/tree/main/docs). + +| Topic | Guide | | --- | --- | | Add caching to a service | [Getting started](https://lan17.github.io/DialCache/getting-started.html) | | Understand what runs on a hit, miss, or error | [How DialCache works](https://lan17.github.io/DialCache/concepts.html) | @@ -227,6 +212,4 @@ its purpose and setup, then explains execution, edge cases, and API details. | Build dashboards and diagnose misses | [Observability](https://lan17.github.io/DialCache/observability.html) | | Upgrade, validate, or contribute | [Upgrading](https://lan17.github.io/DialCache/upgrading.html) · [Maintainer guide](https://lan17.github.io/DialCache/maintainers.html) | -[Browse the reference as Markdown](https://github.com/lan17/DialCache/tree/main/docs). - MIT licensed. See [LICENSE](https://github.com/lan17/DialCache/blob/main/LICENSE). From fc2fb95f9a37f1c4475375d8de3e33e11c370d16 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Mon, 7 Sep 2026 11:06:21 -0700 Subject: [PATCH 18/31] docs: address README review feedback --- README.md | 45 +++++++++++++++++++++++++-------------------- 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 8fdc1c3..6931404 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,17 @@ [![Codecov](https://codecov.io/gh/lan17/DialCache/branch/main/graph/badge.svg)](https://codecov.io/gh/lan17/DialCache) [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/lan17/DialCache/badge)](https://scorecard.dev/viewer/?uri=github.com/lan17/DialCache) -DialCache is a TypeScript caching library for Node.js. It wraps a function and -caches its result within a request, in a process-local LRU, or in Redis or Valkey. +DialCache is a TypeScript caching library for Node.js services. Use it for +database lookups, service reads, and other work whose results can be reused. + +It wraps a function and caches its result within a request, in a process-local +LRU, or in a shared Redis or Valkey cache. You configure cache policy separately +from the loader, so you can change TTLs or gradually enable caching while the +service is running. Caching is off by default. Outside an `enable()` scope, the wrapped function just calls its loader. Inside the scope, each use case's configuration decides -which cache layers to use. TTLs and rollout settings can change at runtime -without rewriting the reader. +which cache layers to use. [Documentation](https://lan17.github.io/DialCache/) · [Getting started](https://lan17.github.io/DialCache/getting-started.html) @@ -23,7 +27,7 @@ npm install dialcache ``` Requires Node.js `>=22.15.0 <23.0.0 || >=23.8.0`. -Redis and telemetry clients are installed separately if you use them. +Redis and telemetry clients are optional; install them separately as needed. Save this as `example.mts`. It creates one `DialCache` instance and reuses the wrapped reader: @@ -65,7 +69,7 @@ node --experimental-strip-types example.mts This prints `Loading from source: 123` twice: once for the first enabled read, then again for the uncached call. The second enabled read reuses the value. -This example uses only the process-local layer. A TTL with no ramp enables that +The example uses only the process-local layer. A TTL with no ramp enables that layer for every key inside the scope. The LRU holds at most 10,000 entries by default. In a service, place `enable()` around a request's reads so nested readers inherit the same asynchronous scope. @@ -74,10 +78,9 @@ Results containing `Date`, `bigint`, or other non-JSON-compatible values need an explicit [typed serializer](https://lan17.github.io/DialCache/redis.html#typed-serializer-requirement), even when you cache only in memory. -For a loader defined at the call site, -[`getOrLoad()`](https://lan17.github.io/DialCache/api.html#getorload) takes a -zero-argument function and a direct key. It uses the same cache behavior without -registering a reusable reader. +For a loader defined at the call site, `getOrLoad()` uses the same cache behavior +without registering a reusable reader. See the +[inline example](https://lan17.github.io/DialCache/getting-started.html#keep-a-calculation-inline). ## Cache layers @@ -102,10 +105,9 @@ describes what gets stored after each kind of hit or miss. When a cache layer is active, concurrent calls with the same key share in-flight work by default. That sharing is scoped to a request or a `DialCache` instance, -depending on the active layers. -Use `coalesce: false` when callers need independent executions. The -[coalescing guide](https://lan17.github.io/DialCache/coalescing.html) covers which -results, errors, and deadlines a follower inherits. +depending on the active layers. Use `coalesce: false` when callers need +independent executions. The [coalescing guide](https://lan17.github.io/DialCache/coalescing.html) +covers which results, errors, and deadlines a follower inherits. ## Runtime configuration @@ -180,20 +182,23 @@ it. · [Stale-on-error](https://lan17.github.io/DialCache/stale-on-error.html) · [Key design](https://lan17.github.io/DialCache/configuration.html#keys-ids-and-extra-dimensions) -## Failures and metrics +## Failures Cache access fails open. A failed Redis read falls back to the loader; a failed cache write does not discard a successful loader result. Source errors still reject unless stale recovery serves a value. Explicit invalidation failures also reject. -Redis reads and source fallbacks have separate deadlines. Providers, serializers, -writes, and the underlying clients need application-owned time limits; see +Redis reads and source fallbacks have separate deadlines. Configuration +providers, serializers, Redis writes, and invalidation need finite +application-owned time limits; see [liveness](https://lan17.github.io/DialCache/coalescing.html#application-owned-budgets). +## Metrics + Metrics are optional. The [Prometheus and Datadog adapters](https://lan17.github.io/DialCache/observability.html) -report requests, miss reasons, errors, latency, and shadow and recovery outcomes. -Custom backends can implement the same adapter interface. +report requests, miss reasons, errors, latency, and outcomes for shadow validation +and stale recovery. Custom backends can implement the same adapter interface. ## Reference @@ -201,7 +206,7 @@ The [reference](https://lan17.github.io/DialCache/) covers setup, behavior, APIs and operational details. It can also be [read as Markdown on GitHub](https://github.com/lan17/DialCache/tree/main/docs). -| Topic | Guide | +| Task | Guide | | --- | --- | | Add caching to a service | [Getting started](https://lan17.github.io/DialCache/getting-started.html) | | Understand what runs on a hit, miss, or error | [How DialCache works](https://lan17.github.io/DialCache/concepts.html) | From f60494aa87674e865c83153448c0e2a36b54101c Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Mon, 7 Sep 2026 12:53:19 -0700 Subject: [PATCH 19/31] docs: define terms and add scenarios to the README Name the loader and the cached function where the example introduces them, replace internal vocabulary (fence, publication, lower chain, observation, retained snapshot, application-owned) with plain words, give coalescing, invalidation, and stale-on-error one concrete scenario each, and keep each paragraph to one topic. The opening states what a cache changes besides latency and why caching is off by default. The first TypeScript block and the link set are unchanged. --- README.md | 146 ++++++++++++++++++++++++++++++------------------------ 1 file changed, 80 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index 6931404..6a16dc3 100644 --- a/README.md +++ b/README.md @@ -4,17 +4,21 @@ [![Codecov](https://codecov.io/gh/lan17/DialCache/branch/main/graph/badge.svg)](https://codecov.io/gh/lan17/DialCache) [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/lan17/DialCache/badge)](https://scorecard.dev/viewer/?uri=github.com/lan17/DialCache) -DialCache is a TypeScript caching library for Node.js services. Use it for -database lookups, service reads, and other work whose results can be reused. - -It wraps a function and caches its result within a request, in a process-local -LRU, or in a shared Redis or Valkey cache. You configure cache policy separately -from the loader, so you can change TTLs or gradually enable caching while the -service is running. - -Caching is off by default. Outside an `enable()` scope, the wrapped function -just calls its loader. Inside the scope, each use case's configuration decides -which cache layers to use. +DialCache is a read-through cache for TypeScript functions in Node.js services. +Use it for database lookups, service reads, and other work whose results can be +reused. + +You wrap the function that reads from the source, and DialCache decides on each +call whether to return a cached result or run it. Results can be cached within a +request, in a process-local LRU, or in a shared Redis or Valkey cache. Cache +policy lives apart from the function itself, so you can change TTLs or enable +caching for a growing share of keys while the service runs. + +A cache changes more than latency. It changes how often your source runs, what +concurrent callers share, and how soon a read sees a write. DialCache makes each +of those a per-use-case setting, and caching is off by default: outside an +`enable()` scope the wrapped function just calls through, so a write path never +fills a cache unless you enable it there. [Documentation](https://lan17.github.io/DialCache/) · [Getting started](https://lan17.github.io/DialCache/getting-started.html) @@ -29,8 +33,7 @@ npm install dialcache Requires Node.js `>=22.15.0 <23.0.0 || >=23.8.0`. Redis and telemetry clients are optional; install them separately as needed. -Save this as `example.mts`. It creates one `DialCache` instance and reuses the -wrapped reader: +Save this as `example.mts`: ```ts import { CacheLayer, DialCache, DialCacheKeyConfig } from "dialcache"; @@ -69,23 +72,29 @@ node --experimental-strip-types example.mts This prints `Loading from source: 123` twice: once for the first enabled read, then again for the uncached call. The second enabled read reuses the value. -The example uses only the process-local layer. A TTL with no ramp enables that -layer for every key inside the scope. The LRU holds at most 10,000 entries by -default. In a service, place `enable()` around a request's reads so nested -readers inherit the same asynchronous scope. +`fetchUser` is the loader, the function that reads from the source. `getUser` is +the cached function that DialCache returns; call it wherever you would have +called `fetchUser`. The `keyType`, `useCase`, and `cacheKey` options make up the +cache key, so include every input that changes the result. + +The example caches only in process memory. A TTL with no ramp turns that layer +on for every key inside the scope, and the LRU holds 10,000 entries by default. +In a service, wrap each request's reads in one `enable()` call; every cached +function called inside it shares that scope. Results containing `Date`, `bigint`, or other non-JSON-compatible values need an explicit [typed serializer](https://lan17.github.io/DialCache/redis.html#typed-serializer-requirement), even when you cache only in memory. -For a loader defined at the call site, `getOrLoad()` uses the same cache behavior -without registering a reusable reader. See the +When the loader is a one-off calculation rather than a reusable function, +`getOrLoad()` takes it inline with a direct key and uses the same cache +behavior. See the [inline example](https://lan17.github.io/DialCache/getting-started.html#keep-a-calculation-inline). ## Cache layers -When an enabled call reaches an active layer, a hit returns immediately. A miss -continues down the chain: +Inside `enable()`, a call checks each active layer in order and stops at the +first hit. A miss at every layer runs the loader: ```text request-local → process-local → Redis / Valkey → your loader @@ -97,23 +106,26 @@ request-local → process-local → Redis / Valkey → your loader | Process-local | Requests using one `DialCache` instance | TTL, bounded by LRU capacity | Avoid repeated reads between requests | | Remote | Application instances using the same Redis keyspace | TTL, with optional invalidation tracking | Reuse reads across processes | -Use any combination. Redis hits can warm an active process-local cache; results -from the lower chain can be memoized within the request. Tracked Redis reads -have additional publication rules to keep a fallback from bypassing an -invalidation fence. The [read-path guide](https://lan17.github.io/DialCache/concepts.html) -describes what gets stored after each kind of hit or miss. +Layers combine. A Redis hit warms the process-local cache, and a request-local +layer memoizes whatever the layers below it return. The +[read-path guide](https://lan17.github.io/DialCache/concepts.html) lists exactly +what is stored after each kind of hit or miss. -When a cache layer is active, concurrent calls with the same key share in-flight -work by default. That sharing is scoped to a request or a `DialCache` instance, -depending on the active layers. Use `coalesce: false` when callers need -independent executions. The [coalescing guide](https://lan17.github.io/DialCache/coalescing.html) -covers which results, errors, and deadlines a follower inherits. +When a layer is active, concurrent calls for the same key share one in-progress +call by default. Ten callers asking for the same user at the same moment cause +at most one read of the source, and the other nine receive that result. Sharing +is scoped to the request or to the process, depending on which layers are +active. Set `coalesce: false` when callers must not share. The +[coalescing guide](https://lan17.github.io/DialCache/coalescing.html) explains +what a waiting caller inherits, including errors and deadlines. -## Runtime configuration +## Changing policy at runtime -A reader's `defaultConfig` is its baseline. `cacheConfigProvider` can override -individual fields on each enabled invocation. For example, register a reader -with local caching ramped to zero, then change the ramp while the instance runs: +A cached function's `defaultConfig` is its baseline. A `cacheConfigProvider` on +the instance can override individual fields on every enabled call, so you can +roll a cache out, tune it, or turn it off without touching the function. This +example registers a cached function with its local cache ramped to zero, then +opens it to a 10% cohort of keys: ```ts const policies = new Map(); @@ -142,41 +154,43 @@ await cache.enable(() => readUser("123")); policies.set("ReadUser", DialCacheKeyConfig.disabled()); ``` -The map can be populated from your application's existing configuration system. -A ramp selects a stable set of keys, so a 10% cohort can account for more or -less than 10% of traffic. Increasing a ramp adds keys to the same cohort. -Decreasing it removes keys without reshuffling the rest. +In a service, your configuration system feeds the map. A ramp selects a stable +set of keys rather than a share of traffic, so a 10% cohort can serve more or +less than 10% of calls. Raising the ramp adds keys to the cohort. Lowering it +removes keys without reshuffling the rest. -With Redis configured, shadow validation can sample reads, compare cached values -with the source, and fill misses while Redis serving is off. Serving and shadow -ramps are independent: turning serving off does not stop shadow work. -`disabled()` disables both for new invocations. +Policy changes apply to new calls. They do not evict cached values or cancel +calls already in progress, and a shorter TTL affects local and Redis entries +differently. Read [how TTL changes affect each layer](https://lan17.github.io/DialCache/configuration.html#changing-policy-on-a-running-service) +before using a runtime change to tighten freshness. -Policy changes govern new invocations; they do not evict existing values or -cancel shared work. The reference explains -[how TTL changes affect each layer](https://lan17.github.io/DialCache/configuration.html#changing-policy-on-a-running-service). +With Redis configured, shadow validation can compare cached values with the +source on a sample of reads, and fill misses, before Redis serves any caller. +Serving and shadow ramps are independent, so turning serving off does not stop +shadow work. `disabled()` stops both for new calls. [Runtime configuration](https://lan17.github.io/DialCache/configuration.html) · [Shadow validation](https://lan17.github.io/DialCache/shadow-validation.html) ## Freshness and invalidation -For mutable data, a reader can track Redis invalidation by entity. Advance the -entity's watermark with `invalidateRemote()` after the source mutation commits. -A tracked Redis read checks the value and watermark together. In-memory hits -and coalesced callers can reuse an earlier observation. The +By default a cached value lives until its TTL expires. For data that changes, a +cached function can opt into tracked invalidation. After a write commits, call +`invalidateRemote()` for the entity. Tracked Redis reads of that entity then +reject values written before the invalidation, extended by a buffer you choose +to cover clock skew and in-progress writes. Values already in process memory, +and callers already waiting on an in-progress read, can still return the +earlier value. The [invalidation guide](https://lan17.github.io/DialCache/invalidation.html#independent-fence-checks) -shows how to give each invocation its own fence check. +shows how to give every call its own check. -Stale-on-error can return a retained Redis snapshot after selected source -failures, subject to a maximum age. It is off by default. When enabled, its -built-in error policy accepts only `FallbackTimeoutError`; applications can -supply a different classifier. Later invalidation does not revoke a snapshot -already retained for recovery. +Stale-on-error makes the opposite trade. When the source fails, it can return +the value Redis still holds even though that value's TTL has passed, up to a +maximum age you set. It is off by default. Its built-in policy treats only +`FallbackTimeoutError` as recoverable; you can supply your own classifier. A +value retained for recovery is not revoked by a later invalidation. -Include every input that affects the result in the cache key. In-memory values -and coalesced results are shared references, so copy an object before modifying -it. +Cached objects are shared references. Copy one before you modify it. [Invalidation](https://lan17.github.io/DialCache/invalidation.html) · [Stale-on-error](https://lan17.github.io/DialCache/stale-on-error.html) @@ -184,14 +198,14 @@ it. ## Failures -Cache access fails open. A failed Redis read falls back to the loader; a failed -cache write does not discard a successful loader result. Source errors still -reject unless stale recovery serves a value. Explicit invalidation failures -also reject. +Cache access fails open. If a Redis read fails, the call runs the loader. If a +cache write fails, the loader's result is still returned. Loader errors reject +unless stale-on-error serves a value. `invalidateRemote()` failures reject, so +your application knows the invalidation did not happen. -Redis reads and source fallbacks have separate deadlines. Configuration -providers, serializers, Redis writes, and invalidation need finite -application-owned time limits; see +DialCache puts separate deadlines on Redis reads and on the loader. It does not +time out configuration providers, serializers, Redis writes, or invalidation; +give those their own limits. See [liveness](https://lan17.github.io/DialCache/coalescing.html#application-owned-budgets). ## Metrics From 6e3566a6adaef941ca00031e50028a70c6c620e5 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Mon, 7 Sep 2026 12:53:19 -0700 Subject: [PATCH 20/31] docs: align page titles and fix reference nits Match the configuration, coalescing, and shadow-validation page titles to their sidebar and link text. Refer to the library as DialCache instead of "core" in the Redis, shadow, observability, invalidation, and stale-on-error guides. Import DialCacheKeyConfig in the coalescing deadline example, describe the DogStatsD shutdown comment accurately, distinguish the missing coalesced-flight cap from shadowMaxInFlight, describe ramps as key-selection thresholds rather than percentages, qualify the invalidation example's coalesce comment, and use a plain text fence for the PromQL sample that the highlighter cannot load. --- docs/api.md | 5 +++-- docs/coalescing.md | 4 ++-- docs/configuration.md | 2 +- docs/invalidation.md | 7 ++++--- docs/observability.md | 14 +++++++------- docs/redis.md | 34 +++++++++++++++++----------------- docs/shadow-validation.md | 18 +++++++++--------- docs/stale-on-error.md | 6 +++--- docs/upgrading.md | 2 +- 9 files changed, 47 insertions(+), 45 deletions(-) diff --git a/docs/api.md b/docs/api.md index 2089bad..0df6d5b 100644 --- a/docs/api.md +++ b/docs/api.md @@ -146,7 +146,7 @@ overlay. Omission inherits; it does not turn an inherited field off. | Field | Effective default | Values | | --- | --- | --- | | `ttlSec.local`, `ttlSec.remote` | No TTL: layer off | Positive safe-integer seconds, at most `31_536_000` (365 days) | -| `ramp.local`, `ramp.remote` | `100` when a TTL exists | Finite percentage from `0` through `100`; `0` bypasses serving | +| `ramp.local`, `ramp.remote` | `100` when a TTL exists | Key-selection threshold from `0` through `100`, not a share of traffic; `0` bypasses serving | | `requestLocal` | `false` | Boolean; no TTL or ramp | | `coalesce` | `true` | Boolean; affects request-local and process flights | | `remoteReadTimeoutMs` | Instance setting, then `50` | Positive safe-integer milliseconds, at most `2_147_483_647`; cannot be unbounded | @@ -196,7 +196,8 @@ process.oldestLeaderAgeMs; // Monotonic age, or null when idle. ``` The nested shape is `ProcessCoalescingState`. Request-local flights are excluded. -There is no public flight cap, cancellation, cache clear, or shutdown method. +There is no method to clear a cache, cancel in-flight loads, cap coalesced +flights, or shut an instance down. See [Coalescing state](coalescing.md#inspecting-process-scoped-flights). ## Keys and serializers diff --git a/docs/coalescing.md b/docs/coalescing.md index 2b24589..998309c 100644 --- a/docs/coalescing.md +++ b/docs/coalescing.md @@ -1,4 +1,4 @@ -# Coalescing and fallback liveness +# Coalescing and liveness [Documentation](index.md) · [API reference](api.md) @@ -222,7 +222,7 @@ it to `null` only when the application intentionally accepts an unbounded fallback: ```ts -import { FallbackTimeoutError } from "dialcache"; +import { DialCacheKeyConfig, FallbackTimeoutError } from "dialcache"; const getUser = dialcache.cached( (userId: string) => db.fetchUser(userId), diff --git a/docs/configuration.md b/docs/configuration.md index 4cdb037..c927a56 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,4 +1,4 @@ -# Configuration and cache layers +# Configuration [Documentation](index.md) · [API reference](api.md) diff --git a/docs/invalidation.md b/docs/invalidation.md index e78032c..4557c35 100644 --- a/docs/invalidation.md +++ b/docs/invalidation.md @@ -34,7 +34,8 @@ const getUser = dialcache.cached( trackForInvalidation: true, defaultConfig: new DialCacheKeyConfig({ ttlSec: { [CacheLayer.REMOTE]: 300 }, - coalesce: false, // Each invocation performs its own tracked read. + // No local layers here, so each invocation performs its own tracked read. + coalesce: false, }), }, ); @@ -79,7 +80,7 @@ read-time fencing supplies that distinction. ### Conditional refills An adapter-level tracked miss may carry `observedWatermarkMs` from the same -atomic read. After a successful fallback, core uses that observation to avoid +atomic read. After a successful fallback, DialCache uses that observation to avoid writing a replacement already known to be fenced: 1. Sample the application clock before serialization. If the sample is at or @@ -197,7 +198,7 @@ in-flight operations, or stop an already-dispatched write. ## Watermark lifetime -Core caps tracked Redis value retention at **one hour**. Each dispatched write +DialCache caps tracked Redis value retention at **one hour**. Each dispatched write configured above that cap records `tracked_ttl_clamped`; its logical policy is not rewritten. Invalidation alone creates and updates watermarks. diff --git a/docs/observability.md b/docs/observability.md index febb613..bed9deb 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -199,7 +199,8 @@ const dialcache = new DialCache({ }); function shutdown(): void { - // Drain outstanding cache operations before application shutdown. + // Close the client yourself once outstanding DialCache calls have settled. + // DialCache never flushes or closes it. dogStatsD.close(); } ``` @@ -286,11 +287,11 @@ and bytes without unit conversion: | `dialcache.compression.duration` | Distribution or histogram | `cache_namespace`, `use_case`, `key_type`, `layer`, `operation` | Payload compression and decompression latency in seconds | Synchronous client throws are isolated when DialCache invokes the adapter. -Core also consumes thenables returned by adapter hooks, but this adapter does +DialCache also consumes thenables returned by adapter hooks, but this adapter does not forward every client return value: only `shadowValidation` and `staleRecovery` return the counter call's result. A custom DogStatsD client must handle its own asynchronous delivery failures, including rejected promises. -Direct adapter calls do not have core's observer guard. Configure client error +Direct adapter calls do not have DialCache's observer guard. Configure client error handling and shutdown as part of application ownership. ## Shadow outcomes @@ -353,7 +354,7 @@ enter histogram sums. Repeated reads can observe the same future frame. For direct adapter callers, Prometheus additionally discards nonfinite or nonpositive `observeFutureTimestampOffset` values. Datadog forwards those -observations without that extra guard; normal core calls supply positive finite +observations without that extra guard; normal DialCache calls supply positive finite offsets to both. Use external fleet clock monitoring as well: workload observations cannot detect @@ -454,8 +455,7 @@ thrown value's class or `Error.name`: | `fallback` | The source loader failed or exceeded its DialCache deadline | | `unknown` | Reserved for a future failure site that cannot be classified otherwise | -These values are defined by the backend-neutral core and are identical for -every adapter. +DialCache defines these values itself, so they are identical for every adapter. A valid `invalidateRemote()` call without a configured Redis client is still an invalidation attempt: DialCache records `dialcache_invalidation_counter` (or @@ -534,7 +534,7 @@ returned at runtime. Synchronous throws and rejections of those returned thenables are isolated so telemetry cannot change cache correctness, fallback results, or shadow outcomes. -This guard applies when core invokes the observer, not to direct calls to an +This guard applies when DialCache invokes the observer, not to direct calls to an adapter or to asynchronous work whose promise the hook does not return. A custom adapter may buffer or transmit asynchronously, but it owns delivery, diff --git a/docs/redis.md b/docs/redis.md index cdd1337..d835062 100644 --- a/docs/redis.md +++ b/docs/redis.md @@ -106,7 +106,7 @@ deserialization. An invalidated large value therefore still consumes transfer bandwidth until it expires or is replaced. The adapter returns either `DecodedRedisFrame { payload, createdAtMs }` or -`RedisReadMiss { kind: "miss", reason, observedWatermarkMs? }`. Core then checks +`RedisReadMiss { kind: "miss", reason, observedWatermarkMs? }`. DialCache then checks logical age against the operation's effective TTL. Future-dated or invalid frames miss before deserialization. With recovery enabled, the initial read may retain expired bytes while the source runs; see [Stale-on-error](stale-on-error.md). @@ -129,11 +129,11 @@ write script, placeholder, transaction, or watermark mutation. Same-key writes are last-writer-wins; tracked **reads** enforce invalidation. Physical TTL is normally the remote TTL. With stale-on-error it is the maximum -recovery age instead. Core separately caps tracked values at one hour and emits +recovery age instead. DialCache separately caps tracked values at one hour and emits `tracked_ttl_clamped` for each dispatched write whose requested TTL exceeds the cap. Untracked values retain their configured TTL, up to 365 days. -A tracked miss can carry a valid observed watermark. Core skips a replacement +A tracked miss can carry a valid observed watermark. DialCache skips a replacement already known to be fenced, checking once before payload preparation and again immediately before dispatch. An admitted write uses the final timestamp exactly. Misses without that fence let the adapter sample `Date.now()` before dispatch. @@ -149,7 +149,7 @@ Invalid reply-domain values are errors and are not retried. If the retry also fails, GLIDE attaches the original error as `cause` when possible. Node-redis surfaces the retry rejection unmodified because some -client errors are shared objects. A healed retry looks like success to core +client errors are shared objects. A healed retry looks like success to DialCache metrics; server command statistics expose unexpected `EVAL` activity. A rejected or timed-out dispatched mutation does not prove that Redis remained @@ -179,7 +179,7 @@ runtime remoteReadTimeoutMs → defaultConfig.remoteReadTimeoutMs Values are positive safe integers through `2_147_483_647` milliseconds. A remote read cannot be configured as unbounded. -When the wait expires, core aborts `RedisReadContext.signal`, logs a +When the wait expires, DialCache aborts `RedisReadContext.signal`, logs a `RedisReadTimeoutError`, records `cache_read_timeout`, and invokes the source. Late read outcomes are consumed and ignored. A read error or timeout does not trigger a Redis refill or stale recovery. An active untracked local layer may @@ -191,16 +191,16 @@ remaining budget. The source deadline begins separately when fallback starts. Recovery reuses the initial snapshot and creates no second read budget. Node-redis passes a cooperative signal to native reads where supported. GLIDE -uses its configured native request budget. Core still bounds its own wait; +uses its configured native request budget. DialCache still bounds its own wait; neither mechanism promises server-side cancellation or bounds all underlying client work. See [Coalescing and liveness](coalescing.md). ## Lifecycle ownership Before shutdown, stop new work and await public cache-operation and invalidation -promises, including loaders that may later write Redis. A read whose core wait -expired can still be active in the client. Use client-native controls to drain -or terminate that work before closing the connection. +promises, including loaders that may later write Redis. A read that DialCache +stopped waiting for can still be active in the client. Use client-native +controls to drain or terminate that work before closing the connection. Close node-redis with `await redisClient.quit()` or close GLIDE with `glideClient.close()` after draining application work. The adapters own no @@ -236,7 +236,7 @@ for ordinary top-level functions or symbols. Bigint and cycles normally reject with native `TypeError`. The generic `T` is a caller assertion, not schema validation. -A fresh frame whose `load` fails becomes a refreshable miss: core records +A fresh frame whose `load` fails becomes a refreshable miss: DialCache records `serialization_load`, calls the source, and attempts replacement. The default codec validates JSON syntax, not your application schema. For incompatible value changes, use a validating serializer or change an identity dimension such @@ -318,7 +318,7 @@ duration [metrics](observability.md#compression-metrics) to evaluate that tradeo Decompressed output is capped at 512 MiB. Writes above the same ceiling remain raw. With compression enabled, `below_threshold` takes precedence; `write_over_limit` records an oversized payload that also reaches the threshold. -When native zstd rejects marked input, core hands the original bytes to the +When native zstd rejects marked input, DialCache hands the original bytes to the serializer (`fallback_raw`, or `read_over_limit` when the output limit caused rejection). Native decoder acceptance is not corruption validation: it can accept empty or truncated bodies as empty output and ignore @@ -342,14 +342,14 @@ Implement the three methods of `DialCacheRedisClient` and pass the object in `read` receives `valueKey` and, only for tracked reads, `watermarkKey`. `RedisReadContext` supplies `timeoutMs` and an `AbortSignal` for cooperative -cancellation. Returned payload bytes transfer to core and must remain stable +cancellation. Returned payload bytes transfer to DialCache and must remain stable while retained for shadow or recovery; return a dedicated Buffer if the client pools or reuses response storage. Use `decodeRedisReadResult` or `decodeTrackedRedisReadResult` from `dialcache/redis-protocol`, or preserve their behavior exactly. Attach an `observedWatermarkMs` only from the same valid tracked snapshot. Cause and fence -are independent: an absent value can carry a fence. Core validates the fence, +are independent: an absent value can carry a fence. DialCache validates the fence, discards it for untracked keys, and maps unknown results/reasons to `unclassified` misses. A `watermark_fenced` claim also becomes `unclassified` if its observation is absent, invalid, or discarded for an untracked key. @@ -357,7 +357,7 @@ Normalizing an unknown reason does not discard an otherwise valid tracked observation. Use `isRedisReadMiss(result)` instead of a null comparison. `write` receives `valueKey`, `value`, `cacheTtlMs`, and optional `createdAtMs`. -If present, that timestamp is the final value core admitted against an observed +If present, that timestamp is the final value DialCache admitted against an observed fence: honor it exactly. Otherwise sample real client time before dispatch. A constant timestamp is incompatible with logical age enforcement. @@ -367,7 +367,7 @@ that logical operation. The public script takes `[futureBufferMs, invalidatedAtMs]` as its arguments and returns integer `1`. Bound connection, queue, dispatch, retry, reconnect, and response lifetimes. -Core bounds read waits but does not own the client's resource lifecycle or add +DialCache bounds read waits but does not own the client's resource lifecycle or add write/invalidation deadlines. ## Advanced wire protocol @@ -420,7 +420,7 @@ throws `DialCacheRedisPayloadEncodingError`. The untracked decoder accepts a zero timestamp. Both decoders convert the raw uint64 to a JavaScript number without rejecting unsafe values, which can lose -precision. Core separately rejects unsafe timestamps and applies its +precision. DialCache separately rejects unsafe timestamps and applies its [age and clock rules](observability.md#value-ages-and-clock-offsets); the codecs alone do not establish that a decoded frame is fresh or safe to serve. @@ -434,7 +434,7 @@ Its repair and retention rules are covered under The binary payload envelope uses `0x00` to escape raw marker-prefixed bytes, `0x01` for compressed string output, and `0x02` for compressed binary output. -Adapters treat the payload as opaque: core interprets this envelope above them. +Adapters treat the payload as opaque: DialCache interprets this envelope above them. The physical value key appends `:dialcache-frame-v1` to the logical key. Read [Upgrading](upgrading.md) before migrating an older adapter or namespace. diff --git a/docs/shadow-validation.md b/docs/shadow-validation.md index 031959a..ff71b14 100644 --- a/docs/shadow-validation.md +++ b/docs/shadow-validation.md @@ -1,4 +1,4 @@ -# Redis shadow validation +# Shadow validation [Documentation](index.md) · [Observability](observability.md#shadow-outcomes) @@ -80,7 +80,7 @@ capacity, lifetime, and earlier-hit gates still apply. ## Serving-hit and ramped-down paths -On a served hit, core retains the serialized frame that supplied the caller as +On a served hit, DialCache retains the serialized frame that supplied the caller as `C0`, returns the normal decoded result, then starts detached source work. That loader runs with caching disabled for this `DialCache` instance, so nested readers through the instance bypass caching. The comparator later receives an @@ -109,7 +109,7 @@ C0 present → compare S ### Clean-miss fill A semantic `C0` miss can be filled from `S`. For a tracked miss carrying a valid -`observedWatermarkMs`, core checks the application timestamp before serialization +`observedWatermarkMs`, DialCache checks the application timestamp before serialization and again immediately before dispatch. A timestamp at or below that observation skips the fill and emits `fill_fenced`. Preflight suppression also avoids serialization, compression, and frame allocation. @@ -132,7 +132,7 @@ observed fence; another operation may still change Redis. ### Comparison and confirmation -For present `C0`, core deserializes an independent cached snapshot after the +For present `C0`, DialCache deserializes an independent cached snapshot after the source result is available. Equal values report `match`. A disagreement triggers one direct Redis `C1` read in the same tracked or untracked mode. @@ -142,7 +142,7 @@ Confirmation failure or read timeout produces `confirmation_error`. Confirmation bypasses logical age solely for supersession comparison. A future-dated `C1` records its offset but can be retained for comparing bytes; it -cannot serve the caller. Core does not deserialize `C1`, compare it with `S`, or +cannot serve the caller. DialCache does not deserialize `C1`, compare it with `S`, or chase another version. Strings compare exactly, Buffers by bytes, and mixed string/Buffer payloads by UTF-8 bytes. @@ -186,7 +186,7 @@ A sampled served hit runs the serializer's `load` again in detached work. It must be repeatable, non-mutating, and return independently usable values. Returned Redis payload bytes must remain stable after the adapter read settles. -Core retains the original `cached()` argument references or `getOrLoad()` +DialCache retains the original `cached()` argument references or `getOrLoad()` closure. It cannot generically clone source-selection state. Keep arguments, captured state, and accepted source values immutable, or snapshot before the invocation, so detached work still refers to the key that was selected. @@ -207,7 +207,7 @@ read separately has the effective remote-read deadline. Served-hit timing starts with the detached callback. Ramped-down timing starts immediately before the caller's source invocation, including its synchronous -prefix. On abandonment, core releases retained `C0` and stops later phases. +prefix. On abandonment, DialCache releases retained `C0` and stops later phases. Already-started shadow-owned work keeps capacity until it settles, even after its DialCache deadline. A shared caller-owned loader can continue without holding the shadow slot after timeout. @@ -216,7 +216,7 @@ Scheduling and shadow deadline timers are unreferenced. They do not keep an otherwise idle process alive. Detachment uses the Node event loop, not a worker; synchronous loader, serializer, comparator, or logger work still consumes it. Underlying I/O is not generally cancellable. Give dependencies finite native -budgets, including commands that may settle after a core timeout. +budgets, including commands that may settle after a DialCache timeout. ## Consistency modes and race boundaries @@ -263,7 +263,7 @@ It is default-off and independent of sampling. The logger receives the message Clipped fields end with `...[truncated]` inside the cap. JSON failure or undefined output makes that side `null`; the other side is still attempted. Logging does not compute a diff or reuse the Redis serializer. If detail construction fails, -core still attempts the warning with the four metadata fields; all three detail +DialCache still attempts the warning with the four metadata fields; all three detail fields can be absent. Truncation is not redaction. Keys and values can include sensitive application diff --git a/docs/stale-on-error.md b/docs/stale-on-error.md index 34af85d..aa27b2d 100644 --- a/docs/stale-on-error.md +++ b/docs/stale-on-error.md @@ -62,7 +62,7 @@ recovery. ## Follow one invocation The initial read uses one invocation snapshot of `F`, `M`, and the read deadline. -Core classifies the returned frame before normal deserialization: +DialCache classifies the returned frame before normal deserialization: | Age when the initial read settles | Behavior | | --- | --- | @@ -75,10 +75,10 @@ A read error or timeout never enters recovery. A fresh frame that failed ordinar deserialization is not reconsidered as a stale candidate. If the source succeeds, normal refill rules apply; the retained candidate is not -deserialized. If the source rejects, core calls the selected classifier. An +deserialized. If the source rejects, DialCache calls the selected classifier. An accepted rejection authorizes a recovery check, even when no candidate exists. -With a candidate, core checks `0 <= age < M`, deserializes/decompresses lazily, +With a candidate, DialCache checks `0 <= age < M`, deserializes/decompresses lazily, and checks the age again before returning. Crossing `M` during asynchronous `load` prevents serving. A missing, expired, or undecodable candidate preserves the **exact original source rejection**. diff --git a/docs/upgrading.md b/docs/upgrading.md index 1444754..0a3882f 100644 --- a/docs/upgrading.md +++ b/docs/upgrading.md @@ -151,7 +151,7 @@ partial registration. Use a separate registry or prefix where needed. During a mixed-fleet rollout, aggregate away `reason` when comparing total misses with total requests. For example: -```promql +```text sum by (cache_namespace, use_case, key_type, layer) ( rate(dialcache_miss_counter[5m]) ) From e0b1dfca8766b78ac4e3cde68a40c7bba76721cb Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Mon, 7 Sep 2026 13:14:38 -0700 Subject: [PATCH 21/31] docs: open the README with a capability list and show both entry points Replace the three opening paragraphs with one definition sentence and six plain capability bullets: layers, per-use-case runtime policy, entity-organized keys with targeted Redis invalidation, default coalescing and fail-open, opt-in stale-on-error and shadow validation, and metrics adapters. The runnable example now calls getOrLoad() on the same cache path as the wrapped function, so the packaged-example check expects three source loads instead of two. The explanation after the example covers both forms, and the runtime and invalidation sections refer to use cases rather than cached functions. --- README.md | 68 +++++++++++++++++++++++++--------------- scripts/test-package.mjs | 2 +- 2 files changed, 43 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 6a16dc3..331d747 100644 --- a/README.md +++ b/README.md @@ -4,21 +4,27 @@ [![Codecov](https://codecov.io/gh/lan17/DialCache/branch/main/graph/badge.svg)](https://codecov.io/gh/lan17/DialCache) [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/lan17/DialCache/badge)](https://scorecard.dev/viewer/?uri=github.com/lan17/DialCache) -DialCache is a read-through cache for TypeScript functions in Node.js services. -Use it for database lookups, service reads, and other work whose results can be -reused. - -You wrap the function that reads from the source, and DialCache decides on each -call whether to return a cached result or run it. Results can be cached within a -request, in a process-local LRU, or in a shared Redis or Valkey cache. Cache -policy lives apart from the function itself, so you can change TTLs or enable -caching for a growing share of keys while the service runs. - -A cache changes more than latency. It changes how often your source runs, what -concurrent callers share, and how soon a read sees a write. DialCache makes each -of those a per-use-case setting, and caching is off by default: outside an -`enable()` scope the wrapped function just calls through, so a write path never -fills a cache unless you enable it there. +DialCache is a read-through cache for TypeScript services on Node.js. Use it for +database lookups, service reads, and other work whose results can be reused. +Give it a key and a loader, either by wrapping a function with `cached()` or +inline with `getOrLoad()`, and it returns a cached result or runs the loader. + +- Three layers: request-local memoization, a process-local LRU, and Redis or + Valkey. +- Per-use-case policy: layers, TTLs, and rollout ramps, changeable at runtime + through a configuration provider. +- Keys organized by entity, such as `urn:user_id:123#GetUser`, so one + `invalidateRemote()` call invalidates every tracked Redis result for that + entity. +- By default, concurrent same-key calls share one in-progress read, and cache + failures fall back to the loader. +- Opt-in: stale-on-error serves a retained Redis value when the source fails + with an error you allow; shadow validation checks Redis values against the + source and can warm Redis before it serves callers. +- Prometheus and Datadog adapters report requests, misses by reason, errors, + and latency. + +Caching is off by default and runs only inside an `enable()` scope. [Documentation](https://lan17.github.io/DialCache/) · [Getting started](https://lan17.github.io/DialCache/getting-started.html) @@ -58,6 +64,16 @@ const getUser = dialcache.cached(fetchUser, { await dialcache.enable(async () => { await getUser("123"); // Loads from source and caches the result. await getUser("123"); // Reuses the value for up to 60 seconds. + + // Same cache, inline: a key and a loader instead of a wrapped function. + const inline = { + keyType: "user_id", + useCase: "GetUserInline", + key: "456", + defaultConfig: new DialCacheKeyConfig({ ttlSec: { [CacheLayer.LOCAL]: 60 } }), + }; + await dialcache.getOrLoad(() => fetchUser("456"), inline); // Loads from source. + await dialcache.getOrLoad(() => fetchUser("456"), inline); // Reuses the value. }); await getUser("123"); // Outside enable(): loads from source again. @@ -69,13 +85,18 @@ Run it directly with Node: node --experimental-strip-types example.mts ``` -This prints `Loading from source: 123` twice: once for the first enabled read, -then again for the uncached call. The second enabled read reuses the value. +This prints three `Loading from source` lines: user 123 for the first enabled +read, user 456 for the first inline read, and user 123 again for the call +outside `enable()`. The repeated reads inside the scope reuse cached values. `fetchUser` is the loader, the function that reads from the source. `getUser` is the cached function that DialCache returns; call it wherever you would have -called `fetchUser`. The `keyType`, `useCase`, and `cacheKey` options make up the -cache key, so include every input that changes the result. +called `fetchUser`. `getOrLoad()` takes the same kind of loader inline with a +direct `key` and does not register a use case, so any number of call sites can +share one name, and calls that share a key share cached entries. In both forms, +`keyType`, `useCase`, and the key make up the cache identity, so include every +input that changes the result. The getting-started guide covers +[when to prefer each form](https://lan17.github.io/DialCache/getting-started.html#keep-a-calculation-inline). The example caches only in process memory. A TTL with no ramp turns that layer on for every key inside the scope, and the LRU holds 10,000 entries by default. @@ -86,11 +107,6 @@ Results containing `Date`, `bigint`, or other non-JSON-compatible values need an explicit [typed serializer](https://lan17.github.io/DialCache/redis.html#typed-serializer-requirement), even when you cache only in memory. -When the loader is a one-off calculation rather than a reusable function, -`getOrLoad()` takes it inline with a direct key and uses the same cache -behavior. See the -[inline example](https://lan17.github.io/DialCache/getting-started.html#keep-a-calculation-inline). - ## Cache layers Inside `enable()`, a call checks each active layer in order and stops at the @@ -121,7 +137,7 @@ what a waiting caller inherits, including errors and deadlines. ## Changing policy at runtime -A cached function's `defaultConfig` is its baseline. A `cacheConfigProvider` on +Each use case's `defaultConfig` is its baseline. A `cacheConfigProvider` on the instance can override individual fields on every enabled call, so you can roll a cache out, tune it, or turn it off without touching the function. This example registers a cached function with its local cache ramped to zero, then @@ -175,7 +191,7 @@ shadow work. `disabled()` stops both for new calls. ## Freshness and invalidation By default a cached value lives until its TTL expires. For data that changes, a -cached function can opt into tracked invalidation. After a write commits, call +use case can opt into tracked invalidation. After a write commits, call `invalidateRemote()` for the entity. Tracked Redis reads of that entity then reject values written before the invalidation, extended by a buffer you choose to cover clock skew and in-progress writes. Values already in process memory, diff --git a/scripts/test-package.mjs b/scripts/test-package.mjs index 0fb7789..8d80a14 100644 --- a/scripts/test-package.mjs +++ b/scripts/test-package.mjs @@ -18,7 +18,7 @@ const documentationExamples = [ { source: "README.md", filename: "readme-example.mts", - stdout: "Loading from source: 123\nLoading from source: 123\n", + stdout: "Loading from source: 123\nLoading from source: 456\nLoading from source: 123\n", }, { source: "docs/getting-started.md", From 363220438aa4e451b31b384fb3f7f7f902a064cb Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Mon, 7 Sep 2026 13:41:25 -0700 Subject: [PATCH 22/31] docs: trim the README to the example and its comments Drop the example.mts filename, the run command, and the paragraphs that explained the example, the layers, and the runtime example; the code comments now carry the loader, cached-function, key, and inline-form notes. Shorten the layer, runtime policy, invalidation, stale-on-error, failure, and metrics sections to the facts a reader needs before the linked guides. The console output and the packaged-example expectation are unchanged. --- README.md | 130 ++++++++++++++++++++---------------------------------- 1 file changed, 47 insertions(+), 83 deletions(-) diff --git a/README.md b/README.md index 331d747..c7156a0 100644 --- a/README.md +++ b/README.md @@ -36,36 +36,37 @@ Caching is off by default and runs only inside an `enable()` scope. npm install dialcache ``` -Requires Node.js `>=22.15.0 <23.0.0 || >=23.8.0`. -Redis and telemetry clients are optional; install them separately as needed. - -Save this as `example.mts`: +Requires Node.js `>=22.15.0 <23.0.0 || >=23.8.0`. Redis and telemetry clients +are optional; install them separately as needed. ```ts import { CacheLayer, DialCache, DialCacheKeyConfig } from "dialcache"; const dialcache = new DialCache(); -// Replace this loader with your database or service read. +// The loader: your database or service read. async function fetchUser(userId: string) { console.log("Loading from source:", userId); return { id: userId, name: "Ada" }; } +// The cached function. Call it wherever you would call fetchUser. const getUser = dialcache.cached(fetchUser, { - keyType: "user_id", - useCase: "GetUser", - cacheKey: (userId) => userId, + keyType: "user_id", // Entity kind; with the id, the unit of invalidation. + useCase: "GetUser", // Operation name; part of the key and metric labels. + cacheKey: (userId) => userId, // Include every input that changes the result. defaultConfig: new DialCacheKeyConfig({ ttlSec: { [CacheLayer.LOCAL]: 60 }, }), }); +// In a service, wrap each request's reads in one enable() call. await dialcache.enable(async () => { await getUser("123"); // Loads from source and caches the result. await getUser("123"); // Reuses the value for up to 60 seconds. - // Same cache, inline: a key and a loader instead of a wrapped function. + // Inline form: a direct key instead of cacheKey, and no registration. + // Call sites that share a key share cached entries. const inline = { keyType: "user_id", useCase: "GetUserInline", @@ -79,30 +80,6 @@ await dialcache.enable(async () => { await getUser("123"); // Outside enable(): loads from source again. ``` -Run it directly with Node: - -```bash -node --experimental-strip-types example.mts -``` - -This prints three `Loading from source` lines: user 123 for the first enabled -read, user 456 for the first inline read, and user 123 again for the call -outside `enable()`. The repeated reads inside the scope reuse cached values. - -`fetchUser` is the loader, the function that reads from the source. `getUser` is -the cached function that DialCache returns; call it wherever you would have -called `fetchUser`. `getOrLoad()` takes the same kind of loader inline with a -direct `key` and does not register a use case, so any number of call sites can -share one name, and calls that share a key share cached entries. In both forms, -`keyType`, `useCase`, and the key make up the cache identity, so include every -input that changes the result. The getting-started guide covers -[when to prefer each form](https://lan17.github.io/DialCache/getting-started.html#keep-a-calculation-inline). - -The example caches only in process memory. A TTL with no ramp turns that layer -on for every key inside the scope, and the LRU holds 10,000 entries by default. -In a service, wrap each request's reads in one `enable()` call; every cached -function called inside it shares that scope. - Results containing `Date`, `bigint`, or other non-JSON-compatible values need an explicit [typed serializer](https://lan17.github.io/DialCache/redis.html#typed-serializer-requirement), even when you cache only in memory. @@ -122,28 +99,25 @@ request-local → process-local → Redis / Valkey → your loader | Process-local | Requests using one `DialCache` instance | TTL, bounded by LRU capacity | Avoid repeated reads between requests | | Remote | Application instances using the same Redis keyspace | TTL, with optional invalidation tracking | Reuse reads across processes | -Layers combine. A Redis hit warms the process-local cache, and a request-local -layer memoizes whatever the layers below it return. The -[read-path guide](https://lan17.github.io/DialCache/concepts.html) lists exactly -what is stored after each kind of hit or miss. +Layers combine: a Redis hit warms the process-local cache, and the request-local +layer memoizes whatever the layers below return. The +[read-path guide](https://lan17.github.io/DialCache/concepts.html) lists what is +stored after each kind of hit or miss. -When a layer is active, concurrent calls for the same key share one in-progress -call by default. Ten callers asking for the same user at the same moment cause -at most one read of the source, and the other nine receive that result. Sharing -is scoped to the request or to the process, depending on which layers are -active. Set `coalesce: false` when callers must not share. The -[coalescing guide](https://lan17.github.io/DialCache/coalescing.html) explains -what a waiting caller inherits, including errors and deadlines. +Concurrent calls for the same key share one in-progress read by default, so ten +callers asking for the same user at once cause at most one source read. Set +`coalesce: false` to opt out. The +[coalescing guide](https://lan17.github.io/DialCache/coalescing.html) covers what +a waiting caller inherits, including errors and deadlines. ## Changing policy at runtime -Each use case's `defaultConfig` is its baseline. A `cacheConfigProvider` on -the instance can override individual fields on every enabled call, so you can -roll a cache out, tune it, or turn it off without touching the function. This -example registers a cached function with its local cache ramped to zero, then -opens it to a 10% cohort of keys: +Each use case's `defaultConfig` is its baseline; a `cacheConfigProvider` on the +instance overrides individual fields on every enabled call. This example starts +with local caching ramped to zero, then opens it to a 10% cohort of keys: ```ts +// Your configuration system feeds this map. const policies = new Map(); const cache = new DialCache({ cacheConfigProvider: (key) => policies.get(key.useCase) ?? null, @@ -170,41 +144,32 @@ await cache.enable(() => readUser("123")); policies.set("ReadUser", DialCacheKeyConfig.disabled()); ``` -In a service, your configuration system feeds the map. A ramp selects a stable -set of keys rather than a share of traffic, so a 10% cohort can serve more or -less than 10% of calls. Raising the ramp adds keys to the cohort. Lowering it -removes keys without reshuffling the rest. - -Policy changes apply to new calls. They do not evict cached values or cancel -calls already in progress, and a shorter TTL affects local and Redis entries -differently. Read [how TTL changes affect each layer](https://lan17.github.io/DialCache/configuration.html#changing-policy-on-a-running-service) -before using a runtime change to tighten freshness. +A ramp selects a stable set of keys, not a share of traffic: raising it adds +keys to the cohort, and lowering it removes keys without reshuffling the rest. +Policy changes apply to new calls only. They do not evict cached values, and +[a shorter TTL affects local and Redis entries differently](https://lan17.github.io/DialCache/configuration.html#changing-policy-on-a-running-service). -With Redis configured, shadow validation can compare cached values with the -source on a sample of reads, and fill misses, before Redis serves any caller. -Serving and shadow ramps are independent, so turning serving off does not stop -shadow work. `disabled()` stops both for new calls. +With Redis configured, shadow validation compares cached values with the source +on a sample of reads and fills misses before Redis serves any caller. Serving +and shadow ramps are independent; `disabled()` stops both. [Runtime configuration](https://lan17.github.io/DialCache/configuration.html) · [Shadow validation](https://lan17.github.io/DialCache/shadow-validation.html) ## Freshness and invalidation -By default a cached value lives until its TTL expires. For data that changes, a -use case can opt into tracked invalidation. After a write commits, call -`invalidateRemote()` for the entity. Tracked Redis reads of that entity then -reject values written before the invalidation, extended by a buffer you choose -to cover clock skew and in-progress writes. Values already in process memory, -and callers already waiting on an in-progress read, can still return the -earlier value. The +A cached value lives until its TTL expires. For data that changes, a use case +can opt into tracked invalidation: after a write commits, call +`invalidateRemote()` for the entity, and tracked Redis reads reject values +written before it. In-memory hits and in-progress reads can still return the +earlier value; the [invalidation guide](https://lan17.github.io/DialCache/invalidation.html#independent-fence-checks) shows how to give every call its own check. -Stale-on-error makes the opposite trade. When the source fails, it can return -the value Redis still holds even though that value's TTL has passed, up to a -maximum age you set. It is off by default. Its built-in policy treats only -`FallbackTimeoutError` as recoverable; you can supply your own classifier. A -value retained for recovery is not revoked by a later invalidation. +Stale-on-error, off by default, returns the value Redis still holds past its TTL +when the source fails with an error you allow, up to a maximum age you set. The +built-in policy accepts only `FallbackTimeoutError`. A value retained for +recovery is not revoked by a later invalidation. Cached objects are shared references. Copy one before you modify it. @@ -214,21 +179,20 @@ Cached objects are shared references. Copy one before you modify it. ## Failures -Cache access fails open. If a Redis read fails, the call runs the loader. If a -cache write fails, the loader's result is still returned. Loader errors reject -unless stale-on-error serves a value. `invalidateRemote()` failures reject, so -your application knows the invalidation did not happen. +Cache access fails open: a failed Redis read runs the loader, and a failed cache +write still returns the loader's result. Loader errors reject unless +stale-on-error serves a value, and `invalidateRemote()` failures always reject. DialCache puts separate deadlines on Redis reads and on the loader. It does not time out configuration providers, serializers, Redis writes, or invalidation; -give those their own limits. See -[liveness](https://lan17.github.io/DialCache/coalescing.html#application-owned-budgets). +see [liveness](https://lan17.github.io/DialCache/coalescing.html#application-owned-budgets). ## Metrics -Metrics are optional. The [Prometheus and Datadog adapters](https://lan17.github.io/DialCache/observability.html) -report requests, miss reasons, errors, latency, and outcomes for shadow validation -and stale recovery. Custom backends can implement the same adapter interface. +Optional [Prometheus and Datadog adapters](https://lan17.github.io/DialCache/observability.html) +report requests, misses by reason, errors, latency, and outcomes for shadow +validation and stale recovery. Custom backends implement the same adapter +interface. ## Reference From ecd3a90db8dfd8e182654bb17c5cba5de338f892 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Mon, 7 Sep 2026 13:56:38 -0700 Subject: [PATCH 23/31] docs: give the README opening more energy Open with what DialCache is and how simple the two entry points are, promise the behind-the-scenes work, and give each capability bullet a bold lead: multi-layer, runtime policies per use case, targeted invalidation, coalescing and fail-open by default, opt-in resilience, and observability. The example and every later section are unchanged. --- README.md | 43 ++++++++++++++++++++++--------------------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index c7156a0..dac805c 100644 --- a/README.md +++ b/README.md @@ -4,27 +4,28 @@ [![Codecov](https://codecov.io/gh/lan17/DialCache/branch/main/graph/badge.svg)](https://codecov.io/gh/lan17/DialCache) [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/lan17/DialCache/badge)](https://scorecard.dev/viewer/?uri=github.com/lan17/DialCache) -DialCache is a read-through cache for TypeScript services on Node.js. Use it for -database lookups, service reads, and other work whose results can be reused. -Give it a key and a loader, either by wrapping a function with `cached()` or -inline with `getOrLoad()`, and it returns a cached result or runs the loader. - -- Three layers: request-local memoization, a process-local LRU, and Redis or - Valkey. -- Per-use-case policy: layers, TTLs, and rollout ramps, changeable at runtime - through a configuration provider. -- Keys organized by entity, such as `urn:user_id:123#GetUser`, so one - `invalidateRemote()` call invalidates every tracked Redis result for that - entity. -- By default, concurrent same-key calls share one in-progress read, and cache - failures fall back to the loader. -- Opt-in: stale-on-error serves a retained Redis value when the source fails - with an error you allow; shadow validation checks Redis values against the - source and can warm Redis before it serves callers. -- Prometheus and Datadog adapters report requests, misses by reason, errors, - and latency. - -Caching is off by default and runs only inside an `enable()` scope. +DialCache is a caching library for TypeScript on Node.js. Caching a function is +as simple as wrapping it with `cached()`, or calling `getOrLoad()` inline with a +key and a loader, and you keep fine-grained control over every use case. Behind +the scenes, it handles the parts that usually go wrong. + +- **Multi-layer:** request-local memoization, a process-local LRU, and Redis or + Valkey, in any combination. +- **Runtime policies per use case:** layers, TTLs, and rollout ramps, changeable + while the service runs through a configuration provider. +- **Targeted invalidation:** keys are organized by entity, such as + `urn:user_id:123#GetUser`, so one `invalidateRemote()` call invalidates every + tracked Redis result for that entity. +- **Coalescing and fail-open by default:** concurrent same-key calls share one + in-progress read, and cache failures fall back to the loader. +- **Opt-in resilience:** stale-on-error serves a retained Redis value when the + source fails with an error you allow; shadow validation checks Redis against + the source and can warm it before it serves callers. +- **Observability:** Prometheus and Datadog adapters report requests, misses by + reason, errors, and latency. + +Caching is off until you turn it on. It runs only inside an `enable()` scope, so +a write path never fills a cache unless you enable it there. [Documentation](https://lan17.github.io/DialCache/) · [Getting started](https://lan17.github.io/DialCache/getting-started.html) From 27c40dc304df9053ed80ffb5b36b9e17efd55d1f Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Mon, 7 Sep 2026 17:09:37 -0700 Subject: [PATCH 24/31] docs: rewrite the README opening paragraph One idea per sentence: what DialCache is, the two entry points as one motion, the per-use-case decisions, that they can change at runtime, and the concrete problems handled behind the scenes. --- README.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index dac805c..7597417 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,12 @@ [![Codecov](https://codecov.io/gh/lan17/DialCache/branch/main/graph/badge.svg)](https://codecov.io/gh/lan17/DialCache) [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/lan17/DialCache/badge)](https://scorecard.dev/viewer/?uri=github.com/lan17/DialCache) -DialCache is a caching library for TypeScript on Node.js. Caching a function is -as simple as wrapping it with `cached()`, or calling `getOrLoad()` inline with a -key and a loader, and you keep fine-grained control over every use case. Behind -the scenes, it handles the parts that usually go wrong. +DialCache is a caching library for TypeScript on Node.js. Wrap a function with +`cached()`, or hand `getOrLoad()` a key and a loader, and the result is cached. +You decide, per use case, where results live, for how long, and for which keys. +Those decisions can change while the service runs. Behind the scenes, DialCache +handles the parts that usually go wrong: hot keys, cache outages, stale data, +and risky rollouts. - **Multi-layer:** request-local memoization, a process-local LRU, and Redis or Valkey, in any combination. From e935b1e45646f62c52a10a18ca7b560de89423af Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Mon, 7 Sep 2026 17:14:23 -0700 Subject: [PATCH 25/31] docs: write the README in the third person Remove second-person phrasing from the opening, the bullets, the example comments, the read-path diagram, and the freshness section. Policy is described as set per use case, recoverable errors as classified by the policy, and the enable() rule as applying to write paths. --- README.md | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 7597417..a166f93 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,10 @@ DialCache is a caching library for TypeScript on Node.js. Wrap a function with `cached()`, or hand `getOrLoad()` a key and a loader, and the result is cached. -You decide, per use case, where results live, for how long, and for which keys. -Those decisions can change while the service runs. Behind the scenes, DialCache -handles the parts that usually go wrong: hot keys, cache outages, stale data, -and risky rollouts. +Policy is set per use case: where results live, for how long, and for which +keys. It can change while the service runs. Behind the scenes, DialCache handles +the parts that usually go wrong: hot keys, cache outages, stale data, and risky +rollouts. - **Multi-layer:** request-local memoization, a process-local LRU, and Redis or Valkey, in any combination. @@ -21,13 +21,13 @@ and risky rollouts. - **Coalescing and fail-open by default:** concurrent same-key calls share one in-progress read, and cache failures fall back to the loader. - **Opt-in resilience:** stale-on-error serves a retained Redis value when the - source fails with an error you allow; shadow validation checks Redis against - the source and can warm it before it serves callers. + source fails with an error classified as recoverable; shadow validation checks + Redis against the source and can warm it before it serves callers. - **Observability:** Prometheus and Datadog adapters report requests, misses by reason, errors, and latency. -Caching is off until you turn it on. It runs only inside an `enable()` scope, so -a write path never fills a cache unless you enable it there. +Caching is off until enabled. It runs only inside an `enable()` scope, so write +paths stay uncached unless wrapped in one. [Documentation](https://lan17.github.io/DialCache/) · [Getting started](https://lan17.github.io/DialCache/getting-started.html) @@ -47,13 +47,13 @@ import { CacheLayer, DialCache, DialCacheKeyConfig } from "dialcache"; const dialcache = new DialCache(); -// The loader: your database or service read. +// The loader: the database or service read. async function fetchUser(userId: string) { console.log("Loading from source:", userId); return { id: userId, name: "Ada" }; } -// The cached function. Call it wherever you would call fetchUser. +// The cached function. A drop-in replacement for fetchUser. const getUser = dialcache.cached(fetchUser, { keyType: "user_id", // Entity kind; with the id, the unit of invalidation. useCase: "GetUser", // Operation name; part of the key and metric labels. @@ -85,7 +85,7 @@ await getUser("123"); // Outside enable(): loads from source again. Results containing `Date`, `bigint`, or other non-JSON-compatible values need an explicit [typed serializer](https://lan17.github.io/DialCache/redis.html#typed-serializer-requirement), -even when you cache only in memory. +even when caching only in memory. ## Cache layers @@ -93,7 +93,7 @@ Inside `enable()`, a call checks each active layer in order and stops at the first hit. A miss at every layer runs the loader: ```text -request-local → process-local → Redis / Valkey → your loader +request-local → process-local → Redis / Valkey → loader ``` | Layer | Shares values across | Lifetime | Typical use | @@ -120,7 +120,7 @@ instance overrides individual fields on every enabled call. This example starts with local caching ramped to zero, then opens it to a 10% cohort of keys: ```ts -// Your configuration system feeds this map. +// The application's configuration system feeds this map. const policies = new Map(); const cache = new DialCache({ cacheConfigProvider: (key) => policies.get(key.useCase) ?? null, @@ -170,11 +170,11 @@ earlier value; the shows how to give every call its own check. Stale-on-error, off by default, returns the value Redis still holds past its TTL -when the source fails with an error you allow, up to a maximum age you set. The -built-in policy accepts only `FallbackTimeoutError`. A value retained for +when the source fails with an error classified as recoverable, up to a configured +maximum age. The built-in policy accepts only `FallbackTimeoutError`. A value retained for recovery is not revoked by a later invalidation. -Cached objects are shared references. Copy one before you modify it. +Cached objects are shared references. Copy before modifying. [Invalidation](https://lan17.github.io/DialCache/invalidation.html) · [Stale-on-error](https://lan17.github.io/DialCache/stale-on-error.html) From dafd7241878c55cfa2f8a735564cc83bbb6150c6 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Mon, 7 Sep 2026 17:14:49 -0700 Subject: [PATCH 26/31] docs: reflow the stale-on-error paragraph --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index a166f93..9627fe6 100644 --- a/README.md +++ b/README.md @@ -170,9 +170,9 @@ earlier value; the shows how to give every call its own check. Stale-on-error, off by default, returns the value Redis still holds past its TTL -when the source fails with an error classified as recoverable, up to a configured -maximum age. The built-in policy accepts only `FallbackTimeoutError`. A value retained for -recovery is not revoked by a later invalidation. +when the source fails with an error classified as recoverable, up to a +configured maximum age. The built-in policy accepts only `FallbackTimeoutError`. +A value retained for recovery is not revoked by a later invalidation. Cached objects are shared references. Copy before modifying. From 8a72292edd9423db68364ebb55c9f5ba224a94a5 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Mon, 7 Sep 2026 17:26:49 -0700 Subject: [PATCH 27/31] docs: reframe the README opening around use cases Open with what the library organizes, controls, and observes, then the two entry points. Drop the problem-list hook and the policy sentences, which the framing sentence and the bullets now cover. --- README.md | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 9627fe6..ce7e892 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,10 @@ [![Codecov](https://codecov.io/gh/lan17/DialCache/branch/main/graph/badge.svg)](https://codecov.io/gh/lan17/DialCache) [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/lan17/DialCache/badge)](https://scorecard.dev/viewer/?uri=github.com/lan17/DialCache) -DialCache is a caching library for TypeScript on Node.js. Wrap a function with -`cached()`, or hand `getOrLoad()` a key and a loader, and the result is cached. -Policy is set per use case: where results live, for how long, and for which -keys. It can change while the service runs. Behind the scenes, DialCache handles -the parts that usually go wrong: hot keys, cache outages, stale data, and risky -rollouts. +DialCache is a TypeScript library that organizes caching into use cases, offers +runtime control and observability for each one, and provides a set of features +behind the scenes. Wrap a function with `cached()`, or hand `getOrLoad()` a key +and a loader, and the result is cached. - **Multi-layer:** request-local memoization, a process-local LRU, and Redis or Valkey, in any combination. From cc361eaba7a378288e3098e0ec6b16d8e1d6a857 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Mon, 7 Sep 2026 18:36:37 -0700 Subject: [PATCH 28/31] docs: drop the entry-point sentence from the README opening --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index ce7e892..4691ea1 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,7 @@ DialCache is a TypeScript library that organizes caching into use cases, offers runtime control and observability for each one, and provides a set of features -behind the scenes. Wrap a function with `cached()`, or hand `getOrLoad()` a key -and a loader, and the result is cached. +behind the scenes. - **Multi-layer:** request-local memoization, a process-local LRU, and Redis or Valkey, in any combination. From f6aaf96745a9cdc3f00abeb93a29492ad1cd800d Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Mon, 7 Sep 2026 18:37:53 -0700 Subject: [PATCH 29/31] docs: shorten the multi-layer bullet to the layer chain --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 4691ea1..5641c5e 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,7 @@ DialCache is a TypeScript library that organizes caching into use cases, offers runtime control and observability for each one, and provides a set of features behind the scenes. -- **Multi-layer:** request-local memoization, a process-local LRU, and Redis or - Valkey, in any combination. +- **Multi-layer:** request-local → process-local → Redis. - **Runtime policies per use case:** layers, TTLs, and rollout ramps, changeable while the service runs through a configuration provider. - **Targeted invalidation:** keys are organized by entity, such as From 9070df456e483f7f990f983f4562773a2d1cd617 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Mon, 7 Sep 2026 18:40:50 -0700 Subject: [PATCH 30/31] docs: restore the README install section Bring back the install block from the previous README, with the optional Redis, Valkey, Prometheus, and Datadog client packages and their supported ranges, and the Node.js zstd requirement, under its own heading. Commands use npm to match the getting-started guide. --- README.md | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 5641c5e..e830393 100644 --- a/README.md +++ b/README.md @@ -29,14 +29,26 @@ paths stay uncached unless wrapped in one. · [Getting started](https://lan17.github.io/DialCache/getting-started.html) · [API reference](https://lan17.github.io/DialCache/api.html) -## Usage +## Install ```bash npm install dialcache +# Choose a Redis client when using the remote layer: +npm install redis@~4.7.1 +# or +npm install @valkey/valkey-glide@^2.0.0 +# Add a metrics client only when using its adapter: +npm install prom-client@^15.1.3 +# or +npm install hot-shots@^17.0.0 ``` -Requires Node.js `>=22.15.0 <23.0.0 || >=23.8.0`. Redis and telemetry clients -are optional; install them separately as needed. +DialCache requires Node.js with zstd support in `node:zlib`: 22.15.0 or newer +within the 22.x line, or 23.8.0 and newer (23.0–23.7 lack zstd and are +excluded). Production deployments should use a +[currently supported LTS release](https://nodejs.org/en/about/previous-releases). + +## Usage ```ts import { CacheLayer, DialCache, DialCacheKeyConfig } from "dialcache"; From 24a3c53770964b8b39a2292e72bedfbd4d3e1e85 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Mon, 7 Sep 2026 18:47:12 -0700 Subject: [PATCH 31/31] docs: shape the README as a landing page Add an enabled-scope section with the write-path rationale and the enable/disable example. Drop the freshness, failures, and metrics sections, whose facts the capability bullets already state, and route their guides through the reference table instead. Keep the two gotchas under Usage, trim the coalescing paragraph to one sentence, and bold the two key phrases. Align the package, site, and documentation-home descriptions with the README's framing. --- README.md | 70 +++++++++++++++----------------------- docs/.vitepress/config.mts | 2 +- docs/index.md | 8 ++--- package.json | 2 +- 4 files changed, 33 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index e830393..df6a277 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,7 @@ behind the scenes. - **Observability:** Prometheus and Datadog adapters report requests, misses by reason, errors, and latency. -Caching is off until enabled. It runs only inside an `enable()` scope, so write -paths stay uncached unless wrapped in one. +Caching is **off by default** and runs only inside an `enable()` scope. [Documentation](https://lan17.github.io/DialCache/) · [Getting started](https://lan17.github.io/DialCache/getting-started.html) @@ -93,7 +92,28 @@ await getUser("123"); // Outside enable(): loads from source again. Results containing `Date`, `bigint`, or other non-JSON-compatible values need an explicit [typed serializer](https://lan17.github.io/DialCache/redis.html#typed-serializer-requirement), -even when caching only in memory. +even when caching only in memory. Cached objects are shared references; copy +before modifying. + +## Enabled scope + +Caching is **off by default**. Outside an `enable()` scope, `cached()` and +`getOrLoad()` just run the loader. **Enable once at the request boundary**, such +as a middleware around read handlers, so call sites need no changes, and wrap +mutation handlers in `disable()` so a write path cannot cache a read it is about +to make stale: + +```ts +await dialcache.enable(async () => { + await getUser("123"); // Cached. + + await dialcache.disable(async () => { + await updateUser("123", patch); // Reads in here go to the source. + }); + + await getUser("123"); // Cached again; disable() evicts nothing. +}); +``` ## Cache layers @@ -115,8 +135,7 @@ layer memoizes whatever the layers below return. The [read-path guide](https://lan17.github.io/DialCache/concepts.html) lists what is stored after each kind of hit or miss. -Concurrent calls for the same key share one in-progress read by default, so ten -callers asking for the same user at once cause at most one source read. Set +Concurrent calls for the same key share one in-progress read by default; set `coalesce: false` to opt out. The [coalescing guide](https://lan17.github.io/DialCache/coalescing.html) covers what a waiting caller inherits, including errors and deadlines. @@ -167,44 +186,6 @@ and shadow ramps are independent; `disabled()` stops both. [Runtime configuration](https://lan17.github.io/DialCache/configuration.html) · [Shadow validation](https://lan17.github.io/DialCache/shadow-validation.html) -## Freshness and invalidation - -A cached value lives until its TTL expires. For data that changes, a use case -can opt into tracked invalidation: after a write commits, call -`invalidateRemote()` for the entity, and tracked Redis reads reject values -written before it. In-memory hits and in-progress reads can still return the -earlier value; the -[invalidation guide](https://lan17.github.io/DialCache/invalidation.html#independent-fence-checks) -shows how to give every call its own check. - -Stale-on-error, off by default, returns the value Redis still holds past its TTL -when the source fails with an error classified as recoverable, up to a -configured maximum age. The built-in policy accepts only `FallbackTimeoutError`. -A value retained for recovery is not revoked by a later invalidation. - -Cached objects are shared references. Copy before modifying. - -[Invalidation](https://lan17.github.io/DialCache/invalidation.html) -· [Stale-on-error](https://lan17.github.io/DialCache/stale-on-error.html) -· [Key design](https://lan17.github.io/DialCache/configuration.html#keys-ids-and-extra-dimensions) - -## Failures - -Cache access fails open: a failed Redis read runs the loader, and a failed cache -write still returns the loader's result. Loader errors reject unless -stale-on-error serves a value, and `invalidateRemote()` failures always reject. - -DialCache puts separate deadlines on Redis reads and on the loader. It does not -time out configuration providers, serializers, Redis writes, or invalidation; -see [liveness](https://lan17.github.io/DialCache/coalescing.html#application-owned-budgets). - -## Metrics - -Optional [Prometheus and Datadog adapters](https://lan17.github.io/DialCache/observability.html) -report requests, misses by reason, errors, latency, and outcomes for shadow -validation and stale recovery. Custom backends implement the same adapter -interface. - ## Reference The [reference](https://lan17.github.io/DialCache/) covers setup, behavior, APIs, @@ -218,6 +199,9 @@ and operational details. It can also be | Look up methods, options, and exports | [API reference](https://lan17.github.io/DialCache/api.html) | | Set keys, layers, TTLs, and rollout policy | [Configuration](https://lan17.github.io/DialCache/configuration.html) | | Connect Redis or Valkey; customize serialization | [Redis and Valkey](https://lan17.github.io/DialCache/redis.html) | +| Invalidate cached results when an entity changes | [Targeted invalidation](https://lan17.github.io/DialCache/invalidation.html) | +| Serve a retained value when the source fails | [Stale-on-error](https://lan17.github.io/DialCache/stale-on-error.html) | +| Compare Redis with the source before serving it | [Shadow validation](https://lan17.github.io/DialCache/shadow-validation.html) | | Understand shared work and deadlines | [Coalescing and liveness](https://lan17.github.io/DialCache/coalescing.html) | | Build dashboards and diagnose misses | [Observability](https://lan17.github.io/DialCache/observability.html) | | Upgrade, validate, or contribute | [Upgrading](https://lan17.github.io/DialCache/upgrading.html) · [Maintainer guide](https://lan17.github.io/DialCache/maintainers.html) | diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index dc78ab4..5613336 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -7,7 +7,7 @@ const browserTargets = ["es2020", "chrome87", "edge88", "firefox78", "safari14.1 export default defineConfig({ title: "DialCache", description: - "Request-local, in-process, and Redis caching for TypeScript services. Learn the read path, configure runtime policies, and explore the API.", + "DialCache organizes caching into use cases, with runtime control and observability for each one. Reference for the read path, runtime policies, and the API.", lang: "en-US", base: "/DialCache/", vite: { diff --git a/docs/index.md b/docs/index.md index 5582c27..14b0647 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,9 +1,9 @@ # DialCache documentation -DialCache adds configurable read-through caching to TypeScript functions in -Node.js services. This reference explains the system from the outside in: -first the read path, then the policies that control it, then individual APIs -and integration contracts. +DialCache is a TypeScript library that organizes caching into use cases, with +runtime control and observability for each one. This reference explains the +system from the outside in: first the read path, then the policies that control +it, then individual APIs and integration contracts. ## Start here diff --git a/package.json b/package.json index d4b6efa..d3eb7a8 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "dialcache", "version": "0.23.2", - "description": "Fine-grained TypeScript caching with explicit local and Redis controls.", + "description": "A TypeScript library that organizes caching into use cases, with runtime control and observability for each one.", "license": "MIT", "sideEffects": false, "type": "module",