From 63a9b0dbc3424ffeb69ed1f4fb866a491c07f454 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Fri, 31 Jul 2026 11:55:24 -0700 Subject: [PATCH 1/5] feat: add cluster-aware batch invalidation --- README.md | 55 ++++-- package.json | 1 + scripts/benchmark-batch-invalidation.mjs | 79 +++++++++ scripts/test-package.mjs | 20 +++ src/dialcache.ts | 84 +++++++++ src/index.ts | 1 + src/internal/await-all.ts | 25 +++ src/internal/redis-cache.ts | 26 +++ src/internal/redis-cluster-slot.ts | 56 ++++++ src/node-redis.ts | 53 +++++- src/prometheus.ts | 2 +- src/redis-client.ts | 9 + src/valkey-glide.ts | 112 +++++++++++- test/dialcache-invalidation.test.ts | 213 +++++++++++++++++++++++ test/node-redis.test.ts | 141 +++++++++++++++ test/redis-cluster-slot.test.ts | 54 ++++++ test/redis-cluster.integration.test.ts | 111 +++++++++++- test/redis-real.integration.test.ts | 21 +++ test/valkey-glide.test.ts | 177 +++++++++++++++++++ 19 files changed, 1217 insertions(+), 23 deletions(-) create mode 100644 scripts/benchmark-batch-invalidation.mjs create mode 100644 src/internal/await-all.ts create mode 100644 src/internal/redis-cluster-slot.ts create mode 100644 test/redis-cluster-slot.test.ts diff --git a/README.md b/README.md index 7d6b4c1..27cab2d 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![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. +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 scalar and batch invalidation. ## Contents @@ -82,7 +82,7 @@ request-local cache -> process-local cache -> Redis cache -> fallback function - 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. - Selected tracked Redis keys can execute non-serving [shadow work](#shadow-validation) that validates hits and fills clean misses, even before Redis is allowed to serve callers. -- 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. +- 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` and `invalidateRemoteMany` log/count Redis failures and rethrow 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. @@ -185,7 +185,7 @@ const dialcache = new DialCache({ 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). +- **`keyType` + `id` is the invalidation unit for tracked Redis entries.** `dialcache.invalidateRemote("user_id", "123", futureBufferMs)` writes one watermark for that user; `invalidateRemoteMany([{ keyType: "user_id", id: "123" }], futureBufferMs)` applies the same operation to a batch. Any `trackForInvalidation` Redis entry with the same `keyType` and `id` is refreshed across all `args` variants when Redis is read. Neither method evicts 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. @@ -215,7 +215,7 @@ The disabled baseline sets `requestLocal` to false, leaves the process-local and `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. Top-level fields, cache-layer leaves, and leaves inside `shadow` merge independently; an explicit `false` logging flag overrides an inherited `true`. 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 the complete new-cache-invocation kill switch in one call: request-local and shadow work off, shadow logging off, and both shared layers ramped to 0. It does not cancel already-admitted work, and explicit maintenance operations such as `invalidateRemote()` remain available. To stop new cache-invocation Redis reads and fills while preserving other runtime settings, explicitly set both `ramp.remote` and `shadow.ramp` to `0`; the remote ramp alone stops serving but does not override an inherited nonzero shadow ramp. +A provider result of `null` (or defensive `undefined`) applies no overrides. An empty `DialCacheKeyConfig` and omitted runtime fields also inherit the baseline. Top-level fields, cache-layer leaves, and leaves inside `shadow` merge independently; an explicit `false` logging flag overrides an inherited `true`. 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 the complete new-cache-invocation kill switch in one call: request-local and shadow work off, shadow logging off, and both shared layers ramped to 0. It does not cancel already-admitted work, and explicit maintenance operations such as `invalidateRemote()` and `invalidateRemoteMany()` remain available. To stop new cache-invocation Redis reads and fills while preserving other runtime settings, explicitly set both `ramp.remote` and `shadow.ramp` to `0`; the remote ramp alone stops serving but does not override an inherited nonzero shadow ramp. 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 (a fixed 365-day duration), remote-read deadlines must be positive safe integers within their documented limit, layer and shadow ramps must be finite percentages from 0 to 100, layer maps and `shadow` must be objects, and `requestLocal` and `shadow.logMismatches` must be booleans when present. Invalid defaults are rejected immediately. @@ -390,13 +390,15 @@ Pass the same module namespace that created the client. DialCache uses its itself, so linked workspaces and applications with another installed GLIDE version cannot accidentally mix native script handles. -The application owns the complete Redis lifecycle. It creates and connects the underlying client and passes the semantic adapter to DialCache. During shutdown, stop starting DialCache-backed work and await every 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 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()`, `invalidateRemote()`, or `invalidateRemoteMany()`, including calls still running fallbacks that may later write Redis. Batch invalidation can have multiple in-flight slot partitions, so do not close the client after the first partition rejects. 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. Awaiting those public promises does not drain detached shadow work. Shadow scheduling and deadline timers are unreferenced and completion is not guaranteed during shutdown; Redis operations, source reads, serializers, and asynchronous telemetry already started by shadow work remain caller-owned and may still be active. Stop new work before closing their dependencies and accept that an in-flight shadow fill may have been dispatched even if its final outcome is lost during teardown. DialCache does not add a shutdown hook or keep the process alive to deliver best-effort outcomes. 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. +Node-redis computes each script's SHA, uses `EVALSHA`, and retries with `EVAL` after `NOSCRIPT`. Its cluster client routes scalar scripts by their first key and performs that fallback on the selected shard. For batch invalidation, the adapter uses one non-transactional pipeline on standalone Redis; on Redis Cluster it groups watermark keys by their exact hash slot and sends one routed pipeline per slot. The GLIDE adapter uses GLIDE's native `Script` lifecycle and byte decoder for scalar operations and, when the supplied structural runtime exposes them, its native standalone or cluster batch support for batch invalidation. Existing narrow GLIDE wrappers that expose only scalar scripting remain compatible through scalar fallback. Tracked reads are deliberately routed to primaries so a lagging replica cannot hide an invalidation watermark. + +The semantic `DialCacheRedisClient.invalidateMany` capability is optional. A custom adapter can implement it for client-native batching; an existing scalar-only adapter remains valid, and DialCache falls back to its `invalidate` operation for each target. Adapter batch implementations must preserve the scalar invalidation semantics, but the batching and Redis Cluster routing strategy stays inside the adapter rather than leaking client-specific concepts into the core API. #### Remote read deadlines and async liveness @@ -408,7 +410,7 @@ Same-key followers share the leader's remaining remote-read budget. The timer co 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. +Writes, scalar or batch 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 @@ -577,10 +579,15 @@ Use a narrower copy when its semantics are sufficient; the ownership boundary is ## 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: +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 one write or `dialcache.invalidateRemoteMany(targets, futureBufferMs)` after a write affecting multiple invalidation units. The buffer is an application-owned safety value; DialCache cannot choose a universally safe nonzero value: ```ts -import { CacheLayer, DialCache, DialCacheKeyConfig } from "dialcache"; +import { + CacheLayer, + DialCache, + DialCacheKeyConfig, + type RemoteInvalidationTarget, +} from "dialcache"; import { createNodeRedisDialCacheClient } from "dialcache/node-redis"; const dialcache = new DialCache({ @@ -608,13 +615,24 @@ const getUser = dialcache.cached( await updateUser("123", patch); await dialcache.invalidateRemote("user_id", "123", USER_INVALIDATION_BUFFER_MS); + +const affectedTargets = [ + { keyType: "user_id", id: "123" }, + { keyType: "organization_id", id: "acme" }, +] satisfies readonly RemoteInvalidationTarget[]; +await updateMembership("123", "acme", membershipPatch); +await dialcache.invalidateRemoteMany(affectedTargets, USER_INVALIDATION_BUFFER_MS); ``` +`invalidateRemoteMany` is one public semantic batch operation. An empty target list is a no-op and does not call Redis. Before dispatch, DialCache canonicalizes each id with `String(id)` and de-duplicates identical `keyType` plus canonical-id pairs, so numeric `1`, string `"1"`, and bigint `1n` for one key type advance one watermark. Each unique target records one invalidation metric. If a batch fails, invalidation errors are counted once per distinct key type rather than once per id, keeping metric cardinality bounded. + +Batch invalidation is optimized for fewer client round trips, not cross-key atomicity. Standalone pipelines can partially execute if a connection fails, and Redis Cluster slot partitions execute independently. DialCache waits for every dispatched partition to settle and then rejects when any failed, but a rejected call can still have complete, partial, or ambiguous server-side effects; there is no rollback. Retrying the full canonical target list is safe because each invalidation script advances its watermark monotonically and never lowers it. + 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. -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. A ramped-out invocation without shadow work does not consult the watermark; a selected shadow path does consult it for `C0` and any clean-miss fill, although caller-path request-local/process-local publication remains independent. +A cached Redis value whose Redis-created timestamp is older than or equal to the watermark is treated as stale and refreshed through fallback. Both invalidation methods set every selected 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. A ramped-out invocation without shadow work does not consult the watermark; a selected shadow path does consult it for `C0` and any clean-miss fill, although caller-path request-local/process-local publication remains independent. 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. @@ -628,7 +646,7 @@ Size the buffer to cover the maximum expected negative clock skew between promot 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. -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). +Targeted invalidation is remote-only and enforced by Redis watermarks. Neither `invalidateRemote` nor `invalidateRemoteMany` evicts 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). ## Request coalescing @@ -745,7 +763,7 @@ The Prometheus adapter emits: | `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_invalidation_counter` | Counter | `cache_namespace`, `key_type`, `layer` | Invalidation targets 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` | Sampled Redis shadow-job outcomes | | `dialcache_get_timer` | Histogram | `cache_namespace`, `use_case`, `key_type`, `layer` | Cache get latency in seconds | @@ -803,7 +821,7 @@ The Datadog adapter emits exact increments of `1` for counters and preserves sec | `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.invalidation.count` | Count | `cache_namespace`, `key_type`, `layer` | Invalidation targets 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` | Sampled Redis shadow-job outcomes | | `dialcache.get.duration` | Distribution or histogram | `cache_namespace`, `use_case`, `key_type`, `layer` | Cache get latency in seconds | @@ -848,6 +866,17 @@ pnpm benchmark:request-local The command builds `dist` before reporting ten scenarios: sequential request-local hits, sequential process-local hits, enabled bounded fallbacks, request-local coalescing fan-out, process coalescing fan-out, remote-read-deadline coalescing fan-out, tracked Redis hits with shadow omitted, tracked Redis hits deterministically outside a partial shadow ramp, a ramped-down warm-hit confirmation, and a ramped-down clean-miss fill. Both shadow scenarios prove that the caller completes before detached Redis work. The benchmark is a maintainer tool and is not included in the published package. It asserts fallback counts, Redis behavior, coalescing state, timer cleanup, returned values, exactly-once SoT reuse, and conditional confirmation/fill without applying a timing threshold. Override its work sizes with `DIALCACHE_BENCH_ITERATIONS` and `DIALCACHE_BENCH_FANOUT`. +### Batch-invalidation benchmark + +With a live standalone Redis available, compare one batch-invalidation call with concurrent scalar calls through the node-redis adapter: + +```bash +# Defaults to redis://127.0.0.1:6379. +DIALCACHE_BENCH_REDIS_URL=redis://127.0.0.1:6379 pnpm benchmark:batch-invalidation +``` + +The command builds `dist`, warms the Lua protocol, and reports 10, 100, and 1,000 unique targets for `invalidateRemoteMany(...)` versus `Promise.all(targets.map(invalidateRemote))`. It uses a unique namespace and never flushes Redis; the benchmark watermark keys expire under the normal invalidation protocol. Results are directional and depend on Redis topology, network latency, and client configuration, so the script intentionally asserts no performance threshold. The maintainer-only script is not included in the published package. + ### 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. diff --git a/package.json b/package.json index 5149ba8..e6c6332 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "LICENSE" ], "scripts": { + "benchmark:batch-invalidation": "pnpm build && node scripts/benchmark-batch-invalidation.mjs", "benchmark:request-local": "pnpm build && node scripts/benchmark-request-local.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", diff --git a/scripts/benchmark-batch-invalidation.mjs b/scripts/benchmark-batch-invalidation.mjs new file mode 100644 index 0000000..84a1d59 --- /dev/null +++ b/scripts/benchmark-batch-invalidation.mjs @@ -0,0 +1,79 @@ +import assert from "node:assert/strict"; +import { performance } from "node:perf_hooks"; + +import { createClient } from "redis"; + +import { DialCache, invalidationPrefix, redisClusterHashTag } from "../dist/index.js"; +import { + createNodeRedisDialCacheClient, + dialcacheRedisScripts, +} from "../dist/node-redis.js"; + +const redisUrl = process.env.DIALCACHE_BENCH_REDIS_URL ?? "redis://127.0.0.1:6379"; +const sizes = [10, 100, 1_000]; +const runId = `${process.pid}-${Date.now()}`; +const redisClient = createClient({ + url: redisUrl, + scripts: dialcacheRedisScripts, + disableOfflineQueue: true, + commandsQueueMaxLength: 10_000, + socket: { connectTimeout: 2_000 }, +}); +redisClient.on("error", () => undefined); + +await redisClient.connect(); + +try { + const namespace = `dialcache-batch-invalidation-benchmark-${runId}`; + const dialcache = new DialCache({ + namespace, + redis: { client: createNodeRedisDialCacheClient(redisClient) }, + }); + const results = []; + + // Pay one-time connection and Lua loading costs before the measured runs. + await dialcache.invalidateRemote("benchmark_warmup", runId); + + for (const size of sizes) { + const scalarTargets = targetsFor(`scalar-${size}`, size); + const scalarStartedAt = performance.now(); + await Promise.all( + scalarTargets.map(({ keyType, id }) => dialcache.invalidateRemote(keyType, id)), + ); + const scalarMs = performance.now() - scalarStartedAt; + assert.equal(await countWatermarks(namespace, scalarTargets), size); + + const batchTargets = targetsFor(`batch-${size}`, size); + const batchStartedAt = performance.now(); + await dialcache.invalidateRemoteMany(batchTargets); + const batchMs = performance.now() - batchStartedAt; + assert.equal(await countWatermarks(namespace, batchTargets), size); + + results.push({ + targets: size, + "Promise.all scalar (ms)": scalarMs.toFixed(2), + "single batch call (ms)": batchMs.toFixed(2), + "scalar / batch": (scalarMs / batchMs).toFixed(2), + }); + } + + console.table(results); + console.log( + "Directional maintainer benchmark only: results depend on Redis topology, client configuration, and network conditions; no timing threshold is asserted.", + ); +} finally { + await redisClient.quit(); +} + +function targetsFor(prefix, count) { + return Array.from({ length: count }, (_, index) => ({ + keyType: "benchmark_id", + id: `${runId}-${prefix}-${index}`, + })); +} + +async function countWatermarks(namespace, targets) { + const keys = targets.map(({ keyType, id }) => + `${redisClusterHashTag(invalidationPrefix(namespace, keyType, String(id)))}#watermark`); + return await redisClient.exists(keys); +} diff --git a/scripts/test-package.mjs b/scripts/test-package.mjs index b2a9c58..a8def43 100644 --- a/scripts/test-package.mjs +++ b/scripts/test-package.mjs @@ -40,6 +40,7 @@ const rootConsumer = `import { type RedisInvalidationRequest, type RedisReadContext, type RedisWriteRequest, + type RemoteInvalidationTarget, type Serializer, type ShadowComparator, type ShadowConfig, @@ -136,6 +137,12 @@ const datadogClassAdapter = new DatadogDialCacheMetrics(datadogOptions); // @ts-expect-error The observation type is an explicit, required choice. const missingObservationType: DatadogMetricsOptions = { client: dogStatsDClient }; const cache = new DialCache({ namespace: "consumer-cache", metrics }); +const remoteInvalidationTargets: readonly RemoteInvalidationTarget[] = [ + { keyType: "id", id: "123" }, + { keyType: "tenant_id", id: 456 }, + { keyType: "organization_id", id: 789n }, +]; +const batchInvalidation: Promise = cache.invalidateRemoteMany(remoteInvalidationTargets, 1_000); const redisProtocolError = new DialCacheRedisProtocolError("Invalid DialCache Redis write reply"); const fallbackTimeoutError = new FallbackTimeoutError("Load", 1_000); const redisReadTimeoutError = new RedisReadTimeoutError("Load", 100); @@ -324,12 +331,20 @@ const customRedisClient: DialCacheRedisClient = { write: async ({ value }) => typeof value === "string" || Buffer.isBuffer(value), invalidate: async () => undefined, }; +const cacheWithScalarOnlyInvalidation = new DialCache({ redis: { client: customRedisClient } }); +const scalarOnlyFallbackBatch: Promise = cacheWithScalarOnlyInvalidation.invalidateRemoteMany( + remoteInvalidationTargets, +); const redisClientMethods: Readonly> = { read: true, write: true, invalidate: true, + invalidateMany: true, }; void redisClientMethods; +const clientAllowsScalarOnlyInvalidation: {} extends Pick + ? true + : false = true; const cacheHasNoFlushAll: "flushAll" extends keyof DialCache ? false : true = true; const cacheHasNoClose: "close" extends keyof DialCache ? false : true = true; const clientHasNoFlushAll: "flushAll" extends keyof DialCacheRedisClient ? false : true = true; @@ -436,6 +451,11 @@ void unboundedErrorKind; void createNodeRedisDialCacheClient; void READ_CACHE_SCRIPT; void customRedisClient; +void cacheWithScalarOnlyInvalidation; +void scalarOnlyFallbackBatch; +void remoteInvalidationTargets; +void batchInvalidation; +void clientAllowsScalarOnlyInvalidation; const globalSerializer: Serializer = { dump: () => "global", load: () => ({ source: "global" }), diff --git a/src/dialcache.ts b/src/dialcache.ts index 90f3eca..ad085ea 100644 --- a/src/dialcache.ts +++ b/src/dialcache.ts @@ -49,6 +49,17 @@ type Id = string | number | bigint; /** A cache-key spec: a bare id, or an id plus extra (secondary) key dimensions. */ export type CacheKeySpec = Id | { readonly id: Id; readonly args?: CacheKeyArgs }; +/** One remote invalidation identity. */ +export interface RemoteInvalidationTarget { + readonly keyType: string; + readonly id: Id; +} + +interface NormalizedRemoteInvalidationTarget { + readonly keyType: string; + readonly id: string; +} + // "Any function" without using `any`, so Parameters/ReturnType still apply. type AnyFn = (...args: never[]) => unknown; /** The cached value type, derived from the wrapped function's return. */ @@ -543,6 +554,56 @@ export class DialCache { } } + /** + * Writes remote invalidation watermarks for multiple Redis-tracked identities. + * + * Canonically duplicate targets are coalesced. Each watermark update is + * atomic, but the batch is not atomic as a whole and can partially complete. + * Retrying the full batch is safe because watermarks advance monotonically. + * This has the same remote-only and future-buffer contract as + * {@link invalidateRemote}. + */ + async invalidateRemoteMany( + targets: readonly RemoteInvalidationTarget[], + futureBufferMs = 0, + ): Promise { + assertSupportedFutureBufferMs(futureBufferMs); + + if (this.redisCache === null) { + return; + } + + let normalizedTargets: readonly NormalizedRemoteInvalidationTarget[] = []; + try { + normalizedTargets = normalizeRemoteInvalidationTargets(targets); + if (normalizedTargets.length === 0) { + return; + } + + for (const { keyType } of normalizedTargets) { + this.metrics?.invalidation({ + cacheNamespace: this.namespace, + keyType, + layer: CacheLayer.REMOTE, + }); + } + await this.redisCache.invalidateMany(normalizedTargets, futureBufferMs, this.namespace); + } catch (error) { + this.logger.warn("Error writing DialCache invalidation watermarks", error); + for (const keyType of new Set(normalizedTargets.map(({ keyType }) => keyType))) { + this.metrics?.error({ + cacheNamespace: this.namespace, + useCase: "watermark", + keyType, + layer: CacheLayer.REMOTE, + error: "invalidation", + inFallback: false, + }); + } + throw error; + } + } + private async getThroughRequestLocal( requestLocalCache: RequestLocalCache, key: DialCacheKey, @@ -1495,6 +1556,29 @@ function withFallbackTimeout( }); } +function normalizeRemoteInvalidationTargets( + targets: readonly RemoteInvalidationTarget[], +): NormalizedRemoteInvalidationTarget[] { + const idsByKeyType = new Map>(); + const normalized: NormalizedRemoteInvalidationTarget[] = []; + + for (const target of targets) { + const id = String(target.id); + let ids = idsByKeyType.get(target.keyType); + if (ids === undefined) { + ids = new Set(); + idsByKeyType.set(target.keyType, ids); + } + if (ids.has(id)) { + continue; + } + ids.add(id); + normalized.push({ keyType: target.keyType, id }); + } + + return normalized; +} + function safeLogger(logger: Logger): Logger { return { debug: (...args: Parameters) => callObserver(() => logger.debug(...args)), diff --git a/src/index.ts b/src/index.ts index bfa5a63..64b3302 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,6 +32,7 @@ export type { CoalescingState, GetOrLoadOptions, ProcessCoalescingState, + RemoteInvalidationTarget, ShadowComparator, } from "./dialcache.js"; export { DialCacheKey, invalidationPrefix, normalizeArgs, redisClusterHashTag } from "./key.js"; diff --git a/src/internal/await-all.ts b/src/internal/await-all.ts new file mode 100644 index 0000000..61d967a --- /dev/null +++ b/src/internal/await-all.ts @@ -0,0 +1,25 @@ +/** Wait for every launched operation, preserving one error or aggregating many. */ +export async function awaitAll( + operations: readonly Promise[], + aggregateMessage: string, +): Promise { + const results = await Promise.allSettled(operations); + const errors: unknown[] = []; + const values: T[] = []; + + for (const result of results) { + if (result.status === "rejected") { + errors.push(result.reason); + } else { + values.push(result.value); + } + } + + if (errors.length === 1) { + throw errors[0]; + } + if (errors.length > 1) { + throw new AggregateError(errors, aggregateMessage); + } + return values; +} diff --git a/src/internal/redis-cache.ts b/src/internal/redis-cache.ts index 3241a5d..638fa7f 100644 --- a/src/internal/redis-cache.ts +++ b/src/internal/redis-cache.ts @@ -13,6 +13,7 @@ import { import type { DialCacheRedisClient, RedisCachePayload } from "../redis-client.js"; import { JsonSerializer, type Serializer } from "../serializer.js"; import type { RedisCacheGetResult } from "./cache-result.js"; +import { awaitAll } from "./await-all.js"; import { assertValidDeadlineMs, withMonotonicDeadline } from "./deadline.js"; import { cacheTtlSecToMs } from "./duration.js"; import { fetchKeyConfig, resolveLayerConfigResult, type ResolvedLayerConfig } from "./runtime-config.js"; @@ -257,6 +258,31 @@ export class RedisCache { }); } + async invalidateMany( + targets: readonly { readonly keyType: string; readonly id: string }[], + futureBufferMs = 0, + namespace = "urn", + ): Promise { + // Derive every key before dispatch so invalid input cannot partially mutate Redis. + const requests = targets.map(({ keyType, id }) => ({ + watermarkKey: this.redisWatermarkKey(namespace, keyType, id), + futureBufferMs, + })); + + if (requests.length === 0) { + return; + } + if (this.client.invalidateMany !== undefined) { + await this.client.invalidateMany(requests); + return; + } + + await awaitAll( + requests.map(async (request) => await this.client.invalidate(request)), + "Multiple DialCache invalidations failed", + ); + } + redisKey(key: DialCacheKey): string { return `${key.urn}${REDIS_FRAME_KEY_SUFFIX}`; } diff --git a/src/internal/redis-cluster-slot.ts b/src/internal/redis-cluster-slot.ts new file mode 100644 index 0000000..605019b --- /dev/null +++ b/src/internal/redis-cluster-slot.ts @@ -0,0 +1,56 @@ +const REDIS_CLUSTER_SLOT_COUNT = 16_384; +const CRC16_XMODEM_POLYNOMIAL = 0x1021; + +/** Returns the Redis Cluster hash slot for a UTF-8 string key. */ +export function redisClusterSlot(key: string): number { + return crc16Xmodem(Buffer.from(redisHashInput(key), "utf8")) % REDIS_CLUSTER_SLOT_COUNT; +} + +export function groupByRedisClusterSlot( + items: readonly T[], + keyOf: (item: T) => string, +): Map { + const groups = new Map(); + + for (const item of items) { + const slot = redisClusterSlot(keyOf(item)); + const group = groups.get(slot); + if (group === undefined) { + groups.set(slot, [item]); + } else { + group.push(item); + } + } + + return groups; +} + +function redisHashInput(key: string): string { + const tagStart = key.indexOf("{"); + if (tagStart === -1) { + return key; + } + + const tagEnd = key.indexOf("}", tagStart + 1); + if (tagEnd === -1 || tagEnd === tagStart + 1) { + return key; + } + + return key.slice(tagStart + 1, tagEnd); +} + +function crc16Xmodem(bytes: Uint8Array): number { + let crc = 0; + + for (const byte of bytes) { + crc ^= byte << 8; + for (let bit = 0; bit < 8; bit += 1) { + crc = (crc & 0x8000) === 0 + ? crc << 1 + : (crc << 1) ^ CRC16_XMODEM_POLYNOMIAL; + crc &= 0xffff; + } + } + + return crc; +} diff --git a/src/node-redis.ts b/src/node-redis.ts index 2093b8f..f819f25 100644 --- a/src/node-redis.ts +++ b/src/node-redis.ts @@ -1,5 +1,7 @@ import { commandOptions, defineScript } from "redis"; +import { awaitAll } from "./internal/await-all.js"; +import { groupByRedisClusterSlot } from "./internal/redis-cluster-slot.js"; import { INVALIDATE_CACHE_SCRIPT, READ_CACHE_SCRIPT, @@ -12,7 +14,7 @@ import { validateRedisScriptInvalidationReply, validateRedisScriptWriteReply, } from "./internal/redis-script-reply.js"; -import type { DialCacheRedisClient } from "./redis-client.js"; +import { DialCacheRedisProtocolError, type DialCacheRedisClient } from "./redis-client.js"; type BufferReplyOptions = ReturnType< typeof commandOptions<{ @@ -149,6 +151,43 @@ interface NodeRedisScriptClient { payload: string | Buffer, ): Promise; dialcacheInvalidate(watermarkKey: string, futureBufferMs: number): Promise; + multi(routing?: NodeRedisArgument): NodeRedisMultiCommand; + readonly slots?: unknown; +} + +interface NodeRedisMultiCommand { + dialcacheInvalidate(watermarkKey: string, futureBufferMs: number): NodeRedisMultiCommand; + execAsPipeline(): Promise; +} + +function isNodeRedisClusterClient(client: NodeRedisScriptClient): boolean { + return Array.isArray(client.slots); +} + +async function executeInvalidationPipeline( + client: NodeRedisScriptClient, + requests: readonly { readonly watermarkKey: string; readonly futureBufferMs: number }[], +): Promise { + const first = requests[0]; + if (first === undefined) { + return; + } + + // Supplying the first key is required for correct node-redis Cluster routing. + // Standalone clients harmlessly ignore the extra optional argument. + const pipeline = client.multi(first.watermarkKey); + for (const { watermarkKey, futureBufferMs } of requests) { + pipeline.dialcacheInvalidate(watermarkKey, futureBufferMs); + } + const replies = await pipeline.execAsPipeline(); + if (replies.length !== requests.length) { + throw new DialCacheRedisProtocolError( + `Invalid DialCache Redis invalidate batch reply count; expected ${requests.length}, received ${replies.length}`, + ); + } + for (const reply of replies) { + validateRedisScriptInvalidationReply(reply); + } } /** @@ -187,5 +226,17 @@ export function createNodeRedisDialCacheClient(client: NodeRedisScriptClient): D const result = await client.dialcacheInvalidate(watermarkKey, futureBufferMs); validateRedisScriptInvalidationReply(result); }, + async invalidateMany(requests) { + if (requests.length === 0) { + return; + } + const partitions = isNodeRedisClusterClient(client) + ? [...groupByRedisClusterSlot(requests, ({ watermarkKey }) => watermarkKey).values()] + : [requests]; + await awaitAll( + partitions.map(async (partition) => await executeInvalidationPipeline(client, partition)), + "Multiple DialCache invalidation partitions failed", + ); + }, }; } diff --git a/src/prometheus.ts b/src/prometheus.ts index 8c7d7fd..cff65a5 100644 --- a/src/prometheus.ts +++ b/src/prometheus.ts @@ -192,7 +192,7 @@ function collectorConfigs(prefix: string) { invalidationCounter: { type: "counter", name: `${prefix}dialcache_invalidation_counter`, - help: "DialCache invalidation calls by key type and layer.", + help: "DialCache invalidation targets by key type and layer.", labelNames: ["cache_namespace", "key_type", "layer"], }, coalescedCounter: { diff --git a/src/redis-client.ts b/src/redis-client.ts index 2bf18d6..c238fe8 100644 --- a/src/redis-client.ts +++ b/src/redis-client.ts @@ -110,4 +110,13 @@ export interface DialCacheRedisClient { * Its TTL is derived from the future buffer and any longer existing TTL. */ invalidate(request: RedisInvalidationRequest): Awaitable; + /** + * Advance multiple watermarks as one client-side batch when supported. + * + * Each invalidation remains independently atomic, but the batch is not + * atomic as a whole and can partially complete. A rejected operation can + * also have an ambiguous server-side outcome. Retrying the complete batch is + * safe because watermark advancement is monotonic. + */ + invalidateMany?(requests: readonly RedisInvalidationRequest[]): Awaitable; } diff --git a/src/valkey-glide.ts b/src/valkey-glide.ts index c63485c..6b53712 100644 --- a/src/valkey-glide.ts +++ b/src/valkey-glide.ts @@ -1,4 +1,5 @@ import { decodeRedisPayload, redisPayloadEncoding } from "./internal/redis-payload.js"; +import { awaitAll } from "./internal/await-all.js"; import { INVALIDATE_CACHE_SCRIPT, READ_CACHE_SCRIPT, @@ -10,7 +11,12 @@ import { validateRedisScriptInvalidationReply, validateRedisScriptWriteReply, } from "./internal/redis-script-reply.js"; -import { DialCacheRedisPayloadError, type DialCacheRedisClient } from "./redis-client.js"; +import { + DialCacheRedisPayloadError, + DialCacheRedisProtocolError, + type DialCacheRedisClient, + type RedisInvalidationRequest, +} from "./redis-client.js"; type ValkeyGlideString = string | Buffer; @@ -28,11 +34,35 @@ export interface ValkeyGlideScriptingClient { decoder: TDecoder; }, ): Promise; + /** Optional native batch capability; scalar scripting remains supported. */ + exec?( + batch: ValkeyGlideBatch, + raiseOnError: boolean, + options: { decoder: TDecoder }, + ): Promise; +} + +interface ValkeyGlideBatch { + customCommand(args: ValkeyGlideString[]): ValkeyGlideBatch; +} + +interface ValkeyGlideBatchConstructor { + new (isAtomic: boolean): ValkeyGlideBatch; +} + +interface ValkeyGlideClusterClientConstructor { + [Symbol.hasInstance](value: unknown): boolean; } export interface ValkeyGlideRuntime { /** The Script constructor exported by the same GLIDE module instance as the client. */ readonly Script: new (source: string) => TScript; + /** Optional standalone Batch constructor exported by that GLIDE module instance. */ + readonly Batch?: ValkeyGlideBatchConstructor; + /** Optional ClusterBatch constructor exported by that GLIDE module instance. */ + readonly ClusterBatch?: ValkeyGlideBatchConstructor; + /** Optional cluster client class exported by that GLIDE module instance. */ + readonly GlideClusterClient?: ValkeyGlideClusterClientConstructor; /** The Decoder enum exported by the same GLIDE module instance as the client. */ readonly Decoder: { readonly Bytes: TDecoder; @@ -48,6 +78,8 @@ interface DialCacheGlideScripts { } export interface ValkeyGlideDialCacheClient extends DialCacheRedisClient { + /** Advance multiple watermarks in one non-atomic GLIDE batch. */ + invalidateMany(requests: readonly RedisInvalidationRequest[]): Promise; /** Release the adapter-owned GLIDE Script handles. Does not close the wrapped GLIDE client. */ dispose(): void; } @@ -76,22 +108,26 @@ export function createValkeyGlideDialCacheClient => { + const invokeTracked = async (operation: () => Promise): Promise => { if (disposed) { throw new Error("Valkey GLIDE DialCache client is disposed"); } activeInvocations += 1; try { - return await client.invokeScript(script, { keys, args, decoder: glide.Decoder.Bytes }); + return await operation(); } finally { activeInvocations -= 1; } }; + const invoke = async ( + script: TScript, + keys: ValkeyGlideString[], + args: ValkeyGlideString[] = [], + ): Promise => invokeTracked( + async () => await client.invokeScript(script, { keys, args, decoder: glide.Decoder.Bytes }), + ); + return { async read({ valueKey, watermarkKey }) { const raw = watermarkKey === undefined @@ -125,6 +161,68 @@ export function createValkeyGlideDialCacheClient { + await awaitAll( + requests.map(async ({ watermarkKey, futureBufferMs }) => { + const raw = await client.invokeScript(scripts.invalidate, { + keys: [watermarkKey], + args: [String(futureBufferMs)], + decoder: glide.Decoder.Bytes, + }); + validateRedisScriptInvalidationReply(raw); + }), + "Multiple DialCache invalidations failed", + ); + }); + return; + } + + const raw = await invokeTracked( + async () => { + const Batch = client instanceof GlideClusterClient + ? ClusterBatch + : StandaloneBatch; + const batch = new Batch(false); + for (const { watermarkKey, futureBufferMs } of requests) { + batch.customCommand([ + "EVAL", + INVALIDATE_CACHE_SCRIPT, + "1", + watermarkKey, + String(futureBufferMs), + ]); + } + return await exec( + batch, + true, + { decoder: glide.Decoder.Bytes }, + ); + }, + ); + if (!Array.isArray(raw) || raw.length !== requests.length) { + throw new DialCacheRedisProtocolError( + `Invalid DialCache Redis invalidate batch reply; expected ${requests.length} replies`, + ); + } + for (const reply of raw) { + validateRedisScriptInvalidationReply(reply); + } + }, dispose() { if (disposed) { return; diff --git a/test/dialcache-invalidation.test.ts b/test/dialcache-invalidation.test.ts index dae7517..7d1a0e8 100644 --- a/test/dialcache-invalidation.test.ts +++ b/test/dialcache-invalidation.test.ts @@ -9,6 +9,7 @@ import { redisClusterHashTag, type CacheMetricLabels, type DisabledMetricLabels, + type DialCacheRedisClient, type ErrorMetricLabels, type DialCacheMetricsAdapter, type InvalidationMetricLabels, @@ -74,6 +75,14 @@ const MAX_CACHE_TTL_SEC = 31_536_000; const MAX_SUPPORTED_DURATION_MS = 31_536_000_000; const WATERMARK_TTL_MARGIN_MS = 60_000; +function deferred(): { readonly promise: Promise; resolve(): void } { + let resolve!: () => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + describe("DialCache targeted invalidation watermarks", () => { beforeEach(() => { vi.useFakeTimers(); @@ -403,6 +412,210 @@ describe("DialCache targeted invalidation watermarks", () => { }); }); + it("batches unique canonical invalidation targets through an optimized client", async () => { + const invalidate = vi.fn(async () => undefined); + const invalidateMany = vi.fn(async () => undefined); + const redis: DialCacheRedisClient = { + read: async () => null, + write: async () => true, + invalidate, + invalidateMany, + }; + const metrics = new RecordingMetrics(); + const dialcache = new DialCache({ + namespace: "batch:cache", + redis: { client: redis, readTimeoutMs: 1_000 }, + metrics, + }); + + await dialcache.invalidateRemoteMany([ + { keyType: "user_id", id: 1 }, + { keyType: "user_id", id: "1" }, + { keyType: "user_id", id: 1n }, + { keyType: "account_id", id: "1" }, + { keyType: "user_id", id: "2" }, + ], 250); + + expect(invalidate).not.toHaveBeenCalled(); + expect(invalidateMany).toHaveBeenCalledOnce(); + expect(invalidateMany).toHaveBeenCalledWith([ + { watermarkKey: "{batch%3Acache:user_id:1}#watermark", futureBufferMs: 250 }, + { watermarkKey: "{batch%3Acache:account_id:1}#watermark", futureBufferMs: 250 }, + { watermarkKey: "{batch%3Acache:user_id:2}#watermark", futureBufferMs: 250 }, + ]); + expect(metrics.events.filter(({ name }) => name === "invalidation")).toEqual([ + { + name: "invalidation", + labels: { cacheNamespace: "batch:cache", keyType: "user_id", layer: CacheLayer.REMOTE }, + }, + { + name: "invalidation", + labels: { cacheNamespace: "batch:cache", keyType: "account_id", layer: CacheLayer.REMOTE }, + }, + { + name: "invalidation", + labels: { cacheNamespace: "batch:cache", keyType: "user_id", layer: CacheLayer.REMOTE }, + }, + ]); + }); + + it("keeps scalar-only custom clients compatible and waits for every launched invalidation", async () => { + const gate = deferred(); + const firstError = new Error("first invalidation failed"); + const invalidate = vi.fn(async ({ watermarkKey }: { readonly watermarkKey: string }) => { + if (watermarkKey.includes(":first}")) { + throw firstError; + } + await gate.promise; + }); + const redis: DialCacheRedisClient = { + read: async () => null, + write: async () => true, + invalidate, + }; + const dialcache = new DialCache({ redis: { client: redis, readTimeoutMs: 1_000 } }); + + let settled = false; + const operation = dialcache.invalidateRemoteMany([ + { keyType: "user_id", id: "first" }, + { keyType: "user_id", id: "second" }, + ]).finally(() => { + settled = true; + }); + await vi.waitFor(() => expect(invalidate).toHaveBeenCalledTimes(2)); + await Promise.resolve(); + expect(settled).toBe(false); + + gate.resolve(); + await expect(operation).rejects.toBe(firstError); + expect(settled).toBe(true); + }); + + it("aggregates multiple scalar fallback failures in target order", async () => { + const errors = [new Error("first"), new Error("second")]; + let call = 0; + const redis: DialCacheRedisClient = { + read: async () => null, + write: async () => true, + invalidate: async () => { + throw errors[call++]!; + }, + }; + const dialcache = new DialCache({ redis: { client: redis, readTimeoutMs: 1_000 } }); + + let rejection: unknown; + try { + await dialcache.invalidateRemoteMany([ + { keyType: "user_id", id: "first" }, + { keyType: "user_id", id: "second" }, + ]); + } catch (error) { + rejection = error; + } + + expect(rejection).toBeInstanceOf(AggregateError); + expect((rejection as AggregateError).errors).toEqual(errors); + }); + + it("validates the whole batch before dispatch and treats an empty batch as a no-op", async () => { + const invalidate = vi.fn(async () => undefined); + const invalidateMany = vi.fn(async () => undefined); + const redis: DialCacheRedisClient = { + read: async () => null, + write: async () => true, + invalidate, + invalidateMany, + }; + const metrics = new RecordingMetrics(); + const dialcache = new DialCache({ redis: { client: redis, readTimeoutMs: 1_000 }, metrics }); + + await dialcache.invalidateRemoteMany([]); + await expect(dialcache.invalidateRemoteMany([], -1)).rejects.toThrow("futureBufferMs"); + await expect(dialcache.invalidateRemoteMany([ + { keyType: "user_id", id: "valid" }, + { keyType: "user_id", id: "{invalid}" }, + ])).rejects.toThrow(/hash tag/); + + expect(invalidate).not.toHaveBeenCalled(); + expect(invalidateMany).not.toHaveBeenCalled(); + expect(metrics.events.filter(({ name }) => name === "invalidation")).toHaveLength(2); + }); + + it("logs batch failure once and records each distinct targeted key type", async () => { + const batchError = new Error("batch failed"); + const redis: DialCacheRedisClient = { + read: async () => null, + write: async () => true, + invalidate: async () => undefined, + invalidateMany: async () => { + throw batchError; + }, + }; + const logger = { debug: vi.fn(), warn: vi.fn(), error: vi.fn() }; + const metrics = new RecordingMetrics(); + const dialcache = new DialCache({ redis: { client: redis, readTimeoutMs: 1_000 }, logger, metrics }); + + await expect(dialcache.invalidateRemoteMany([ + { keyType: "user_id", id: "1" }, + { keyType: "user_id", id: "2" }, + { keyType: "account_id", id: "1" }, + ])).rejects.toBe(batchError); + + expect(logger.warn).toHaveBeenCalledOnce(); + expect(logger.warn).toHaveBeenCalledWith("Error writing DialCache invalidation watermarks", batchError); + expect(metrics.events.filter(({ name }) => name === "error")).toEqual([ + { + name: "error", + labels: { + cacheNamespace: "urn", + useCase: "watermark", + keyType: "user_id", + layer: CacheLayer.REMOTE, + error: "invalidation", + inFallback: false, + }, + }, + { + name: "error", + labels: { + cacheNamespace: "urn", + useCase: "watermark", + keyType: "account_id", + layer: CacheLayer.REMOTE, + error: "invalidation", + inFallback: false, + }, + }, + ]); + }); + + it("refreshes multiple tracked identities after one public batch call", async () => { + const redis = new FakeRedis(); + const dialcache = new DialCache({ redis: { client: redis, readTimeoutMs: 1_000 } }); + const versions = new Map([["1", 1], ["2", 1]]); + const getUser = dialcache.cached(async (id: string) => ({ id, version: versions.get(id)! }), { + keyType: "user_id", + useCase: "BatchRefreshUsers", + cacheKey: (id) => id, + trackForInvalidation: true, + defaultConfig: remoteOnly(), + }); + + await dialcache.enable(async () => await Promise.all([getUser("1"), getUser("2")])); + versions.set("1", 2); + versions.set("2", 2); + await dialcache.invalidateRemoteMany([ + { keyType: "user_id", id: "1" }, + { keyType: "user_id", id: "2" }, + ]); + vi.advanceTimersByTime(1); + + await expect(dialcache.enable(async () => await Promise.all([getUser("1"), getUser("2")]))).resolves.toEqual([ + { id: "1", version: 2 }, + { id: "2", version: 2 }, + ]); + }); + it("rejects invalid future buffers before calling Redis", async () => { const redis = new FakeRedis(); const dialcache = new DialCache({ redis: { client: redis, readTimeoutMs: 1_000 } }); diff --git a/test/node-redis.test.ts b/test/node-redis.test.ts index 0447429..c66a08b 100644 --- a/test/node-redis.test.ts +++ b/test/node-redis.test.ts @@ -41,6 +41,43 @@ function fakeClient(replies: FakeReplies = {}) { }; } +interface FakePipeline { + readonly routing: string | Buffer | undefined; + readonly commands: Array; + readonly execAsPipeline: ReturnType; +} + +function fakeBatchClient(options: { + readonly cluster?: boolean; + readonly execute?: (pipeline: FakePipeline) => Promise; +} = {}) { + const pipelines: FakePipeline[] = []; + const client = { + ...fakeClient(), + ...(options.cluster === true ? { slots: [] } : {}), + multi: vi.fn((routing?: string | Buffer) => { + const commands: Array = []; + const pipeline: FakePipeline & { + dialcacheInvalidate(watermarkKey: string, futureBufferMs: number): unknown; + } = { + routing, + commands, + dialcacheInvalidate(watermarkKey: string, futureBufferMs: number) { + commands.push([watermarkKey, futureBufferMs]); + return pipeline; + }, + execAsPipeline: vi.fn(async () => + options.execute === undefined + ? commands.map(() => 1) + : await options.execute(pipeline)), + }; + pipelines.push(pipeline); + return pipeline; + }), + }; + return { client, pipelines }; +} + async function expectProtocolError(operation: Promise, message: string): Promise { let rejection: unknown; try { @@ -113,6 +150,110 @@ describe("node-redis adapter", () => { ).resolves.toBeUndefined(); }); + it("pipelines a standalone invalidation batch in one explicitly routed call", async () => { + const { client, pipelines } = fakeBatchClient(); + const adapter = createNodeRedisDialCacheClient(client as never); + + await adapter.invalidateMany?.([ + { watermarkKey: "cache:{one}:watermark", futureBufferMs: 0 }, + { watermarkKey: "cache:{two}:watermark", futureBufferMs: 250 }, + ]); + + expect(client.multi).toHaveBeenCalledOnce(); + expect(client.multi).toHaveBeenCalledWith("cache:{one}:watermark"); + expect(pipelines).toHaveLength(1); + expect(pipelines[0]?.commands).toEqual([ + ["cache:{one}:watermark", 0], + ["cache:{two}:watermark", 250], + ]); + expect(pipelines[0]?.execAsPipeline).toHaveBeenCalledOnce(); + }); + + it("partitions node-redis Cluster pipelines by exact slot", async () => { + const { client, pipelines } = fakeBatchClient({ cluster: true }); + const adapter = createNodeRedisDialCacheClient(client as never); + + await adapter.invalidateMany?.([ + { watermarkKey: "cache:{k-620}:watermark", futureBufferMs: 10 }, + { watermarkKey: "cache:{different}:watermark", futureBufferMs: 20 }, + { watermarkKey: "cache:{k-1000}:watermark", futureBufferMs: 30 }, + ]); + + expect(client.multi).toHaveBeenCalledTimes(2); + expect(pipelines.map(({ routing }) => routing)).toEqual([ + "cache:{k-620}:watermark", + "cache:{different}:watermark", + ]); + expect(pipelines[0]?.commands).toEqual([ + ["cache:{k-620}:watermark", 10], + ["cache:{k-1000}:watermark", 30], + ]); + expect(pipelines[1]?.commands).toEqual([ + ["cache:{different}:watermark", 20], + ]); + }); + + it("waits for every Cluster partition before surfacing a failure", async () => { + let releaseSecond!: () => void; + const secondGate = new Promise((resolve) => { + releaseSecond = resolve; + }); + const firstError = new Error("first partition failed"); + const { client, pipelines } = fakeBatchClient({ + cluster: true, + execute: async ({ routing, commands }) => { + if (routing === "cache:{one}:watermark") { + throw firstError; + } + await secondGate; + return commands.map(() => 1); + }, + }); + const adapter = createNodeRedisDialCacheClient(client as never); + + let settled = false; + const operation = Promise.resolve(adapter.invalidateMany?.([ + { watermarkKey: "cache:{one}:watermark", futureBufferMs: 0 }, + { watermarkKey: "cache:{two}:watermark", futureBufferMs: 0 }, + ])).finally(() => { + settled = true; + }); + await vi.waitFor(() => expect(pipelines).toHaveLength(2)); + await Promise.resolve(); + expect(settled).toBe(false); + + releaseSecond(); + await expect(operation).rejects.toBe(firstError); + expect(settled).toBe(true); + }); + + it("rejects malformed invalidation batch replies and skips empty batches", async () => { + const tooShort = fakeBatchClient({ execute: async () => [1] }); + const shortAdapter = createNodeRedisDialCacheClient(tooShort.client as never); + await expectProtocolError( + shortAdapter.invalidateMany?.([ + { watermarkKey: "cache:{one}:watermark", futureBufferMs: 0 }, + { watermarkKey: "cache:{two}:watermark", futureBufferMs: 0 }, + ]) ?? Promise.resolve(), + "Invalid DialCache Redis invalidate batch reply count; expected 2, received 1", + ); + + const malformed = fakeBatchClient({ execute: async () => [1, 0] }); + const malformedAdapter = createNodeRedisDialCacheClient(malformed.client as never); + await expectProtocolError( + malformedAdapter.invalidateMany?.([ + { watermarkKey: "cache:{one}:watermark", futureBufferMs: 0 }, + { watermarkKey: "cache:{two}:watermark", futureBufferMs: 0 }, + ]) ?? Promise.resolve(), + "Invalid DialCache Redis invalidate reply; expected integer 1", + ); + + const empty = fakeBatchClient(); + const emptyAdapter = createNodeRedisDialCacheClient(empty.client as never); + await emptyAdapter.invalidateMany?.([]); + expect(empty.client.multi).not.toHaveBeenCalled(); + }); + it("passes the cooperative read signal through node-redis command options", async () => { const client = fakeClient(); const adapter = createNodeRedisDialCacheClient(client as never); diff --git a/test/redis-cluster-slot.test.ts b/test/redis-cluster-slot.test.ts new file mode 100644 index 0000000..71cc097 --- /dev/null +++ b/test/redis-cluster-slot.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; + +import { + groupByRedisClusterSlot, + redisClusterSlot, +} from "../src/internal/redis-cluster-slot.js"; + +describe("Redis Cluster slot calculation", () => { + it.each([ + ["", 0], + ["123456789", 12_739], + ["somekey", 11_058], + ["foo{bar}", 5_061], + ["{user1000}.following", 3_443], + ["{user1000}.followers", 3_443], + ])("maps %j to slot %i", (key, expectedSlot) => { + expect(redisClusterSlot(key)).toBe(expectedSlot); + }); + + it("uses the first valid hash tag and hashes the whole key when the first braces are empty", () => { + expect(redisClusterSlot("foo{bar}{zap}")) + .toBe(redisClusterSlot("bar")); + expect(redisClusterSlot("foo{}{bar}")) + .toBe(8_363); + expect(redisClusterSlot("foo{}{bar}")) + .not.toBe(redisClusterSlot("bar")); + expect(redisClusterSlot("foo{{bar}}zap")) + .toBe(redisClusterSlot("{bar")); + }); + + it.each([ + ["mañana", 8_542], + ["{東京}:key", 16_157], + ])("hashes the UTF-8 bytes of %j", (key, expectedSlot) => { + expect(redisClusterSlot(key)).toBe(expectedSlot); + }); + + it("groups different hash tags that collide on the same exact slot", () => { + const requests = [ + { watermarkKey: "prefix:{k-620}:watermark", id: "first" }, + { watermarkKey: "prefix:{different}:watermark", id: "other" }, + { watermarkKey: "prefix:{k-1000}:watermark", id: "second" }, + ]; + + expect(redisClusterSlot(requests[0]!.watermarkKey)).toBe(6_474); + expect(redisClusterSlot(requests[2]!.watermarkKey)).toBe(6_474); + + const groups = groupByRedisClusterSlot(requests, ({ watermarkKey }) => watermarkKey); + + expect(groups.get(6_474)).toEqual([requests[0], requests[2]]); + expect([...groups.values()].flat()).toHaveLength(requests.length); + expect(groups.size).toBe(2); + }); +}); diff --git a/test/redis-cluster.integration.test.ts b/test/redis-cluster.integration.test.ts index 1a5f721..3209478 100644 --- a/test/redis-cluster.integration.test.ts +++ b/test/redis-cluster.integration.test.ts @@ -8,7 +8,15 @@ import { } from "testcontainers"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { CacheLayer, DialCache, DialCacheKeyConfig, type DialCacheRedisClient } from "../src/index.js"; +import { + CacheLayer, + DialCache, + DialCacheKeyConfig, + invalidationPrefix, + redisClusterHashTag, + type DialCacheRedisClient, +} from "../src/index.js"; +import { redisClusterSlot } from "../src/internal/redis-cluster-slot.js"; import { createNodeRedisDialCacheClient, dialcacheRedisScripts } from "../src/node-redis.js"; const remoteOnly = new DialCacheKeyConfig({ @@ -190,6 +198,107 @@ describe("DialCache Lua protocol on Redis Cluster", () => { await expect(cluster.dialcacheReadTracked("{slot-a}:value", "{slot-b}:watermark")).rejects.toThrow(/CROSSSLOT/); }); + it("batches same-slot and cross-slot invalidations after per-node SCRIPT FLUSH", async () => { + if (cluster === undefined) { + throw new Error("Redis Cluster did not start"); + } + const activeCluster = cluster; + const namespace = "cluster-batch"; + const keyType = "item_id"; + const watermarkFor = (id: string) => + `${redisClusterHashTag(invalidationPrefix(namespace, keyType, id))}#watermark`; + const idsBySlot = new Map(); + let sameSlotIds: readonly [string, string] | undefined; + // A collision is guaranteed after 16,384 distinct keys, though one usually appears much sooner. + for (let index = 0; index <= 16_384 && sameSlotIds === undefined; index += 1) { + const id = `item-${index}`; + const slot = redisClusterSlot(watermarkFor(id)); + const existing = idsBySlot.get(slot); + if (existing === undefined) { + idsBySlot.set(slot, id); + } else { + sameSlotIds = [existing, id]; + } + } + if (sameSlotIds === undefined) { + throw new Error("Could not find two generated invalidation keys in the same Redis Cluster slot"); + } + const sameSlot = redisClusterSlot(watermarkFor(sameSlotIds[0])); + const sameSlotOwner = activeCluster.slots[sameSlot]?.master.id; + if (sameSlotOwner === undefined) { + throw new Error("Could not resolve the primary owning the generated same-slot keys"); + } + let otherSlotId: string | undefined; + for (let index = 0; index <= 16_384; index += 1) { + const id = `item-${index}`; + const slotOwner = activeCluster.slots[redisClusterSlot(watermarkFor(id))]?.master.id; + if (slotOwner !== undefined && slotOwner !== sameSlotOwner) { + otherSlotId = id; + break; + } + } + if (otherSlotId === undefined) { + throw new Error("Could not find an invalidation key owned by a different Redis Cluster primary"); + } + const ids = [...sameSlotIds, otherSlotId]; + const firstMaster = activeCluster.masters[0]; + if (firstMaster === undefined) { + throw new Error("Redis Cluster has no primary nodes"); + } + const slotInspector = await activeCluster.nodeClient(firstMaster); + + for (const key of [ + ...ids.map(watermarkFor), + "123456789", + "foo{}{bar}", + "unicode:{café}:key", + ]) { + expect(await slotInspector.clusterKeySlot(key)).toBe(redisClusterSlot(key)); + } + expect(await slotInspector.clusterKeySlot(watermarkFor(ids[0]!))).toBe( + await slotInspector.clusterKeySlot(watermarkFor(ids[1]!)), + ); + expect(await slotInspector.clusterKeySlot(watermarkFor(ids[2]!))).not.toBe( + await slotInspector.clusterKeySlot(watermarkFor(ids[0]!)), + ); + expect(activeCluster.slots[redisClusterSlot(watermarkFor(ids[2]!))]?.master.id).not.toBe( + sameSlotOwner, + ); + + const scriptClient = createNodeRedisDialCacheClient(activeCluster); + const dialcache = new DialCache({ + namespace, + redis: { client: scriptClient, readTimeoutMs: 10_000 }, + }); + const versions = new Map(ids.map((id) => [id, 1])); + const getValue = dialcache.cached(async (id: string) => ({ id, version: versions.get(id)! }), { + keyType, + useCase: "ClusterBatchInvalidation", + cacheKey: (id) => id, + trackForInvalidation: true, + defaultConfig: remoteOnly, + }); + + const before = await dialcache.enable(async () => await Promise.all(ids.map(getValue))); + for (const id of ids) { + versions.set(id, 2); + } + await Promise.all( + activeCluster.masters.map(async (master) => { + const client = await activeCluster.nodeClient(master); + await client.scriptFlush(); + }), + ); + await dialcache.invalidateRemoteMany(ids.map((id) => ({ keyType, id }))); + await new Promise((resolve) => setTimeout(resolve, 2)); + const after = await dialcache.enable(async () => await Promise.all(ids.map(getValue))); + + expect(before).toEqual(ids.map((id) => ({ id, version: 1 }))); + expect(after).toEqual(ids.map((id) => ({ id, version: 2 }))); + const watermarks = await Promise.all(ids.map(async (id) => await activeCluster.get(watermarkFor(id)))); + expect(watermarks.every((watermark) => watermark !== null && /^\d+$/.test(watermark))).toBe(true); + }); + it("round-trips binary payloads through cluster script routing", async () => { if (cluster === undefined) { throw new Error("Redis Cluster did not start"); diff --git a/test/redis-real.integration.test.ts b/test/redis-real.integration.test.ts index 4ca5df9..ad05dad 100644 --- a/test/redis-real.integration.test.ts +++ b/test/redis-real.integration.test.ts @@ -803,6 +803,27 @@ describe.each(engines)("DialCache Lua protocol on $name", ({ image }) => { ).resolves.toBeUndefined(); }); + it("batches invalidation scripts after SCRIPT FLUSH", async () => { + if (client === undefined || admin === undefined) { + throw new Error("Redis test clients did not start"); + } + const invalidateMany = client.adapter.invalidateMany; + if (invalidateMany === undefined) { + throw new Error("Bundled adapter does not support batch invalidation"); + } + const requests = ["one", "two", "three"].map((id) => ({ + watermarkKey: `batch-recovery:{item:${id}}:watermark`, + futureBufferMs: 100, + })); + + await admin.scriptFlush(); + await expect(invalidateMany.call(client.adapter, requests)).resolves.toBeUndefined(); + + const watermarks = await admin.mGet(requests.map(({ watermarkKey }) => watermarkKey)); + expect(watermarks).toHaveLength(requests.length); + expect(watermarks.every((watermark) => watermark !== null && /^\d+$/.test(watermark))).toBe(true); + }); + it("treats every invalid read frame and watermark state as a miss", async () => { if (client === undefined || admin === undefined) { throw new Error("Redis test clients did not start"); diff --git a/test/valkey-glide.test.ts b/test/valkey-glide.test.ts index cb7d405..ada7bda 100644 --- a/test/valkey-glide.test.ts +++ b/test/valkey-glide.test.ts @@ -24,6 +24,8 @@ const INVALID_INVALIDATION_REPLIES: readonly unknown[] = [0, ...INVALID_WRITE_RE const decoderBytes = Symbol("bytes"); const scriptInstances: MockScript[] = []; +const batchInstances: MockBatch[] = []; +const clusterBatchInstances: MockClusterBatch[] = []; class MockScript { readonly release = vi.fn(); @@ -33,8 +35,41 @@ class MockScript { } } +class MockBatch { + readonly commands: Array> = []; + + constructor(readonly isAtomic: boolean) { + batchInstances.push(this); + } + + customCommand(args: Array): this { + this.commands.push(args); + return this; + } +} + +class MockClusterBatch extends MockBatch { + constructor(isAtomic: boolean) { + super(isAtomic); + clusterBatchInstances.push(this); + } +} + +class MockClusterClient { + readonly invokeScript = vi.fn( + async (_script: MockScript, _options: InvokeScriptOptions): Promise => null, + ); + readonly exec = vi.fn( + async (_batch: MockClusterBatch, _raiseOnError: boolean, _options: { decoder: typeof decoderBytes }) => + [1], + ); +} + const mockGlide = { + Batch: MockBatch, + ClusterBatch: MockClusterBatch, Decoder: { Bytes: decoderBytes }, + GlideClusterClient: MockClusterClient, Script: MockScript, }; @@ -46,6 +81,13 @@ interface InvokeScriptOptions { function fakeClient(...replies: unknown[]) { return { + exec: vi.fn( + async ( + _batch: MockBatch, + _raiseOnError: boolean, + _options: { decoder: typeof decoderBytes }, + ): Promise => [], + ), invokeScript: vi.fn(async (_script: MockScript, _options: InvokeScriptOptions) => replies.shift()), }; } @@ -64,6 +106,8 @@ async function expectProtocolError(operation: Promise, message: string) describe("Valkey GLIDE adapter", () => { beforeEach(() => { scriptInstances.length = 0; + batchInstances.length = 0; + clusterBatchInstances.length = 0; }); it("invokes distinct read scripts with byte decoding", async () => { @@ -151,6 +195,111 @@ describe("Valkey GLIDE adapter", () => { ); }); + it("executes standalone invalidations in one non-atomic batch", async () => { + const client = fakeClient(); + client.exec.mockResolvedValueOnce([1, 1]); + const adapter = createValkeyGlideDialCacheClient(client, mockGlide); + + await expect(adapter.invalidateMany([ + { watermarkKey: "cache:{one}:watermark", futureBufferMs: 0 }, + { watermarkKey: "cache:{two}:watermark", futureBufferMs: 250 }, + ])).resolves.toBeUndefined(); + + expect(batchInstances).toHaveLength(1); + expect(clusterBatchInstances).toHaveLength(0); + expect(batchInstances[0]).toMatchObject({ + isAtomic: false, + commands: [ + ["EVAL", expect.any(String), "1", "cache:{one}:watermark", "0"], + ["EVAL", expect.any(String), "1", "cache:{two}:watermark", "250"], + ], + }); + expect(client.exec).toHaveBeenCalledTimes(1); + expect(client.exec).toHaveBeenCalledWith( + batchInstances[0], + true, + { decoder: decoderBytes }, + ); + }); + + it("keeps legacy scalar-only GLIDE wrappers compatible", async () => { + const client = { + invokeScript: vi.fn(async () => 1), + }; + const legacyGlide = { + Decoder: { Bytes: decoderBytes }, + Script: MockScript, + }; + const adapter = createValkeyGlideDialCacheClient(client, legacyGlide); + + await expect(adapter.invalidateMany([ + { watermarkKey: "cache:{one}:watermark", futureBufferMs: 0 }, + { watermarkKey: "cache:{two}:watermark", futureBufferMs: 250 }, + ])).resolves.toBeUndefined(); + + expect(client.invokeScript).toHaveBeenCalledTimes(2); + expect(client.invokeScript).toHaveBeenNthCalledWith( + 1, + expect.any(MockScript), + { keys: ["cache:{one}:watermark"], args: ["0"], decoder: decoderBytes }, + ); + expect(client.invokeScript).toHaveBeenNthCalledWith( + 2, + expect.any(MockScript), + { keys: ["cache:{two}:watermark"], args: ["250"], decoder: decoderBytes }, + ); + }); + + it("uses one native cluster batch for invalidations across hash slots", async () => { + const client = new MockClusterClient(); + client.exec.mockResolvedValueOnce([1, 1]); + const adapter = createValkeyGlideDialCacheClient(client, mockGlide); + + await expect(adapter.invalidateMany([ + { watermarkKey: "cache:{one}:watermark", futureBufferMs: 0 }, + { watermarkKey: "cache:{two}:watermark", futureBufferMs: 100 }, + ])).resolves.toBeUndefined(); + + expect(clusterBatchInstances).toHaveLength(1); + expect(clusterBatchInstances[0]).toMatchObject({ isAtomic: false }); + expect(client.exec).toHaveBeenCalledTimes(1); + expect(client.exec).toHaveBeenCalledWith( + clusterBatchInstances[0], + true, + { decoder: decoderBytes }, + ); + }); + + it("rejects malformed invalidation batch replies", async () => { + const invalidationMessage = "Invalid DialCache Redis invalidate reply; expected integer 1"; + + for (const reply of [null, [], [1, 1, 1]]) { + const client = fakeClient(); + client.exec.mockResolvedValueOnce(reply); + const adapter = createValkeyGlideDialCacheClient(client, mockGlide); + await expectProtocolError( + adapter.invalidateMany([ + { watermarkKey: "cache:{one}:watermark", futureBufferMs: 0 }, + { watermarkKey: "cache:{two}:watermark", futureBufferMs: 0 }, + ]), + "Invalid DialCache Redis invalidate batch reply; expected 2 replies", + ); + adapter.dispose(); + } + + const client = fakeClient(); + client.exec.mockResolvedValueOnce([1, 0]); + const adapter = createValkeyGlideDialCacheClient(client, mockGlide); + await expectProtocolError( + adapter.invalidateMany([ + { watermarkKey: "cache:{one}:watermark", futureBufferMs: 0 }, + { watermarkKey: "cache:{two}:watermark", futureBufferMs: 0 }, + ]), + invalidationMessage, + ); + adapter.dispose(); + }); + it("rejects malformed script replies", async () => { const client = fakeClient("not-bytes", Buffer.alloc(0), Buffer.from([2, 1]), "not-an-integer", null); const adapter = createValkeyGlideDialCacheClient(client, mockGlide); @@ -222,7 +371,11 @@ describe("Valkey GLIDE adapter", () => { expect(script.release).toHaveBeenCalledTimes(1); } await expect(adapter.read({ valueKey: "disposed" })).rejects.toThrow("Valkey GLIDE DialCache client is disposed"); + await expect(adapter.invalidateMany([ + { watermarkKey: "cache:{disposed}:watermark", futureBufferMs: 0 }, + ])).rejects.toThrow("Valkey GLIDE DialCache client is disposed"); expect(client.invokeScript).not.toHaveBeenCalled(); + expect(client.exec).not.toHaveBeenCalled(); }); it("does not release scripts while an invocation is in flight", async () => { @@ -247,6 +400,30 @@ describe("Valkey GLIDE adapter", () => { expect(scriptInstances.every((script) => script.release.mock.calls.length === 1)).toBe(true); }); + it("does not release scripts while an invalidation batch is in flight", async () => { + let resolveBatch: ((value: unknown[]) => void) | undefined; + const client = fakeClient(); + client.exec.mockImplementationOnce( + async () => await new Promise((resolve) => { + resolveBatch = resolve; + }), + ); + const adapter = createValkeyGlideDialCacheClient(client, mockGlide); + + const invalidation = adapter.invalidateMany([ + { watermarkKey: "cache:{in-flight}:watermark", futureBufferMs: 0 }, + ]); + expect(() => adapter.dispose()).toThrow( + "Cannot dispose Valkey GLIDE DialCache client while operations are in flight", + ); + expect(scriptInstances.every((script) => script.release.mock.calls.length === 0)).toBe(true); + + resolveBatch?.([1]); + await expect(invalidation).resolves.toBeUndefined(); + adapter.dispose(); + expect(scriptInstances.every((script) => script.release.mock.calls.length === 1)).toBe(true); + }); + it("uses Script and Decoder from the supplied GLIDE module instance", async () => { class OtherScript { readonly release = vi.fn(); From 653dd8180179c08aeb9fcf3bd9846f473f2f583a Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Fri, 31 Jul 2026 12:36:37 -0700 Subject: [PATCH 2/5] test: cover GLIDE cluster batch invalidation --- test/redis-cluster.integration.test.ts | 154 ++++++++++++++++++++----- 1 file changed, 123 insertions(+), 31 deletions(-) diff --git a/test/redis-cluster.integration.test.ts b/test/redis-cluster.integration.test.ts index 3209478..1ce0202 100644 --- a/test/redis-cluster.integration.test.ts +++ b/test/redis-cluster.integration.test.ts @@ -1,3 +1,4 @@ +import * as valkeyGlide from "@valkey/valkey-glide"; import { commandOptions, createCluster, type RedisClusterOptions } from "redis"; import { GenericContainer, @@ -6,7 +7,7 @@ import { type StartedTestContainer, Wait, } from "testcontainers"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { CacheLayer, @@ -18,6 +19,7 @@ import { } from "../src/index.js"; import { redisClusterSlot } from "../src/internal/redis-cluster-slot.js"; import { createNodeRedisDialCacheClient, dialcacheRedisScripts } from "../src/node-redis.js"; +import { createValkeyGlideDialCacheClient } from "../src/valkey-glide.js"; const remoteOnly = new DialCacheKeyConfig({ ttlSec: { [CacheLayer.REMOTE]: 60 }, @@ -41,10 +43,61 @@ async function waitForCluster(container: StartedTestContainer): Promise { throw new Error("Redis Cluster did not become ready"); } +async function configureAdvertisedClusterEndpoint(container: StartedTestContainer): Promise { + // GLIDE discovers every primary from the server topology and has no node-address remapping hook. + // Advertise the host-reachable client endpoint while cluster creation and bus traffic use bridge IPs. + const settings = [ + ["cluster-announce-hostname", container.getHost()], + ["cluster-preferred-endpoint-type", "hostname"], + ["cluster-announce-port", String(container.getMappedPort(6379))], + ] as const; + for (const [name, value] of settings) { + const result = await container.exec(["redis-cli", "CONFIG", "SET", name, value]); + if (result.exitCode !== 0 || !result.output.includes("OK")) { + throw new Error(`Could not configure Redis Cluster endpoint ${name}: ${result.output}`); + } + } +} + +function selectCrossPrimaryBatchIds( + activeCluster: ReturnType, + watermarkFor: (id: string) => string, +): readonly [string, string, string] { + const idsBySlot = new Map(); + let sameSlotIds: readonly [string, string] | undefined; + // A collision is guaranteed after 16,384 distinct keys, though one usually appears much sooner. + for (let index = 0; index <= 16_384 && sameSlotIds === undefined; index += 1) { + const id = `item-${index}`; + const slot = redisClusterSlot(watermarkFor(id)); + const existing = idsBySlot.get(slot); + if (existing === undefined) { + idsBySlot.set(slot, id); + } else { + sameSlotIds = [existing, id]; + } + } + if (sameSlotIds === undefined) { + throw new Error("Could not find two generated invalidation keys in the same Redis Cluster slot"); + } + const sameSlotOwner = activeCluster.slots[redisClusterSlot(watermarkFor(sameSlotIds[0]))]?.master.id; + if (sameSlotOwner === undefined) { + throw new Error("Could not resolve the primary owning the generated same-slot keys"); + } + for (let index = 0; index <= 16_384; index += 1) { + const id = `item-${index}`; + const slotOwner = activeCluster.slots[redisClusterSlot(watermarkFor(id))]?.master.id; + if (slotOwner !== undefined && slotOwner !== sameSlotOwner) { + return [sameSlotIds[0], sameSlotIds[1], id]; + } + } + throw new Error("Could not find an invalidation key owned by a different Redis Cluster primary"); +} + describe("DialCache Lua protocol on Redis Cluster", () => { let network: StartedNetwork | undefined; let containers: Array = []; let cluster: ReturnType | undefined; + let glideCluster: valkeyGlide.GlideClusterClient | undefined; beforeAll(async () => { const startedNetwork = await new Network().start(); @@ -75,6 +128,8 @@ describe("DialCache Lua protocol on Redis Cluster", () => { ); } + await Promise.all(containers.map(configureAdvertisedClusterEndpoint)); + const networkName = network.getName(); const internalAddresses = containers.map((container) => `${container.getIpAddress(networkName)}:6379`); const firstContainer = containers[0]; @@ -107,9 +162,19 @@ describe("DialCache Lua protocol on Redis Cluster", () => { }); cluster.on("error", () => undefined); await cluster.connect(); + glideCluster = await valkeyGlide.GlideClusterClient.createClient({ + addresses: containers.map((container) => ({ + host: container.getHost(), + port: container.getMappedPort(6379), + })), + requestTimeout: 10_000, + periodicChecks: "disabled", + advancedConfiguration: { connectionTimeout: 5_000 }, + }); }); afterAll(async () => { + glideCluster?.close(); await cluster?.quit(); await Promise.all(containers.map(async (container) => await container.stop())); await network?.stop(); @@ -207,40 +272,12 @@ describe("DialCache Lua protocol on Redis Cluster", () => { const keyType = "item_id"; const watermarkFor = (id: string) => `${redisClusterHashTag(invalidationPrefix(namespace, keyType, id))}#watermark`; - const idsBySlot = new Map(); - let sameSlotIds: readonly [string, string] | undefined; - // A collision is guaranteed after 16,384 distinct keys, though one usually appears much sooner. - for (let index = 0; index <= 16_384 && sameSlotIds === undefined; index += 1) { - const id = `item-${index}`; - const slot = redisClusterSlot(watermarkFor(id)); - const existing = idsBySlot.get(slot); - if (existing === undefined) { - idsBySlot.set(slot, id); - } else { - sameSlotIds = [existing, id]; - } - } - if (sameSlotIds === undefined) { - throw new Error("Could not find two generated invalidation keys in the same Redis Cluster slot"); - } - const sameSlot = redisClusterSlot(watermarkFor(sameSlotIds[0])); + const ids = selectCrossPrimaryBatchIds(activeCluster, watermarkFor); + const sameSlot = redisClusterSlot(watermarkFor(ids[0])); const sameSlotOwner = activeCluster.slots[sameSlot]?.master.id; if (sameSlotOwner === undefined) { throw new Error("Could not resolve the primary owning the generated same-slot keys"); } - let otherSlotId: string | undefined; - for (let index = 0; index <= 16_384; index += 1) { - const id = `item-${index}`; - const slotOwner = activeCluster.slots[redisClusterSlot(watermarkFor(id))]?.master.id; - if (slotOwner !== undefined && slotOwner !== sameSlotOwner) { - otherSlotId = id; - break; - } - } - if (otherSlotId === undefined) { - throw new Error("Could not find an invalidation key owned by a different Redis Cluster primary"); - } - const ids = [...sameSlotIds, otherSlotId]; const firstMaster = activeCluster.masters[0]; if (firstMaster === undefined) { throw new Error("Redis Cluster has no primary nodes"); @@ -299,6 +336,61 @@ describe("DialCache Lua protocol on Redis Cluster", () => { expect(watermarks.every((watermark) => watermark !== null && /^\d+$/.test(watermark))).toBe(true); }); + it("batches same-slot and cross-primary invalidations through Valkey GLIDE Cluster", async () => { + if (cluster === undefined || glideCluster === undefined) { + throw new Error("Redis Cluster clients did not start"); + } + const activeCluster = cluster; + const activeGlideCluster = glideCluster; + const namespace = "glide-cluster-batch"; + const keyType = "item_id"; + const watermarkFor = (id: string) => + `${redisClusterHashTag(invalidationPrefix(namespace, keyType, id))}#watermark`; + const ids = selectCrossPrimaryBatchIds(activeCluster, watermarkFor); + const scriptClient = createValkeyGlideDialCacheClient(activeGlideCluster, valkeyGlide); + const executeBatch = vi.spyOn(activeGlideCluster, "exec"); + const dialcache = new DialCache({ + namespace, + redis: { client: scriptClient, readTimeoutMs: 10_000 }, + }); + const versions = new Map(ids.map((id) => [id, 1])); + const getValue = dialcache.cached(async (id: string) => ({ id, version: versions.get(id)! }), { + keyType, + useCase: "GlideClusterBatchInvalidation", + cacheKey: (id) => id, + trackForInvalidation: true, + defaultConfig: remoteOnly, + }); + + try { + const before = await dialcache.enable(async () => await Promise.all(ids.map(getValue))); + for (const id of ids) { + versions.set(id, 2); + } + await activeGlideCluster.scriptFlush({ route: "allPrimaries" }); + await dialcache.invalidateRemoteMany(ids.map((id) => ({ keyType, id }))); + expect(executeBatch).toHaveBeenCalledOnce(); + expect(executeBatch.mock.calls[0]?.[0]).toBeInstanceOf(valkeyGlide.ClusterBatch); + await new Promise((resolve) => setTimeout(resolve, 2)); + const after = await dialcache.enable(async () => await Promise.all(ids.map(getValue))); + + expect(before).toEqual(ids.map((id) => ({ id, version: 1 }))); + expect(after).toEqual(ids.map((id) => ({ id, version: 2 }))); + const watermarks = await Promise.all( + ids.map(async (id) => await activeGlideCluster.get( + watermarkFor(id), + { decoder: valkeyGlide.Decoder.String }, + )), + ); + expect(watermarks.every( + (watermark) => typeof watermark === "string" && /^\d+$/.test(watermark), + )).toBe(true); + } finally { + executeBatch.mockRestore(); + scriptClient.dispose(); + } + }); + it("round-trips binary payloads through cluster script routing", async () => { if (cluster === undefined) { throw new Error("Redis Cluster did not start"); From b9d5d811a6788a7fe9ca2168a735f8a09908dad2 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Fri, 31 Jul 2026 15:30:43 -0700 Subject: [PATCH 3/5] perf: optimize batch invalidation adapters --- README.md | 18 +- scripts/benchmark-batch-invalidation.mjs | 70 ++++++-- scripts/test-package.mjs | 12 ++ src/internal/await-all.ts | 10 +- src/internal/redis-cluster-slot.ts | 19 --- src/node-redis.ts | 128 +++++++++++++- src/prometheus.ts | 2 +- src/valkey-glide.ts | 67 ++++++-- test/node-redis.test.ts | 206 +++++++++++++++++++++-- test/redis-cluster-slot.test.ts | 22 +-- test/redis-cluster.integration.test.ts | 100 ++++++----- test/valkey-glide.test.ts | 139 ++++++++++++++- 12 files changed, 639 insertions(+), 154 deletions(-) diff --git a/README.md b/README.md index 27cab2d..bef5198 100644 --- a/README.md +++ b/README.md @@ -390,13 +390,17 @@ Pass the same module namespace that created the client. DialCache uses its itself, so linked workspaces and applications with another installed GLIDE version cannot accidentally mix native script handles. -The application owns the complete Redis lifecycle. It creates and connects the underlying client and passes the semantic adapter to DialCache. During shutdown, stop starting DialCache-backed work and await every promise returned by a cached function, `getOrLoad()`, `invalidateRemote()`, or `invalidateRemoteMany()`, including calls still running fallbacks that may later write Redis. Batch invalidation can have multiple in-flight slot partitions, so do not close the client after the first partition rejects. 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 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()`, `invalidateRemote()`, or `invalidateRemoteMany()`, including calls still running fallbacks that may later write Redis. Batch invalidation can have multiple in-flight primary-owner partitions, so do not close the client after the first partition rejects. 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. Awaiting those public promises does not drain detached shadow work. Shadow scheduling and deadline timers are unreferenced and completion is not guaranteed during shutdown; Redis operations, source reads, serializers, and asynchronous telemetry already started by shadow work remain caller-owned and may still be active. Stop new work before closing their dependencies and accept that an in-flight shadow fill may have been dispatched even if its final outcome is lost during teardown. DialCache does not add a shutdown hook or keep the process alive to deliver best-effort outcomes. 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 scalar scripts by their first key and performs that fallback on the selected shard. For batch invalidation, the adapter uses one non-transactional pipeline on standalone Redis; on Redis Cluster it groups watermark keys by their exact hash slot and sends one routed pipeline per slot. The GLIDE adapter uses GLIDE's native `Script` lifecycle and byte decoder for scalar operations and, when the supplied structural runtime exposes them, its native standalone or cluster batch support for batch invalidation. Existing narrow GLIDE wrappers that expose only scalar scripting remain compatible through scalar fallback. Tracked reads are deliberately routed to primaries so a lagging replica cannot hide an invalidation watermark. +Node-redis computes each script's SHA, uses `EVALSHA`, and retries with `EVAL` after `NOSCRIPT`. Its cluster client routes scalar scripts by their first key and performs that fallback on the selected shard. For batch invalidation, the adapter uses one non-transactional pipeline on standalone Redis. On Redis Cluster it computes each watermark key's slot, maps that slot through node-redis's current topology, groups the requests by primary owner, and routes one pipeline per targeted primary using the partition's first key; node-redis still selects the node. If an owner partition ultimately fails with `MOVED` or `ASK` while topology changes, the adapter conservatively retries that partition through independently routed scalar commands. Other pipeline failures retain the normal partial-execution and aggregate-error semantics. A narrow structural node-redis wrapper without `multi` remains compatible through scalar fallback. + +The GLIDE adapter uses GLIDE's native `Script` lifecycle and byte decoder for scalar operations and, when the supplied runtime exposes the necessary capabilities, its native standalone or cluster batch support for batch invalidation. The native batch path uses the invalidation script's hash with `EVALSHA`; after a cold `SCRIPT FLUSH`, a script-cache miss retries the whole idempotent batch once with `EVAL`, and subsequent warm batches return to one `EVALSHA` execution. A runtime whose `Script` handle does not expose `getHash()` uses `EVAL` directly. GLIDE Cluster batches enable GLIDE's server-error and connection-error retries, so a batch may be replayed; monotonic invalidation makes that replay safe. Standalone batches do not opt into those retry flags. + +Cluster batch selection relies on `client instanceof glide.GlideClusterClient`, using the constructor from the same supplied GLIDE module namespace. A structural cluster wrapper that does not preserve that identity should expose only scalar scripting rather than forwarding `exec`; it then remains compatible through scalar fallback. Passing a cluster wrapper that forwards `exec` but loses the native cluster identity is unsupported because the adapter cannot safely distinguish its batch type. Tracked reads are deliberately routed to primaries so a lagging replica cannot hide an invalidation watermark. The semantic `DialCacheRedisClient.invalidateMany` capability is optional. A custom adapter can implement it for client-native batching; an existing scalar-only adapter remains valid, and DialCache falls back to its `invalidate` operation for each target. Adapter batch implementations must preserve the scalar invalidation semantics, but the batching and Redis Cluster routing strategy stays inside the adapter rather than leaking client-specific concepts into the core API. @@ -624,9 +628,9 @@ await updateMembership("123", "acme", membershipPatch); await dialcache.invalidateRemoteMany(affectedTargets, USER_INVALIDATION_BUFFER_MS); ``` -`invalidateRemoteMany` is one public semantic batch operation. An empty target list is a no-op and does not call Redis. Before dispatch, DialCache canonicalizes each id with `String(id)` and de-duplicates identical `keyType` plus canonical-id pairs, so numeric `1`, string `"1"`, and bigint `1n` for one key type advance one watermark. Each unique target records one invalidation metric. If a batch fails, invalidation errors are counted once per distinct key type rather than once per id, keeping metric cardinality bounded. +`invalidateRemoteMany` is one public semantic batch operation. An empty target list is a no-op and does not call Redis. Before dispatch, DialCache canonicalizes each id with `String(id)` and de-duplicates identical `keyType` plus canonical-id pairs, so numeric `1`, string `"1"`, and bigint `1n` for one key type advance one watermark. Each attempted unique target records one invalidation metric before Redis dispatch. If a batch fails, invalidation errors are counted once per distinct key type rather than once per id, keeping metric cardinality bounded. -Batch invalidation is optimized for fewer client round trips, not cross-key atomicity. Standalone pipelines can partially execute if a connection fails, and Redis Cluster slot partitions execute independently. DialCache waits for every dispatched partition to settle and then rejects when any failed, but a rejected call can still have complete, partial, or ambiguous server-side effects; there is no rollback. Retrying the full canonical target list is safe because each invalidation script advances its watermark monotonically and never lowers it. +Batch invalidation is optimized for fewer client round trips, not cross-key atomicity. Standalone pipelines can partially execute if a connection fails, and Redis Cluster primary-owner partitions execute independently; topology recovery can also replay an affected partition through scalar commands. DialCache waits for every dispatched partition to settle and then rejects when any failed, but a rejected call can still have complete, partial, or ambiguous server-side effects; there is no rollback. Retrying the full canonical target list is safe because each invalidation script advances its watermark monotonically and never lowers it. 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. @@ -763,7 +767,7 @@ The Prometheus adapter emits: | `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 targets for the layers touched | +| `dialcache_invalidation_counter` | Counter | `cache_namespace`, `key_type`, `layer` | Attempted unique invalidation targets 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` | Sampled Redis shadow-job outcomes | | `dialcache_get_timer` | Histogram | `cache_namespace`, `use_case`, `key_type`, `layer` | Cache get latency in seconds | @@ -821,7 +825,7 @@ The Datadog adapter emits exact increments of `1` for counters and preserves sec | `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 targets for the layers touched | +| `dialcache.invalidation.count` | Count | `cache_namespace`, `key_type`, `layer` | Attempted unique invalidation targets 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` | Sampled Redis shadow-job outcomes | | `dialcache.get.duration` | Distribution or histogram | `cache_namespace`, `use_case`, `key_type`, `layer` | Cache get latency in seconds | @@ -875,7 +879,7 @@ With a live standalone Redis available, compare one batch-invalidation call with DIALCACHE_BENCH_REDIS_URL=redis://127.0.0.1:6379 pnpm benchmark:batch-invalidation ``` -The command builds `dist`, warms the Lua protocol, and reports 10, 100, and 1,000 unique targets for `invalidateRemoteMany(...)` versus `Promise.all(targets.map(invalidateRemote))`. It uses a unique namespace and never flushes Redis; the benchmark watermark keys expire under the normal invalidation protocol. Results are directional and depend on Redis topology, network latency, and client configuration, so the script intentionally asserts no performance threshold. The maintainer-only script is not included in the published package. +The command builds `dist`, warms the Lua protocol, and reports median timings over five repetitions for 10, 100, and 1,000 unique targets using `invalidateRemoteMany(...)` versus `Promise.all(targets.map(invalidateRemote))`. Each repetition uses fresh targets, alternates which path runs first, and verifies that every expected watermark was written. Set `DIALCACHE_BENCH_REPETITIONS` to a positive integer to adjust the sample count. The script uses a unique namespace and never flushes Redis; its watermark keys expire under the normal invalidation protocol. Results are directional and depend on Redis topology, network latency, and client configuration, so the script intentionally asserts no performance threshold. The maintainer-only script is not included in the published package. ### Releasing diff --git a/scripts/benchmark-batch-invalidation.mjs b/scripts/benchmark-batch-invalidation.mjs index 84a1d59..efcccc0 100644 --- a/scripts/benchmark-batch-invalidation.mjs +++ b/scripts/benchmark-batch-invalidation.mjs @@ -11,6 +11,7 @@ import { const redisUrl = process.env.DIALCACHE_BENCH_REDIS_URL ?? "redis://127.0.0.1:6379"; const sizes = [10, 100, 1_000]; +const repetitions = positiveIntegerFromEnvironment("DIALCACHE_BENCH_REPETITIONS", 5); const runId = `${process.pid}-${Date.now()}`; const redisClient = createClient({ url: redisUrl, @@ -35,25 +36,43 @@ try { await dialcache.invalidateRemote("benchmark_warmup", runId); for (const size of sizes) { - const scalarTargets = targetsFor(`scalar-${size}`, size); - const scalarStartedAt = performance.now(); - await Promise.all( - scalarTargets.map(({ keyType, id }) => dialcache.invalidateRemote(keyType, id)), - ); - const scalarMs = performance.now() - scalarStartedAt; - assert.equal(await countWatermarks(namespace, scalarTargets), size); + const scalarSamples = []; + const batchSamples = []; + for (let repetition = 0; repetition < repetitions; repetition += 1) { + const scalarTargets = targetsFor(`scalar-${size}-${repetition}`, size); + const batchTargets = targetsFor(`batch-${size}-${repetition}`, size); + const runScalar = async () => { + const startedAt = performance.now(); + await Promise.all( + scalarTargets.map(({ keyType, id }) => dialcache.invalidateRemote(keyType, id)), + ); + scalarSamples.push(performance.now() - startedAt); + assert.equal(await countWatermarks(namespace, scalarTargets), size); + }; + const runBatch = async () => { + const startedAt = performance.now(); + await dialcache.invalidateRemoteMany(batchTargets); + batchSamples.push(performance.now() - startedAt); + assert.equal(await countWatermarks(namespace, batchTargets), size); + }; - const batchTargets = targetsFor(`batch-${size}`, size); - const batchStartedAt = performance.now(); - await dialcache.invalidateRemoteMany(batchTargets); - const batchMs = performance.now() - batchStartedAt; - assert.equal(await countWatermarks(namespace, batchTargets), size); + if (repetition % 2 === 0) { + await runScalar(); + await runBatch(); + } else { + await runBatch(); + await runScalar(); + } + } + const scalarMedianMs = median(scalarSamples); + const batchMedianMs = median(batchSamples); results.push({ targets: size, - "Promise.all scalar (ms)": scalarMs.toFixed(2), - "single batch call (ms)": batchMs.toFixed(2), - "scalar / batch": (scalarMs / batchMs).toFixed(2), + repetitions, + "Promise.all scalar median (ms)": scalarMedianMs.toFixed(2), + "batch median (ms)": batchMedianMs.toFixed(2), + "median scalar / batch": (scalarMedianMs / batchMedianMs).toFixed(2), }); } @@ -77,3 +96,24 @@ async function countWatermarks(namespace, targets) { `${redisClusterHashTag(invalidationPrefix(namespace, keyType, String(id)))}#watermark`); return await redisClient.exists(keys); } + +function median(values) { + assert.ok(values.length > 0); + const sorted = [...values].sort((left, right) => left - right); + const middle = Math.floor(sorted.length / 2); + return sorted.length % 2 === 0 + ? (sorted[middle - 1] + sorted[middle]) / 2 + : sorted[middle]; +} + +function positiveIntegerFromEnvironment(name, fallback) { + const raw = process.env[name]; + if (raw === undefined) { + return fallback; + } + const parsed = Number(raw); + if (!Number.isSafeInteger(parsed) || parsed <= 0) { + throw new Error(`${name} must be a positive safe integer`); + } + return parsed; +} diff --git a/scripts/test-package.mjs b/scripts/test-package.mjs index a8def43..5b31b05 100644 --- a/scripts/test-package.mjs +++ b/scripts/test-package.mjs @@ -331,6 +331,16 @@ const customRedisClient: DialCacheRedisClient = { write: async ({ value }) => typeof value === "string" || Buffer.isBuffer(value), invalidate: async () => undefined, }; +const narrowNodeRedisScriptClient = { + dialcacheRead: async () => null, + dialcacheReadTracked: async () => null, + dialcacheWrite: async () => 1, + dialcacheWriteTracked: async () => 1, + dialcacheInvalidate: async () => 1, +}; +const nodeRedisAdapterWithoutMulti: DialCacheRedisClient = createNodeRedisDialCacheClient( + narrowNodeRedisScriptClient, +); const cacheWithScalarOnlyInvalidation = new DialCache({ redis: { client: customRedisClient } }); const scalarOnlyFallbackBatch: Promise = cacheWithScalarOnlyInvalidation.invalidateRemoteMany( remoteInvalidationTargets, @@ -449,6 +459,8 @@ void disabledOverlay; void metricErrorKinds; void unboundedErrorKind; void createNodeRedisDialCacheClient; +void narrowNodeRedisScriptClient; +void nodeRedisAdapterWithoutMulti; void READ_CACHE_SCRIPT; void customRedisClient; void cacheWithScalarOnlyInvalidation; diff --git a/src/internal/await-all.ts b/src/internal/await-all.ts index 61d967a..c676f54 100644 --- a/src/internal/await-all.ts +++ b/src/internal/await-all.ts @@ -1,17 +1,14 @@ /** Wait for every launched operation, preserving one error or aggregating many. */ -export async function awaitAll( - operations: readonly Promise[], +export async function awaitAll( + operations: readonly Promise[], aggregateMessage: string, -): Promise { +): Promise { const results = await Promise.allSettled(operations); const errors: unknown[] = []; - const values: T[] = []; for (const result of results) { if (result.status === "rejected") { errors.push(result.reason); - } else { - values.push(result.value); } } @@ -21,5 +18,4 @@ export async function awaitAll( if (errors.length > 1) { throw new AggregateError(errors, aggregateMessage); } - return values; } diff --git a/src/internal/redis-cluster-slot.ts b/src/internal/redis-cluster-slot.ts index 605019b..6ea3cb5 100644 --- a/src/internal/redis-cluster-slot.ts +++ b/src/internal/redis-cluster-slot.ts @@ -6,25 +6,6 @@ export function redisClusterSlot(key: string): number { return crc16Xmodem(Buffer.from(redisHashInput(key), "utf8")) % REDIS_CLUSTER_SLOT_COUNT; } -export function groupByRedisClusterSlot( - items: readonly T[], - keyOf: (item: T) => string, -): Map { - const groups = new Map(); - - for (const item of items) { - const slot = redisClusterSlot(keyOf(item)); - const group = groups.get(slot); - if (group === undefined) { - groups.set(slot, [item]); - } else { - group.push(item); - } - } - - return groups; -} - function redisHashInput(key: string): string { const tagStart = key.indexOf("{"); if (tagStart === -1) { diff --git a/src/node-redis.ts b/src/node-redis.ts index f819f25..acf2d1b 100644 --- a/src/node-redis.ts +++ b/src/node-redis.ts @@ -1,7 +1,7 @@ import { commandOptions, defineScript } from "redis"; import { awaitAll } from "./internal/await-all.js"; -import { groupByRedisClusterSlot } from "./internal/redis-cluster-slot.js"; +import { redisClusterSlot } from "./internal/redis-cluster-slot.js"; import { INVALIDATE_CACHE_SCRIPT, READ_CACHE_SCRIPT, @@ -151,8 +151,8 @@ interface NodeRedisScriptClient { payload: string | Buffer, ): Promise; dialcacheInvalidate(watermarkKey: string, futureBufferMs: number): Promise; - multi(routing?: NodeRedisArgument): NodeRedisMultiCommand; - readonly slots?: unknown; + multi?(routing?: NodeRedisArgument): NodeRedisMultiCommand; + readonly slots?: readonly (NodeRedisClusterSlot | undefined)[]; } interface NodeRedisMultiCommand { @@ -160,13 +160,106 @@ interface NodeRedisMultiCommand { execAsPipeline(): Promise; } -function isNodeRedisClusterClient(client: NodeRedisScriptClient): boolean { +interface NodeRedisClusterSlot { + readonly master?: { + readonly id?: string; + }; +} + +interface NodeRedisPipelineClient extends NodeRedisScriptClient { + multi(routing?: NodeRedisArgument): NodeRedisMultiCommand; +} + +interface NodeRedisClusterClient extends NodeRedisPipelineClient { + readonly slots: readonly (NodeRedisClusterSlot | undefined)[]; +} + +interface NodeRedisInvalidationRequest { + readonly watermarkKey: string; + readonly futureBufferMs: number; +} + +function hasNodeRedisPipeline(client: NodeRedisScriptClient): client is NodeRedisPipelineClient { + return client.multi !== undefined; +} + +function isNodeRedisClusterClient(client: NodeRedisPipelineClient): client is NodeRedisClusterClient { return Array.isArray(client.slots); } -async function executeInvalidationPipeline( +function partitionClusterInvalidations( + client: NodeRedisClusterClient, + requests: readonly NodeRedisInvalidationRequest[], +): NodeRedisInvalidationRequest[][] { + const partitions: NodeRedisInvalidationRequest[][] = []; + const partitionsByOwner = new Map(); + const slots = client.slots; + + for (const request of requests) { + const slot = redisClusterSlot(request.watermarkKey); + const ownerId = slots[slot]?.master?.id; + if (ownerId === undefined) { + // A stale or incomplete topology entry cannot be safely combined with another slot. + // Keep it isolated and let node-redis route it from its own key. + partitions.push([request]); + continue; + } + + const existing = partitionsByOwner.get(ownerId); + if (existing === undefined) { + const partition = [request]; + partitionsByOwner.set(ownerId, partition); + partitions.push(partition); + } else { + existing.push(request); + } + } + + return partitions; +} + +async function executeScalarInvalidations( client: NodeRedisScriptClient, - requests: readonly { readonly watermarkKey: string; readonly futureBufferMs: number }[], + requests: readonly NodeRedisInvalidationRequest[], +): Promise { + await awaitAll( + requests.map(async ({ watermarkKey, futureBufferMs }) => { + const reply = await client.dialcacheInvalidate(watermarkKey, futureBufferMs); + validateRedisScriptInvalidationReply(reply); + }), + "Multiple DialCache invalidations failed", + ); +} + +function isDirectRedisClusterRedirection(error: unknown): boolean { + return error instanceof Error + && (error.message.startsWith("MOVED ") || error.message.startsWith("ASK ")); +} + +function isRedisClusterRedirection(error: unknown): boolean { + if (!(error instanceof Error) || !("replies" in error) || !("errorIndexes" in error)) { + return isDirectRedisClusterRedirection(error); + } + + const { replies, errorIndexes } = error as Error & { + readonly replies: unknown; + readonly errorIndexes: unknown; + }; + if (!Array.isArray(replies) || !Array.isArray(errorIndexes) || errorIndexes.length === 0) { + return false; + } + + return errorIndexes.every((index: unknown) => + typeof index === "number" + && Number.isInteger(index) + && index >= 0 + && index < replies.length + && isDirectRedisClusterRedirection(replies[index])); +} + +async function executeInvalidationPipeline( + client: NodeRedisPipelineClient, + requests: readonly NodeRedisInvalidationRequest[], ): Promise { const first = requests[0]; if (first === undefined) { @@ -179,7 +272,20 @@ async function executeInvalidationPipeline( for (const { watermarkKey, futureBufferMs } of requests) { pipeline.dialcacheInvalidate(watermarkKey, futureBufferMs); } - const replies = await pipeline.execAsPipeline(); + let replies: unknown[]; + try { + replies = await pipeline.execAsPipeline(); + } catch (error) { + if (!isRedisClusterRedirection(error)) { + throw error; + } + + // node-redis routes the mixed-slot pipeline using its first key. If topology + // changes, a different key can keep redirecting that pipeline to the wrong + // owner. Registered scalar calls are idempotent and route each key afresh. + await executeScalarInvalidations(client, requests); + return; + } if (replies.length !== requests.length) { throw new DialCacheRedisProtocolError( `Invalid DialCache Redis invalidate batch reply count; expected ${requests.length}, received ${replies.length}`, @@ -230,8 +336,14 @@ export function createNodeRedisDialCacheClient(client: NodeRedisScriptClient): D if (requests.length === 0) { return; } + + if (!hasNodeRedisPipeline(client)) { + await executeScalarInvalidations(client, requests); + return; + } + const partitions = isNodeRedisClusterClient(client) - ? [...groupByRedisClusterSlot(requests, ({ watermarkKey }) => watermarkKey).values()] + ? partitionClusterInvalidations(client, requests) : [requests]; await awaitAll( partitions.map(async (partition) => await executeInvalidationPipeline(client, partition)), diff --git a/src/prometheus.ts b/src/prometheus.ts index cff65a5..e5a10d1 100644 --- a/src/prometheus.ts +++ b/src/prometheus.ts @@ -192,7 +192,7 @@ function collectorConfigs(prefix: string) { invalidationCounter: { type: "counter", name: `${prefix}dialcache_invalidation_counter`, - help: "DialCache invalidation targets by key type and layer.", + help: "DialCache attempted unique invalidation targets by key type and layer.", labelNames: ["cache_namespace", "key_type", "layer"], }, coalescedCounter: { diff --git a/src/valkey-glide.ts b/src/valkey-glide.ts index 6b53712..eb08a62 100644 --- a/src/valkey-glide.ts +++ b/src/valkey-glide.ts @@ -21,10 +21,20 @@ import { type ValkeyGlideString = string | Buffer; export interface ValkeyGlideScriptHandle { + /** Return the Redis script hash when exposed by this GLIDE runtime. */ + getHash?(): string; /** Release the native GLIDE script registration. */ release(): void; } +interface ValkeyGlideBatchExecutionOptions { + readonly decoder: TDecoder; + readonly retryStrategy?: { + readonly retryServerError: boolean; + readonly retryConnectionError: boolean; + }; +} + export interface ValkeyGlideScriptingClient { invokeScript( script: TScript, @@ -38,7 +48,7 @@ export interface ValkeyGlideScriptingClient { exec?( batch: ValkeyGlideBatch, raiseOnError: boolean, - options: { decoder: TDecoder }, + options: ValkeyGlideBatchExecutionOptions, ): Promise; } @@ -51,6 +61,8 @@ interface ValkeyGlideBatchConstructor { } interface ValkeyGlideClusterClientConstructor { + // The adapter only performs an instanceof check; modeling Symbol.hasInstance + // avoids coupling its structural runtime contract to GLIDE's full constructor. [Symbol.hasInstance](value: unknown): boolean; } @@ -77,6 +89,14 @@ interface DialCacheGlideScripts { readonly invalidate: TScript; } +function isScriptCacheMiss(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + return /\bNOSCRIPT\b/.test(error.message) + || /\bNoScriptError:\s*No matching script(?:\.|\s|$)/.test(error.message); +} + export interface ValkeyGlideDialCacheClient extends DialCacheRedisClient { /** Advance multiple watermarks in one non-atomic GLIDE batch. */ invalidateMany(requests: readonly RedisInvalidationRequest[]): Promise; @@ -192,28 +212,45 @@ export function createValkeyGlideDialCacheClient { - const Batch = client instanceof GlideClusterClient - ? ClusterBatch - : StandaloneBatch; + const raw = await invokeTracked(async () => { + const isCluster = client instanceof GlideClusterClient; + const Batch = isCluster ? ClusterBatch : StandaloneBatch; + const options: ValkeyGlideBatchExecutionOptions = isCluster + ? { + decoder: glide.Decoder.Bytes, + retryStrategy: { + retryServerError: true, + retryConnectionError: true, + }, + } + : { decoder: glide.Decoder.Bytes }; + const executeBatch = async (script: string, command: "EVAL" | "EVALSHA") => { const batch = new Batch(false); for (const { watermarkKey, futureBufferMs } of requests) { batch.customCommand([ - "EVAL", - INVALIDATE_CACHE_SCRIPT, + command, + script, "1", watermarkKey, String(futureBufferMs), ]); } - return await exec( - batch, - true, - { decoder: glide.Decoder.Bytes }, - ); - }, - ); + return await exec(batch, true, options); + }; + + const scriptHash = scripts.invalidate.getHash?.(); + if (scriptHash === undefined) { + return await executeBatch(INVALIDATE_CACHE_SCRIPT, "EVAL"); + } + try { + return await executeBatch(scriptHash, "EVALSHA"); + } catch (error) { + if (!isScriptCacheMiss(error)) { + throw error; + } + return await executeBatch(INVALIDATE_CACHE_SCRIPT, "EVAL"); + } + }); if (!Array.isArray(raw) || raw.length !== requests.length) { throw new DialCacheRedisProtocolError( `Invalid DialCache Redis invalidate batch reply; expected ${requests.length} replies`, diff --git a/test/node-redis.test.ts b/test/node-redis.test.ts index c66a08b..2cbcaaf 100644 --- a/test/node-redis.test.ts +++ b/test/node-redis.test.ts @@ -6,6 +6,7 @@ import { DialCacheKeyConfig, DialCacheRedisProtocolError, } from "../src/index.js"; +import { redisClusterSlot } from "../src/internal/redis-cluster-slot.js"; import { createNodeRedisDialCacheClient, dialcacheRedisScripts } from "../src/node-redis.js"; const INVALID_WRITE_REPLIES: readonly unknown[] = [ @@ -47,14 +48,33 @@ interface FakePipeline { readonly execAsPipeline: ReturnType; } +interface FakeClusterSlot { + readonly master: { + readonly id: string; + }; +} + +function fakeClusterSlots( + entries: readonly (readonly [key: string, ownerId: string])[], +): Array { + const slots: Array = []; + for (const [key, ownerId] of entries) { + slots[redisClusterSlot(key)] = { master: { id: ownerId } }; + } + return slots; +} + function fakeBatchClient(options: { readonly cluster?: boolean; + readonly slots?: readonly (FakeClusterSlot | undefined)[]; readonly execute?: (pipeline: FakePipeline) => Promise; } = {}) { const pipelines: FakePipeline[] = []; const client = { ...fakeClient(), - ...(options.cluster === true ? { slots: [] } : {}), + ...(options.cluster === true || options.slots !== undefined + ? { slots: options.slots ?? [] } + : {}), multi: vi.fn((routing?: string | Buffer) => { const commands: Array = []; const pipeline: FakePipeline & { @@ -169,28 +189,187 @@ describe("node-redis adapter", () => { expect(pipelines[0]?.execAsPipeline).toHaveBeenCalledOnce(); }); - it("partitions node-redis Cluster pipelines by exact slot", async () => { - const { client, pipelines } = fakeBatchClient({ cluster: true }); + it("partitions node-redis Cluster pipelines by current primary owner", async () => { + const first = "cache:{one}:watermark"; + const second = "cache:{two}:watermark"; + const third = "cache:{three}:watermark"; + const { client, pipelines } = fakeBatchClient({ + slots: fakeClusterSlots([ + [first, "primary-a"], + [second, "primary-b"], + [third, "primary-a"], + ]), + }); const adapter = createNodeRedisDialCacheClient(client as never); await adapter.invalidateMany?.([ - { watermarkKey: "cache:{k-620}:watermark", futureBufferMs: 10 }, - { watermarkKey: "cache:{different}:watermark", futureBufferMs: 20 }, - { watermarkKey: "cache:{k-1000}:watermark", futureBufferMs: 30 }, + { watermarkKey: first, futureBufferMs: 10 }, + { watermarkKey: second, futureBufferMs: 20 }, + { watermarkKey: third, futureBufferMs: 30 }, ]); + expect(redisClusterSlot(first)).not.toBe(redisClusterSlot(third)); expect(client.multi).toHaveBeenCalledTimes(2); + expect(pipelines.map(({ routing }) => routing)).toEqual([first, second]); + expect(pipelines[0]?.commands).toEqual([ + [first, 10], + [third, 30], + ]); + expect(pipelines[1]?.commands).toEqual([[second, 20]]); + }); + + it("keeps requests with unmapped Cluster slots in isolated key-routed pipelines", async () => { + const mappedOne = "cache:{mapped-one}:watermark"; + const unmappedOne = "cache:{unmapped-one}:watermark"; + const mappedTwo = "cache:{mapped-two}:watermark"; + const unmappedTwo = "cache:{unmapped-two}:watermark"; + const { client, pipelines } = fakeBatchClient({ + slots: fakeClusterSlots([ + [mappedOne, "primary-a"], + [mappedTwo, "primary-a"], + ]), + }); + const adapter = createNodeRedisDialCacheClient(client as never); + + await adapter.invalidateMany?.([ + { watermarkKey: mappedOne, futureBufferMs: 10 }, + { watermarkKey: unmappedOne, futureBufferMs: 20 }, + { watermarkKey: mappedTwo, futureBufferMs: 30 }, + { watermarkKey: unmappedTwo, futureBufferMs: 40 }, + ]); + + expect(client.multi).toHaveBeenCalledTimes(3); expect(pipelines.map(({ routing }) => routing)).toEqual([ - "cache:{k-620}:watermark", - "cache:{different}:watermark", + mappedOne, + unmappedOne, + unmappedTwo, ]); - expect(pipelines[0]?.commands).toEqual([ - ["cache:{k-620}:watermark", 10], - ["cache:{k-1000}:watermark", 30], + expect(pipelines.map(({ commands }) => commands)).toEqual([ + [[mappedOne, 10], [mappedTwo, 30]], + [[unmappedOne, 20]], + [[unmappedTwo, 40]], + ]); + }); + + it("falls back to registered scalar scripts when a client has no pipeline surface", async () => { + const client = fakeClient(); + const adapter = createNodeRedisDialCacheClient(client as never); + + await adapter.invalidateMany?.([ + { watermarkKey: "cache:{one}:watermark", futureBufferMs: 10 }, + { watermarkKey: "cache:{two}:watermark", futureBufferMs: 20 }, + ]); + + expect(client.dialcacheInvalidate).toHaveBeenCalledTimes(2); + expect(client.dialcacheInvalidate).toHaveBeenNthCalledWith(1, "cache:{one}:watermark", 10); + expect(client.dialcacheInvalidate).toHaveBeenNthCalledWith(2, "cache:{two}:watermark", 20); + }); + + it("settles and validates every scalar fallback operation before rejecting", async () => { + let releaseSecond!: () => void; + const secondGate = new Promise((resolve) => { + releaseSecond = resolve; + }); + const client = fakeClient(); + let call = 0; + client.dialcacheInvalidate.mockImplementation(async () => { + call += 1; + if (call === 1) { + return 0; + } + await secondGate; + return 1; + }); + const adapter = createNodeRedisDialCacheClient(client as never); + + let settled = false; + const operation = Promise.resolve(adapter.invalidateMany?.([ + { watermarkKey: "cache:{one}:watermark", futureBufferMs: 10 }, + { watermarkKey: "cache:{two}:watermark", futureBufferMs: 20 }, + ])).finally(() => { + settled = true; + }); + await vi.waitFor(() => expect(client.dialcacheInvalidate).toHaveBeenCalledTimes(2)); + await Promise.resolve(); + expect(settled).toBe(false); + + releaseSecond(); + await expectProtocolError( + operation, + "Invalid DialCache Redis invalidate reply; expected integer 1", + ); + expect(settled).toBe(true); + }); + + it.each([ + "MOVED 12 127.0.0.1:7001", + "ASK 12 127.0.0.1:7001", + ])("recovers a final %s pipeline redirection with scalar routing", async (message) => { + const first = "cache:{one}:watermark"; + const second = "cache:{two}:watermark"; + const { client } = fakeBatchClient({ + slots: fakeClusterSlots([[first, "primary-a"], [second, "primary-a"]]), + execute: async () => { throw new Error(message); }, + }); + const adapter = createNodeRedisDialCacheClient(client as never); + + await adapter.invalidateMany?.([ + { watermarkKey: first, futureBufferMs: 10 }, + { watermarkKey: second, futureBufferMs: 20 }, ]); - expect(pipelines[1]?.commands).toEqual([ - ["cache:{different}:watermark", 20], + + expect(client.dialcacheInvalidate).toHaveBeenCalledTimes(2); + expect(client.dialcacheInvalidate).toHaveBeenNthCalledWith(1, first, 10); + expect(client.dialcacheInvalidate).toHaveBeenNthCalledWith(2, second, 20); + }); + + it("recovers a pipeline aggregate when every failed reply is a redirection", async () => { + const first = "cache:{one}:watermark"; + const second = "cache:{two}:watermark"; + const pipelineError = Object.assign(new Error("2 commands failed"), { + replies: [ + new Error("MOVED 12 127.0.0.1:7001"), + new Error("ASK 34 127.0.0.1:7002"), + ], + errorIndexes: [0, 1], + }); + const { client } = fakeBatchClient({ + slots: fakeClusterSlots([[first, "primary-a"], [second, "primary-a"]]), + execute: async () => { throw pipelineError; }, + }); + const adapter = createNodeRedisDialCacheClient(client as never); + + await adapter.invalidateMany?.([ + { watermarkKey: first, futureBufferMs: 10 }, + { watermarkKey: second, futureBufferMs: 20 }, ]); + + expect(client.dialcacheInvalidate).toHaveBeenCalledTimes(2); + }); + + it("preserves a pipeline aggregate containing a non-redirection failure", async () => { + const first = "cache:{one}:watermark"; + const second = "cache:{two}:watermark"; + const pipelineError = Object.assign(new Error("2 commands failed"), { + replies: [ + new Error("MOVED 12 127.0.0.1:7001"), + new Error("ERR script failure"), + ], + errorIndexes: [0, 1], + }); + const { client } = fakeBatchClient({ + slots: fakeClusterSlots([[first, "primary-a"], [second, "primary-a"]]), + execute: async () => { throw pipelineError; }, + }); + const adapter = createNodeRedisDialCacheClient(client as never); + + const operation = adapter.invalidateMany?.([ + { watermarkKey: first, futureBufferMs: 10 }, + { watermarkKey: second, futureBufferMs: 20 }, + ]) ?? Promise.resolve(); + + await expect(operation).rejects.toBe(pipelineError); + expect(client.dialcacheInvalidate).not.toHaveBeenCalled(); }); it("waits for every Cluster partition before surfacing a failure", async () => { @@ -225,6 +404,7 @@ describe("node-redis adapter", () => { releaseSecond(); await expect(operation).rejects.toBe(firstError); expect(settled).toBe(true); + expect(client.dialcacheInvalidate).not.toHaveBeenCalled(); }); it("rejects malformed invalidation batch replies and skips empty batches", async () => { diff --git a/test/redis-cluster-slot.test.ts b/test/redis-cluster-slot.test.ts index 71cc097..0393ed8 100644 --- a/test/redis-cluster-slot.test.ts +++ b/test/redis-cluster-slot.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it } from "vitest"; -import { - groupByRedisClusterSlot, - redisClusterSlot, -} from "../src/internal/redis-cluster-slot.js"; +import { redisClusterSlot } from "../src/internal/redis-cluster-slot.js"; describe("Redis Cluster slot calculation", () => { it.each([ @@ -34,21 +31,4 @@ describe("Redis Cluster slot calculation", () => { ])("hashes the UTF-8 bytes of %j", (key, expectedSlot) => { expect(redisClusterSlot(key)).toBe(expectedSlot); }); - - it("groups different hash tags that collide on the same exact slot", () => { - const requests = [ - { watermarkKey: "prefix:{k-620}:watermark", id: "first" }, - { watermarkKey: "prefix:{different}:watermark", id: "other" }, - { watermarkKey: "prefix:{k-1000}:watermark", id: "second" }, - ]; - - expect(redisClusterSlot(requests[0]!.watermarkKey)).toBe(6_474); - expect(redisClusterSlot(requests[2]!.watermarkKey)).toBe(6_474); - - const groups = groupByRedisClusterSlot(requests, ({ watermarkKey }) => watermarkKey); - - expect(groups.get(6_474)).toEqual([requests[0], requests[2]]); - expect([...groups.values()].flat()).toHaveLength(requests.length); - expect(groups.size).toBe(2); - }); }); diff --git a/test/redis-cluster.integration.test.ts b/test/redis-cluster.integration.test.ts index 1ce0202..03dd005 100644 --- a/test/redis-cluster.integration.test.ts +++ b/test/redis-cluster.integration.test.ts @@ -44,8 +44,9 @@ async function waitForCluster(container: StartedTestContainer): Promise { } async function configureAdvertisedClusterEndpoint(container: StartedTestContainer): Promise { - // GLIDE discovers every primary from the server topology and has no node-address remapping hook. - // Advertise the host-reachable client endpoint while cluster creation and bus traffic use bridge IPs. + // This suite configures every node to advertise its host-reachable client endpoint because GLIDE + // discovers all primaries from server topology and has no node-address remapping hook. Cluster + // creation and bus traffic still use bridge IPs for both the node-redis and GLIDE test cases. const settings = [ ["cluster-announce-hostname", container.getHost()], ["cluster-preferred-endpoint-type", "hostname"], @@ -63,34 +64,37 @@ function selectCrossPrimaryBatchIds( activeCluster: ReturnType, watermarkFor: (id: string) => string, ): readonly [string, string, string] { - const idsBySlot = new Map(); - let sameSlotIds: readonly [string, string] | undefined; - // A collision is guaranteed after 16,384 distinct keys, though one usually appears much sooner. - for (let index = 0; index <= 16_384 && sameSlotIds === undefined; index += 1) { + const idsByOwnerAndSlot = new Map>(); + for (let index = 0; index <= 16_384; index += 1) { const id = `item-${index}`; const slot = redisClusterSlot(watermarkFor(id)); - const existing = idsBySlot.get(slot); - if (existing === undefined) { - idsBySlot.set(slot, id); - } else { - sameSlotIds = [existing, id]; + const owner = activeCluster.slots[slot]?.master.id; + if (owner === undefined) { + continue; } - } - if (sameSlotIds === undefined) { - throw new Error("Could not find two generated invalidation keys in the same Redis Cluster slot"); - } - const sameSlotOwner = activeCluster.slots[redisClusterSlot(watermarkFor(sameSlotIds[0]))]?.master.id; - if (sameSlotOwner === undefined) { - throw new Error("Could not resolve the primary owning the generated same-slot keys"); - } - for (let index = 0; index <= 16_384; index += 1) { - const id = `item-${index}`; - const slotOwner = activeCluster.slots[redisClusterSlot(watermarkFor(id))]?.master.id; - if (slotOwner !== undefined && slotOwner !== sameSlotOwner) { - return [sameSlotIds[0], sameSlotIds[1], id]; + const idsBySlot = idsByOwnerAndSlot.get(owner) ?? new Map(); + idsBySlot.set(slot, id); + idsByOwnerAndSlot.set(owner, idsBySlot); + + for (const [samePrimaryOwner, samePrimaryIdsBySlot] of idsByOwnerAndSlot) { + if (samePrimaryIdsBySlot.size < 2) { + continue; + } + const otherPrimary = [...idsByOwnerAndSlot.entries()].find( + ([candidateOwner, candidateIdsBySlot]) => + candidateOwner !== samePrimaryOwner && candidateIdsBySlot.size > 0, + ); + if (otherPrimary === undefined) { + continue; + } + const samePrimaryIds = [...samePrimaryIdsBySlot.values()]; + const otherPrimaryId = otherPrimary[1].values().next().value; + if (samePrimaryIds[0] !== undefined && samePrimaryIds[1] !== undefined && otherPrimaryId !== undefined) { + return [samePrimaryIds[0], samePrimaryIds[1], otherPrimaryId]; + } } } - throw new Error("Could not find an invalidation key owned by a different Redis Cluster primary"); + throw new Error("Could not find distinct-slot invalidation keys spanning Redis Cluster primaries"); } describe("DialCache Lua protocol on Redis Cluster", () => { @@ -263,7 +267,7 @@ describe("DialCache Lua protocol on Redis Cluster", () => { await expect(cluster.dialcacheReadTracked("{slot-a}:value", "{slot-b}:watermark")).rejects.toThrow(/CROSSSLOT/); }); - it("batches same-slot and cross-slot invalidations after per-node SCRIPT FLUSH", async () => { + it("batches distinct slots by primary owner after per-node SCRIPT FLUSH", async () => { if (cluster === undefined) { throw new Error("Redis Cluster did not start"); } @@ -273,11 +277,16 @@ describe("DialCache Lua protocol on Redis Cluster", () => { const watermarkFor = (id: string) => `${redisClusterHashTag(invalidationPrefix(namespace, keyType, id))}#watermark`; const ids = selectCrossPrimaryBatchIds(activeCluster, watermarkFor); - const sameSlot = redisClusterSlot(watermarkFor(ids[0])); - const sameSlotOwner = activeCluster.slots[sameSlot]?.master.id; - if (sameSlotOwner === undefined) { - throw new Error("Could not resolve the primary owning the generated same-slot keys"); + const firstSlot = redisClusterSlot(watermarkFor(ids[0])); + const secondSlot = redisClusterSlot(watermarkFor(ids[1])); + const thirdSlot = redisClusterSlot(watermarkFor(ids[2])); + const firstOwner = activeCluster.slots[firstSlot]?.master.id; + const secondOwner = activeCluster.slots[secondSlot]?.master.id; + const thirdOwner = activeCluster.slots[thirdSlot]?.master.id; + if (firstOwner === undefined || secondOwner === undefined || thirdOwner === undefined) { + throw new Error("Could not resolve the primaries owning the generated invalidation keys"); } + const targetedPrimaryOwners = new Set([firstOwner, secondOwner, thirdOwner]); const firstMaster = activeCluster.masters[0]; if (firstMaster === undefined) { throw new Error("Redis Cluster has no primary nodes"); @@ -292,15 +301,10 @@ describe("DialCache Lua protocol on Redis Cluster", () => { ]) { expect(await slotInspector.clusterKeySlot(key)).toBe(redisClusterSlot(key)); } - expect(await slotInspector.clusterKeySlot(watermarkFor(ids[0]!))).toBe( - await slotInspector.clusterKeySlot(watermarkFor(ids[1]!)), - ); - expect(await slotInspector.clusterKeySlot(watermarkFor(ids[2]!))).not.toBe( - await slotInspector.clusterKeySlot(watermarkFor(ids[0]!)), - ); - expect(activeCluster.slots[redisClusterSlot(watermarkFor(ids[2]!))]?.master.id).not.toBe( - sameSlotOwner, - ); + expect(firstSlot).not.toBe(secondSlot); + expect(secondOwner).toBe(firstOwner); + expect(thirdOwner).not.toBe(firstOwner); + expect(targetedPrimaryOwners.size).toBe(2); const scriptClient = createNodeRedisDialCacheClient(activeCluster); const dialcache = new DialCache({ @@ -326,7 +330,15 @@ describe("DialCache Lua protocol on Redis Cluster", () => { await client.scriptFlush(); }), ); - await dialcache.invalidateRemoteMany(ids.map((id) => ({ keyType, id }))); + const executePipeline = vi.spyOn(activeCluster, "multi"); + try { + await expect( + dialcache.invalidateRemoteMany(ids.map((id) => ({ keyType, id }))), + ).resolves.toBeUndefined(); + expect(executePipeline).toHaveBeenCalledTimes(targetedPrimaryOwners.size); + } finally { + executePipeline.mockRestore(); + } await new Promise((resolve) => setTimeout(resolve, 2)); const after = await dialcache.enable(async () => await Promise.all(ids.map(getValue))); @@ -336,7 +348,7 @@ describe("DialCache Lua protocol on Redis Cluster", () => { expect(watermarks.every((watermark) => watermark !== null && /^\d+$/.test(watermark))).toBe(true); }); - it("batches same-slot and cross-primary invalidations through Valkey GLIDE Cluster", async () => { + it("batches distinct-slot and cross-primary invalidations through Valkey GLIDE Cluster", async () => { if (cluster === undefined || glideCluster === undefined) { throw new Error("Redis Cluster clients did not start"); } @@ -369,8 +381,12 @@ describe("DialCache Lua protocol on Redis Cluster", () => { } await activeGlideCluster.scriptFlush({ route: "allPrimaries" }); await dialcache.invalidateRemoteMany(ids.map((id) => ({ keyType, id }))); - expect(executeBatch).toHaveBeenCalledOnce(); + expect(executeBatch).toHaveBeenCalledTimes(2); expect(executeBatch.mock.calls[0]?.[0]).toBeInstanceOf(valkeyGlide.ClusterBatch); + expect(executeBatch.mock.calls[1]?.[0]).toBeInstanceOf(valkeyGlide.ClusterBatch); + await dialcache.invalidateRemoteMany(ids.map((id) => ({ keyType, id }))); + expect(executeBatch).toHaveBeenCalledTimes(3); + expect(executeBatch.mock.calls[2]?.[0]).toBeInstanceOf(valkeyGlide.ClusterBatch); await new Promise((resolve) => setTimeout(resolve, 2)); const after = await dialcache.enable(async () => await Promise.all(ids.map(getValue))); diff --git a/test/valkey-glide.test.ts b/test/valkey-glide.test.ts index ada7bda..ff14422 100644 --- a/test/valkey-glide.test.ts +++ b/test/valkey-glide.test.ts @@ -23,11 +23,13 @@ const INVALID_WRITE_REPLIES: readonly unknown[] = [ const INVALID_INVALIDATION_REPLIES: readonly unknown[] = [0, ...INVALID_WRITE_REPLIES]; const decoderBytes = Symbol("bytes"); +const invalidateScriptHash = "invalidate-script-hash"; const scriptInstances: MockScript[] = []; const batchInstances: MockBatch[] = []; const clusterBatchInstances: MockClusterBatch[] = []; class MockScript { + readonly getHash = vi.fn(() => invalidateScriptHash); readonly release = vi.fn(); constructor(readonly code: string) { @@ -60,7 +62,11 @@ class MockClusterClient { async (_script: MockScript, _options: InvokeScriptOptions): Promise => null, ); readonly exec = vi.fn( - async (_batch: MockClusterBatch, _raiseOnError: boolean, _options: { decoder: typeof decoderBytes }) => + async ( + _batch: MockClusterBatch, + _raiseOnError: boolean, + _options: BatchExecutionOptions, + ) => [1], ); } @@ -79,13 +85,21 @@ interface InvokeScriptOptions { decoder: typeof decoderBytes; } +interface BatchExecutionOptions { + decoder: typeof decoderBytes; + retryStrategy?: { + retryServerError: boolean; + retryConnectionError: boolean; + }; +} + function fakeClient(...replies: unknown[]) { return { exec: vi.fn( async ( _batch: MockBatch, _raiseOnError: boolean, - _options: { decoder: typeof decoderBytes }, + _options: BatchExecutionOptions, ): Promise => [], ), invokeScript: vi.fn(async (_script: MockScript, _options: InvokeScriptOptions) => replies.shift()), @@ -195,7 +209,7 @@ describe("Valkey GLIDE adapter", () => { ); }); - it("executes standalone invalidations in one non-atomic batch", async () => { + it("executes warm standalone invalidations by hash in one non-atomic batch", async () => { const client = fakeClient(); client.exec.mockResolvedValueOnce([1, 1]); const adapter = createValkeyGlideDialCacheClient(client, mockGlide); @@ -210,8 +224,8 @@ describe("Valkey GLIDE adapter", () => { expect(batchInstances[0]).toMatchObject({ isAtomic: false, commands: [ - ["EVAL", expect.any(String), "1", "cache:{one}:watermark", "0"], - ["EVAL", expect.any(String), "1", "cache:{two}:watermark", "250"], + ["EVALSHA", invalidateScriptHash, "1", "cache:{one}:watermark", "0"], + ["EVALSHA", invalidateScriptHash, "1", "cache:{two}:watermark", "250"], ], }); expect(client.exec).toHaveBeenCalledTimes(1); @@ -220,6 +234,79 @@ describe("Valkey GLIDE adapter", () => { true, { decoder: decoderBytes }, ); + expect(scriptInstances[4]?.getHash).toHaveBeenCalledTimes(1); + }); + + it.each([ + ["standard Redis", new Error("NOSCRIPT No matching script. Please use EVAL.")], + [ + "GLIDE 2.4.2", + new Error( + "An error was signalled by the server - NoScriptError: No matching script. Please use EVAL.", + ), + ], + ])("retries a cold %s script batch once with EVAL", async (_label, cacheMiss) => { + const client = fakeClient(); + client.exec.mockRejectedValueOnce(cacheMiss).mockResolvedValueOnce([1, 1]); + const adapter = createValkeyGlideDialCacheClient(client, mockGlide); + + await expect(adapter.invalidateMany([ + { watermarkKey: "cache:{one}:watermark", futureBufferMs: 0 }, + { watermarkKey: "cache:{two}:watermark", futureBufferMs: 250 }, + ])).resolves.toBeUndefined(); + + expect(client.exec).toHaveBeenCalledTimes(2); + expect(batchInstances).toHaveLength(2); + expect(batchInstances[0]?.commands).toEqual([ + ["EVALSHA", invalidateScriptHash, "1", "cache:{one}:watermark", "0"], + ["EVALSHA", invalidateScriptHash, "1", "cache:{two}:watermark", "250"], + ]); + expect(batchInstances[1]?.commands).toEqual([ + ["EVAL", expect.any(String), "1", "cache:{one}:watermark", "0"], + ["EVAL", expect.any(String), "1", "cache:{two}:watermark", "250"], + ]); + }); + + it("preserves unrelated native batch errors without retrying", async () => { + const failure = new Error("ERR invalid command arguments"); + const client = fakeClient(); + client.exec.mockRejectedValueOnce(failure); + const adapter = createValkeyGlideDialCacheClient(client, mockGlide); + + await expect(adapter.invalidateMany([ + { watermarkKey: "cache:{one}:watermark", futureBufferMs: 0 }, + ])).rejects.toBe(failure); + + expect(client.exec).toHaveBeenCalledTimes(1); + expect(batchInstances).toHaveLength(1); + adapter.dispose(); + }); + + it("keeps one-pass EVAL batching for Script handles without getHash", async () => { + class ScriptWithoutHash { + readonly release = vi.fn(); + } + const glideWithoutHash = { + ...mockGlide, + Script: ScriptWithoutHash, + }; + const client = { + exec: vi.fn(async () => [1, 1]), + invokeScript: vi.fn(async () => null), + }; + const adapter = createValkeyGlideDialCacheClient(client, glideWithoutHash); + + await expect(adapter.invalidateMany([ + { watermarkKey: "cache:{one}:watermark", futureBufferMs: 0 }, + { watermarkKey: "cache:{two}:watermark", futureBufferMs: 250 }, + ])).resolves.toBeUndefined(); + + expect(client.exec).toHaveBeenCalledTimes(1); + expect(batchInstances).toHaveLength(1); + expect(batchInstances[0]?.commands).toEqual([ + ["EVAL", expect.any(String), "1", "cache:{one}:watermark", "0"], + ["EVAL", expect.any(String), "1", "cache:{two}:watermark", "250"], + ]); }); it("keeps legacy scalar-only GLIDE wrappers compatible", async () => { @@ -266,7 +353,13 @@ describe("Valkey GLIDE adapter", () => { expect(client.exec).toHaveBeenCalledWith( clusterBatchInstances[0], true, - { decoder: decoderBytes }, + { + decoder: decoderBytes, + retryStrategy: { + retryServerError: true, + retryConnectionError: true, + }, + }, ); }); @@ -424,6 +517,40 @@ describe("Valkey GLIDE adapter", () => { expect(scriptInstances.every((script) => script.release.mock.calls.length === 1)).toBe(true); }); + it("tracks a cold-script EVAL retry until the fallback batch settles", async () => { + let resolveFallback: ((value: unknown[]) => void) | undefined; + const client = fakeClient(); + client.exec + .mockRejectedValueOnce( + new Error( + "An error was signalled by the server - NoScriptError: No matching script. Please use EVAL.", + ), + ) + .mockImplementationOnce( + async () => await new Promise((resolve) => { + resolveFallback = resolve; + }), + ); + const adapter = createValkeyGlideDialCacheClient(client, mockGlide); + + const invalidation = adapter.invalidateMany([ + { watermarkKey: "cache:{cold-in-flight}:watermark", futureBufferMs: 0 }, + ]); + await vi.waitFor(() => { + expect(client.exec).toHaveBeenCalledTimes(2); + }); + + expect(() => adapter.dispose()).toThrow( + "Cannot dispose Valkey GLIDE DialCache client while operations are in flight", + ); + expect(scriptInstances.every((script) => script.release.mock.calls.length === 0)).toBe(true); + + resolveFallback?.([1]); + await expect(invalidation).resolves.toBeUndefined(); + adapter.dispose(); + expect(scriptInstances.every((script) => script.release.mock.calls.length === 1)).toBe(true); + }); + it("uses Script and Decoder from the supplied GLIDE module instance", async () => { class OtherScript { readonly release = vi.fn(); From 5e09ac1999be28eb1fccecb9b16f76b7244a84c8 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Fri, 31 Jul 2026 16:36:46 -0700 Subject: [PATCH 4/5] fix: bound node-redis batch invalidation work --- README.md | 4 +- src/internal/redis-cache.ts | 33 +++--- src/node-redis.ts | 96 ++++++++++------- src/valkey-glide.ts | 2 + test/node-redis.test.ts | 206 +++++++++++++++++++++++++----------- 5 files changed, 227 insertions(+), 114 deletions(-) diff --git a/README.md b/README.md index bef5198..417bc3d 100644 --- a/README.md +++ b/README.md @@ -396,7 +396,7 @@ Awaiting those public promises does not drain detached shadow work. Shadow sched 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 scalar scripts by their first key and performs that fallback on the selected shard. For batch invalidation, the adapter uses one non-transactional pipeline on standalone Redis. On Redis Cluster it computes each watermark key's slot, maps that slot through node-redis's current topology, groups the requests by primary owner, and routes one pipeline per targeted primary using the partition's first key; node-redis still selects the node. If an owner partition ultimately fails with `MOVED` or `ASK` while topology changes, the adapter conservatively retries that partition through independently routed scalar commands. Other pipeline failures retain the normal partial-execution and aggregate-error semantics. A narrow structural node-redis wrapper without `multi` remains compatible through scalar fallback. +Node-redis computes each script's SHA, uses `EVALSHA`, and retries with `EVAL` after `NOSCRIPT`. Its cluster client routes scalar scripts by their first key and performs that fallback on the selected shard. For batch invalidation, the adapter uses sequential non-transactional pipelines of at most 1,000 commands on standalone Redis. On Redis Cluster it computes each watermark key's slot, maps that slot through node-redis's current topology, groups the requests by primary owner, and runs the owner groups concurrently while sending at most 1,000 commands per sequential pipeline for each owner; node-redis still selects the node from the chunk's first key. This ceiling bounds DialCache's queued contribution per execution chunk but cannot guarantee admission when the caller configured a lower `commandsQueueMaxLength` or the queue is already occupied. Requests whose slots have no mapped owner use registered scalar scripts, also in sequential groups of at most 1,000 concurrent commands. If an owner chunk ultimately fails with `MOVED` or `ASK` while topology changes, the adapter conservatively retries that chunk through independently routed scalar commands. Recovery begins only after node-redis exhausts its configured `maxCommandRedirections` budget, which is 16 retries by default, so resharding can add those pipeline attempts before scalar fallback starts. Other pipeline failures retain the normal partial-execution and aggregate-error semantics. A narrow structural node-redis wrapper without `multi` remains compatible through scalar fallback. The GLIDE adapter uses GLIDE's native `Script` lifecycle and byte decoder for scalar operations and, when the supplied runtime exposes the necessary capabilities, its native standalone or cluster batch support for batch invalidation. The native batch path uses the invalidation script's hash with `EVALSHA`; after a cold `SCRIPT FLUSH`, a script-cache miss retries the whole idempotent batch once with `EVAL`, and subsequent warm batches return to one `EVALSHA` execution. A runtime whose `Script` handle does not expose `getHash()` uses `EVAL` directly. GLIDE Cluster batches enable GLIDE's server-error and connection-error retries, so a batch may be replayed; monotonic invalidation makes that replay safe. Standalone batches do not opt into those retry flags. @@ -630,7 +630,7 @@ await dialcache.invalidateRemoteMany(affectedTargets, USER_INVALIDATION_BUFFER_M `invalidateRemoteMany` is one public semantic batch operation. An empty target list is a no-op and does not call Redis. Before dispatch, DialCache canonicalizes each id with `String(id)` and de-duplicates identical `keyType` plus canonical-id pairs, so numeric `1`, string `"1"`, and bigint `1n` for one key type advance one watermark. Each attempted unique target records one invalidation metric before Redis dispatch. If a batch fails, invalidation errors are counted once per distinct key type rather than once per id, keeping metric cardinality bounded. -Batch invalidation is optimized for fewer client round trips, not cross-key atomicity. Standalone pipelines can partially execute if a connection fails, and Redis Cluster primary-owner partitions execute independently; topology recovery can also replay an affected partition through scalar commands. DialCache waits for every dispatched partition to settle and then rejects when any failed, but a rejected call can still have complete, partial, or ambiguous server-side effects; there is no rollback. Retrying the full canonical target list is safe because each invalidation script advances its watermark monotonically and never lowers it. +Batch invalidation is optimized for fewer client round trips, not cross-key atomicity. Standalone pipelines can partially execute if a connection fails, and Redis Cluster primary-owner groups execute independently; topology recovery can also replay an affected chunk through scalar commands. Chunks within one execution group run sequentially, and a failed chunk prevents later chunks in that group from being submitted. Cluster owner and unmapped-scalar groups start concurrently, and DialCache waits for every already-started group before rejecting. A rejected call can still have complete, partial, or ambiguous server-side effects; there is no rollback. Retrying the full canonical target list is safe because each invalidation script advances its watermark monotonically and never lowers it. 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. diff --git a/src/internal/redis-cache.ts b/src/internal/redis-cache.ts index 638fa7f..bbd194d 100644 --- a/src/internal/redis-cache.ts +++ b/src/internal/redis-cache.ts @@ -10,7 +10,11 @@ import { type MetricErrorKind, type MetricLayer, } from "../metrics.js"; -import type { DialCacheRedisClient, RedisCachePayload } from "../redis-client.js"; +import type { + DialCacheRedisClient, + RedisCachePayload, + RedisInvalidationRequest, +} from "../redis-client.js"; import { JsonSerializer, type Serializer } from "../serializer.js"; import type { RedisCacheGetResult } from "./cache-result.js"; import { awaitAll } from "./await-all.js"; @@ -252,10 +256,7 @@ export class RedisCache { } async invalidate(keyType: string, id: string, futureBufferMs = 0, namespace = "urn"): Promise { - await this.client.invalidate({ - watermarkKey: this.redisWatermarkKey(namespace, keyType, id), - futureBufferMs, - }); + await this.client.invalidate(this.redisInvalidationRequest(namespace, keyType, id, futureBufferMs)); } async invalidateMany( @@ -264,14 +265,10 @@ export class RedisCache { namespace = "urn", ): Promise { // Derive every key before dispatch so invalid input cannot partially mutate Redis. - const requests = targets.map(({ keyType, id }) => ({ - watermarkKey: this.redisWatermarkKey(namespace, keyType, id), - futureBufferMs, - })); + const requests = targets.map(({ keyType, id }) => + this.redisInvalidationRequest(namespace, keyType, id, futureBufferMs), + ); - if (requests.length === 0) { - return; - } if (this.client.invalidateMany !== undefined) { await this.client.invalidateMany(requests); return; @@ -291,6 +288,18 @@ export class RedisCache { return `${redisClusterHashTag(invalidationPrefix(namespace, keyType, id))}#watermark`; } + private redisInvalidationRequest( + namespace: string, + keyType: string, + id: string, + futureBufferMs: number, + ): RedisInvalidationRequest { + return { + watermarkKey: this.redisWatermarkKey(namespace, keyType, id), + futureBufferMs, + }; + } + private redisWatermarkKeyFromKey(key: DialCacheKey): string { return this.redisWatermarkKey(key.namespace, key.keyType, key.id); } diff --git a/src/node-redis.ts b/src/node-redis.ts index acf2d1b..7f3503f 100644 --- a/src/node-redis.ts +++ b/src/node-redis.ts @@ -179,6 +179,15 @@ interface NodeRedisInvalidationRequest { readonly futureBufferMs: number; } +// Pipelines are not transactions, so their size is a resource bound rather +// than a correctness requirement. Bound how much one batch queues at a time. +const MAX_INVALIDATION_COMMANDS_PER_CHUNK = 1_000; + +interface NodeRedisInvalidationPartition { + readonly mode: "pipeline" | "scalar"; + readonly requests: readonly NodeRedisInvalidationRequest[]; +} + function hasNodeRedisPipeline(client: NodeRedisScriptClient): client is NodeRedisPipelineClient { return client.multi !== undefined; } @@ -190,18 +199,23 @@ function isNodeRedisClusterClient(client: NodeRedisPipelineClient): client is No function partitionClusterInvalidations( client: NodeRedisClusterClient, requests: readonly NodeRedisInvalidationRequest[], -): NodeRedisInvalidationRequest[][] { - const partitions: NodeRedisInvalidationRequest[][] = []; +): NodeRedisInvalidationPartition[] { + const partitions: NodeRedisInvalidationPartition[] = []; const partitionsByOwner = new Map(); + let unmappedRequests: NodeRedisInvalidationRequest[] | undefined; const slots = client.slots; for (const request of requests) { const slot = redisClusterSlot(request.watermarkKey); const ownerId = slots[slot]?.master?.id; if (ownerId === undefined) { - // A stale or incomplete topology entry cannot be safely combined with another slot. - // Keep it isolated and let node-redis route it from its own key. - partitions.push([request]); + // Registered scalar scripts route from their own key and avoid sending + // the full Lua source through a one-command pipeline. + if (unmappedRequests === undefined) { + unmappedRequests = []; + partitions.push({ mode: "scalar", requests: unmappedRequests }); + } + unmappedRequests.push(request); continue; } @@ -209,7 +223,7 @@ function partitionClusterInvalidations( if (existing === undefined) { const partition = [request]; partitionsByOwner.set(ownerId, partition); - partitions.push(partition); + partitions.push({ mode: "pipeline", requests: partition }); } else { existing.push(request); } @@ -218,45 +232,33 @@ function partitionClusterInvalidations( return partitions; } +async function executeScalarInvalidation( + client: NodeRedisScriptClient, + { watermarkKey, futureBufferMs }: NodeRedisInvalidationRequest, +): Promise { + const reply = await client.dialcacheInvalidate(watermarkKey, futureBufferMs); + validateRedisScriptInvalidationReply(reply); +} + async function executeScalarInvalidations( client: NodeRedisScriptClient, requests: readonly NodeRedisInvalidationRequest[], ): Promise { - await awaitAll( - requests.map(async ({ watermarkKey, futureBufferMs }) => { - const reply = await client.dialcacheInvalidate(watermarkKey, futureBufferMs); - validateRedisScriptInvalidationReply(reply); - }), - "Multiple DialCache invalidations failed", - ); + for (let index = 0; index < requests.length; index += MAX_INVALIDATION_COMMANDS_PER_CHUNK) { + await awaitAll( + requests + .slice(index, index + MAX_INVALIDATION_COMMANDS_PER_CHUNK) + .map(async (request) => await executeScalarInvalidation(client, request)), + "Multiple DialCache invalidations failed", + ); + } } -function isDirectRedisClusterRedirection(error: unknown): boolean { +function isRedisClusterRedirection(error: unknown): boolean { return error instanceof Error && (error.message.startsWith("MOVED ") || error.message.startsWith("ASK ")); } -function isRedisClusterRedirection(error: unknown): boolean { - if (!(error instanceof Error) || !("replies" in error) || !("errorIndexes" in error)) { - return isDirectRedisClusterRedirection(error); - } - - const { replies, errorIndexes } = error as Error & { - readonly replies: unknown; - readonly errorIndexes: unknown; - }; - if (!Array.isArray(replies) || !Array.isArray(errorIndexes) || errorIndexes.length === 0) { - return false; - } - - return errorIndexes.every((index: unknown) => - typeof index === "number" - && Number.isInteger(index) - && index >= 0 - && index < replies.length - && isDirectRedisClusterRedirection(replies[index])); -} - async function executeInvalidationPipeline( client: NodeRedisPipelineClient, requests: readonly NodeRedisInvalidationRequest[], @@ -296,6 +298,18 @@ async function executeInvalidationPipeline( } } +async function executeInvalidationPipelineChunks( + client: NodeRedisPipelineClient, + requests: readonly NodeRedisInvalidationRequest[], +): Promise { + for (let index = 0; index < requests.length; index += MAX_INVALIDATION_COMMANDS_PER_CHUNK) { + await executeInvalidationPipeline( + client, + requests.slice(index, index + MAX_INVALIDATION_COMMANDS_PER_CHUNK), + ); + } +} + /** * Create a resource-free semantic view over a caller-owned node-redis client. * Read signals are passed to node-redis so queued commands can be removed when @@ -342,11 +356,17 @@ export function createNodeRedisDialCacheClient(client: NodeRedisScriptClient): D return; } - const partitions = isNodeRedisClusterClient(client) + const partitions: readonly NodeRedisInvalidationPartition[] = isNodeRedisClusterClient(client) ? partitionClusterInvalidations(client, requests) - : [requests]; + : [{ mode: "pipeline", requests }]; await awaitAll( - partitions.map(async (partition) => await executeInvalidationPipeline(client, partition)), + partitions.map(async (partition) => { + if (partition.mode === "scalar") { + await executeScalarInvalidations(client, partition.requests); + return; + } + await executeInvalidationPipelineChunks(client, partition.requests); + }), "Multiple DialCache invalidation partitions failed", ); }, diff --git a/src/valkey-glide.ts b/src/valkey-glide.ts index eb08a62..f1a5714 100644 --- a/src/valkey-glide.ts +++ b/src/valkey-glide.ts @@ -89,6 +89,8 @@ interface DialCacheGlideScripts { readonly invalidate: TScript; } +// GLIDE 2.4.2 exposes script-cache misses only through Error.message. Match +// the observed GLIDE form and Redis's standard token, and fail closed otherwise. function isScriptCacheMiss(error: unknown): boolean { if (!(error instanceof Error)) { return false; diff --git a/test/node-redis.test.ts b/test/node-redis.test.ts index 2cbcaaf..f3c48cf 100644 --- a/test/node-redis.test.ts +++ b/test/node-redis.test.ts @@ -189,6 +189,55 @@ describe("node-redis adapter", () => { expect(pipelines[0]?.execAsPipeline).toHaveBeenCalledOnce(); }); + it("bounds standalone pipelines and dispatches their chunks sequentially", async () => { + let releaseFirst!: () => void; + const firstGate = new Promise((resolve) => { + releaseFirst = resolve; + }); + const requests = Array.from({ length: 1_001 }, (_, index) => ({ + watermarkKey: `cache:{standalone-${index}}:watermark`, + futureBufferMs: index, + })); + const { client, pipelines } = fakeBatchClient({ + execute: async ({ routing, commands }) => { + if (routing === requests[0]?.watermarkKey) { + await firstGate; + } + return commands.map(() => 1); + }, + }); + const adapter = createNodeRedisDialCacheClient(client as never); + + const operation = adapter.invalidateMany?.(requests) ?? Promise.resolve(); + await vi.waitFor(() => expect(pipelines).toHaveLength(1)); + expect(pipelines[0]?.commands).toHaveLength(1_000); + + releaseFirst(); + await operation; + expect(client.multi).toHaveBeenCalledTimes(2); + expect(pipelines).toHaveLength(2); + expect(pipelines[1]).toMatchObject({ + routing: requests[1_000]?.watermarkKey, + commands: [[requests[1_000]?.watermarkKey, 1_000]], + }); + }); + + it("does not dispatch later standalone chunks after one fails", async () => { + const firstError = new Error("first chunk failed"); + const requests = Array.from({ length: 1_001 }, (_, index) => ({ + watermarkKey: `cache:{failed-standalone-${index}}:watermark`, + futureBufferMs: index, + })); + const { client, pipelines } = fakeBatchClient({ + execute: async () => { throw firstError; }, + }); + const adapter = createNodeRedisDialCacheClient(client as never); + + await expect(adapter.invalidateMany?.(requests) ?? Promise.resolve()).rejects.toBe(firstError); + expect(pipelines).toHaveLength(1); + expect(pipelines[0]?.commands).toHaveLength(1_000); + }); + it("partitions node-redis Cluster pipelines by current primary owner", async () => { const first = "cache:{one}:watermark"; const second = "cache:{two}:watermark"; @@ -218,7 +267,65 @@ describe("node-redis adapter", () => { expect(pipelines[1]?.commands).toEqual([[second, 20]]); }); - it("keeps requests with unmapped Cluster slots in isolated key-routed pipelines", async () => { + it("runs Cluster owners concurrently and each owner's bounded chunks sequentially", async () => { + let releaseFirstOwner!: () => void; + const firstOwnerGate = new Promise((resolve) => { + releaseFirstOwner = resolve; + }); + const firstOwnerRequests = Array.from({ length: 1_001 }, (_, index) => ({ + watermarkKey: `cache:{primary-a-${index}}:watermark`, + futureBufferMs: index, + })); + const firstOwnerSlots = new Set( + firstOwnerRequests.map(({ watermarkKey }) => redisClusterSlot(watermarkKey)), + ); + let secondOwnerKey = ""; + for (let index = 0; index < 16_384; index += 1) { + const candidate = `cache:{primary-b-${index}}:watermark`; + if (!firstOwnerSlots.has(redisClusterSlot(candidate))) { + secondOwnerKey = candidate; + break; + } + } + expect(secondOwnerKey).not.toBe(""); + + const { client, pipelines } = fakeBatchClient({ + slots: fakeClusterSlots([ + ...firstOwnerRequests.map(({ watermarkKey }) => [watermarkKey, "primary-a"] as const), + [secondOwnerKey, "primary-b"], + ]), + execute: async ({ routing, commands }) => { + if (routing === firstOwnerRequests[0]?.watermarkKey) { + await firstOwnerGate; + } + return commands.map(() => 1); + }, + }); + const adapter = createNodeRedisDialCacheClient(client as never); + + const operation = adapter.invalidateMany?.([ + ...firstOwnerRequests, + { watermarkKey: secondOwnerKey, futureBufferMs: 2_000 }, + ]) ?? Promise.resolve(); + await vi.waitFor(() => expect(pipelines).toHaveLength(2)); + expect(pipelines.map(({ routing }) => routing)).toEqual([ + firstOwnerRequests[0]?.watermarkKey, + secondOwnerKey, + ]); + expect(pipelines[0]?.commands).toHaveLength(1_000); + expect(pipelines[1]?.commands).toEqual([[secondOwnerKey, 2_000]]); + expect(pipelines[1]?.execAsPipeline).toHaveBeenCalledOnce(); + + releaseFirstOwner(); + await operation; + expect(pipelines).toHaveLength(3); + expect(pipelines[2]).toMatchObject({ + routing: firstOwnerRequests[1_000]?.watermarkKey, + commands: [[firstOwnerRequests[1_000]?.watermarkKey, 1_000]], + }); + }); + + it("routes requests with unmapped Cluster slots through registered scalar scripts", async () => { const mappedOne = "cache:{mapped-one}:watermark"; const unmappedOne = "cache:{unmapped-one}:watermark"; const mappedTwo = "cache:{mapped-two}:watermark"; @@ -238,17 +345,39 @@ describe("node-redis adapter", () => { { watermarkKey: unmappedTwo, futureBufferMs: 40 }, ]); - expect(client.multi).toHaveBeenCalledTimes(3); - expect(pipelines.map(({ routing }) => routing)).toEqual([ - mappedOne, - unmappedOne, - unmappedTwo, - ]); + expect(client.multi).toHaveBeenCalledOnce(); + expect(pipelines.map(({ routing }) => routing)).toEqual([mappedOne]); expect(pipelines.map(({ commands }) => commands)).toEqual([ [[mappedOne, 10], [mappedTwo, 30]], - [[unmappedOne, 20]], - [[unmappedTwo, 40]], ]); + expect(client.dialcacheInvalidate).toHaveBeenCalledTimes(2); + expect(client.dialcacheInvalidate).toHaveBeenNthCalledWith(1, unmappedOne, 20); + expect(client.dialcacheInvalidate).toHaveBeenNthCalledWith(2, unmappedTwo, 40); + }); + + it("bounds scalar routing for unmapped Cluster slots", async () => { + let releaseFirstChunk!: () => void; + const firstChunkGate = new Promise((resolve) => { + releaseFirstChunk = resolve; + }); + const { client } = fakeBatchClient({ cluster: true }); + client.dialcacheInvalidate.mockImplementation(async () => { + await firstChunkGate; + return 1; + }); + const adapter = createNodeRedisDialCacheClient(client as never); + const requests = Array.from({ length: 1_001 }, (_, index) => ({ + watermarkKey: `cache:{unmapped-${index}}:watermark`, + futureBufferMs: index, + })); + + const operation = adapter.invalidateMany?.(requests) ?? Promise.resolve(); + await vi.waitFor(() => expect(client.dialcacheInvalidate).toHaveBeenCalledTimes(1_000)); + expect(client.multi).not.toHaveBeenCalled(); + + releaseFirstChunk(); + await operation; + expect(client.dialcacheInvalidate).toHaveBeenCalledTimes(1_001); }); it("falls back to registered scalar scripts when a client has no pipeline surface", async () => { @@ -323,65 +452,18 @@ describe("node-redis adapter", () => { expect(client.dialcacheInvalidate).toHaveBeenNthCalledWith(2, second, 20); }); - it("recovers a pipeline aggregate when every failed reply is a redirection", async () => { - const first = "cache:{one}:watermark"; - const second = "cache:{two}:watermark"; - const pipelineError = Object.assign(new Error("2 commands failed"), { - replies: [ - new Error("MOVED 12 127.0.0.1:7001"), - new Error("ASK 34 127.0.0.1:7002"), - ], - errorIndexes: [0, 1], - }); - const { client } = fakeBatchClient({ - slots: fakeClusterSlots([[first, "primary-a"], [second, "primary-a"]]), - execute: async () => { throw pipelineError; }, - }); - const adapter = createNodeRedisDialCacheClient(client as never); - - await adapter.invalidateMany?.([ - { watermarkKey: first, futureBufferMs: 10 }, - { watermarkKey: second, futureBufferMs: 20 }, - ]); - - expect(client.dialcacheInvalidate).toHaveBeenCalledTimes(2); - }); - - it("preserves a pipeline aggregate containing a non-redirection failure", async () => { - const first = "cache:{one}:watermark"; - const second = "cache:{two}:watermark"; - const pipelineError = Object.assign(new Error("2 commands failed"), { - replies: [ - new Error("MOVED 12 127.0.0.1:7001"), - new Error("ERR script failure"), - ], - errorIndexes: [0, 1], - }); - const { client } = fakeBatchClient({ - slots: fakeClusterSlots([[first, "primary-a"], [second, "primary-a"]]), - execute: async () => { throw pipelineError; }, - }); - const adapter = createNodeRedisDialCacheClient(client as never); - - const operation = adapter.invalidateMany?.([ - { watermarkKey: first, futureBufferMs: 10 }, - { watermarkKey: second, futureBufferMs: 20 }, - ]) ?? Promise.resolve(); - - await expect(operation).rejects.toBe(pipelineError); - expect(client.dialcacheInvalidate).not.toHaveBeenCalled(); - }); - it("waits for every Cluster partition before surfacing a failure", async () => { let releaseSecond!: () => void; const secondGate = new Promise((resolve) => { releaseSecond = resolve; }); const firstError = new Error("first partition failed"); + const first = "cache:{one}:watermark"; + const second = "cache:{two}:watermark"; const { client, pipelines } = fakeBatchClient({ - cluster: true, + slots: fakeClusterSlots([[first, "primary-a"], [second, "primary-b"]]), execute: async ({ routing, commands }) => { - if (routing === "cache:{one}:watermark") { + if (routing === first) { throw firstError; } await secondGate; @@ -392,8 +474,8 @@ describe("node-redis adapter", () => { let settled = false; const operation = Promise.resolve(adapter.invalidateMany?.([ - { watermarkKey: "cache:{one}:watermark", futureBufferMs: 0 }, - { watermarkKey: "cache:{two}:watermark", futureBufferMs: 0 }, + { watermarkKey: first, futureBufferMs: 0 }, + { watermarkKey: second, futureBufferMs: 0 }, ])).finally(() => { settled = true; }); From c8869e464a6b7accc8575e46f1d18eb3a0eb02ba Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Fri, 31 Jul 2026 20:20:53 -0700 Subject: [PATCH 5/5] fix: bound GLIDE batch invalidation work --- README.md | 12 +- scripts/benchmark-batch-invalidation.mjs | 152 ++++++++++++++------- src/redis-client.ts | 11 +- src/valkey-glide.ts | 92 ++++++++----- test/dialcache-invalidation.test.ts | 2 +- test/valkey-glide.test.ts | 161 +++++++++++++++++++++++ 6 files changed, 341 insertions(+), 89 deletions(-) diff --git a/README.md b/README.md index 417bc3d..baf6566 100644 --- a/README.md +++ b/README.md @@ -398,11 +398,13 @@ The node-redis adapter owns no additional resources, so the application closes t Node-redis computes each script's SHA, uses `EVALSHA`, and retries with `EVAL` after `NOSCRIPT`. Its cluster client routes scalar scripts by their first key and performs that fallback on the selected shard. For batch invalidation, the adapter uses sequential non-transactional pipelines of at most 1,000 commands on standalone Redis. On Redis Cluster it computes each watermark key's slot, maps that slot through node-redis's current topology, groups the requests by primary owner, and runs the owner groups concurrently while sending at most 1,000 commands per sequential pipeline for each owner; node-redis still selects the node from the chunk's first key. This ceiling bounds DialCache's queued contribution per execution chunk but cannot guarantee admission when the caller configured a lower `commandsQueueMaxLength` or the queue is already occupied. Requests whose slots have no mapped owner use registered scalar scripts, also in sequential groups of at most 1,000 concurrent commands. If an owner chunk ultimately fails with `MOVED` or `ASK` while topology changes, the adapter conservatively retries that chunk through independently routed scalar commands. Recovery begins only after node-redis exhausts its configured `maxCommandRedirections` budget, which is 16 retries by default, so resharding can add those pipeline attempts before scalar fallback starts. Other pipeline failures retain the normal partial-execution and aggregate-error semantics. A narrow structural node-redis wrapper without `multi` remains compatible through scalar fallback. -The GLIDE adapter uses GLIDE's native `Script` lifecycle and byte decoder for scalar operations and, when the supplied runtime exposes the necessary capabilities, its native standalone or cluster batch support for batch invalidation. The native batch path uses the invalidation script's hash with `EVALSHA`; after a cold `SCRIPT FLUSH`, a script-cache miss retries the whole idempotent batch once with `EVAL`, and subsequent warm batches return to one `EVALSHA` execution. A runtime whose `Script` handle does not expose `getHash()` uses `EVAL` directly. GLIDE Cluster batches enable GLIDE's server-error and connection-error retries, so a batch may be replayed; monotonic invalidation makes that replay safe. Standalone batches do not opt into those retry flags. +Warm concurrent scalar invalidations are already auto-pipelined by node-redis, so the explicit standalone pipeline is not a general steady-state throughput guarantee. It retains bounded dispatch, per-chunk reply-count validation, and one source-bearing `EVAL` per pipeline instead of independent full-source recovery for every scalar command after `SCRIPT FLUSH`. + +The GLIDE adapter uses GLIDE's native `Script` lifecycle and byte decoder for scalar operations and, when the supplied runtime exposes the necessary capabilities, its native standalone or cluster batch support for batch invalidation. Native batches run in sequential chunks of at most 1,000 commands; GLIDE continues routing each non-atomic Cluster chunk across its target nodes. Scalar-only compatibility wrappers use sequential windows of at most 1,000 concurrent script invocations. The native batch path uses the invalidation script's hash with `EVALSHA`; after a cold `SCRIPT FLUSH`, a script-cache miss retries only the affected idempotent chunk once with `EVAL`, and subsequent chunks return to `EVALSHA`. A runtime whose `Script` handle does not expose `getHash()` uses `EVAL` directly. A failed native chunk or scalar window prevents later work from starting. The ceiling bounds DialCache's contribution but cannot guarantee scalar-path admission against a lower or already-occupied GLIDE in-flight request limit. GLIDE Cluster batches enable GLIDE's server-error and connection-error retries, so a chunk may be replayed; monotonic invalidation makes that replay safe. Standalone batches do not opt into those retry flags. Cluster batch selection relies on `client instanceof glide.GlideClusterClient`, using the constructor from the same supplied GLIDE module namespace. A structural cluster wrapper that does not preserve that identity should expose only scalar scripting rather than forwarding `exec`; it then remains compatible through scalar fallback. Passing a cluster wrapper that forwards `exec` but loses the native cluster identity is unsupported because the adapter cannot safely distinguish its batch type. Tracked reads are deliberately routed to primaries so a lagging replica cannot hide an invalidation watermark. -The semantic `DialCacheRedisClient.invalidateMany` capability is optional. A custom adapter can implement it for client-native batching; an existing scalar-only adapter remains valid, and DialCache falls back to its `invalidate` operation for each target. Adapter batch implementations must preserve the scalar invalidation semantics, but the batching and Redis Cluster routing strategy stays inside the adapter rather than leaking client-specific concepts into the core API. +The semantic `DialCacheRedisClient.invalidateMany` capability is optional. A custom adapter can implement it for client-native batching; an existing scalar-only adapter remains valid, and DialCache falls back by launching its `invalidate` operation concurrently for every target and settling every result. Custom adapters own any additional dispatch limits. Adapter batch implementations must preserve the scalar invalidation semantics, but the batching and Redis Cluster routing strategy stays inside the adapter rather than leaking client-specific concepts into the core API. #### Remote read deadlines and async liveness @@ -630,7 +632,7 @@ await dialcache.invalidateRemoteMany(affectedTargets, USER_INVALIDATION_BUFFER_M `invalidateRemoteMany` is one public semantic batch operation. An empty target list is a no-op and does not call Redis. Before dispatch, DialCache canonicalizes each id with `String(id)` and de-duplicates identical `keyType` plus canonical-id pairs, so numeric `1`, string `"1"`, and bigint `1n` for one key type advance one watermark. Each attempted unique target records one invalidation metric before Redis dispatch. If a batch fails, invalidation errors are counted once per distinct key type rather than once per id, keeping metric cardinality bounded. -Batch invalidation is optimized for fewer client round trips, not cross-key atomicity. Standalone pipelines can partially execute if a connection fails, and Redis Cluster primary-owner groups execute independently; topology recovery can also replay an affected chunk through scalar commands. Chunks within one execution group run sequentially, and a failed chunk prevents later chunks in that group from being submitted. Cluster owner and unmapped-scalar groups start concurrently, and DialCache waits for every already-started group before rejecting. A rejected call can still have complete, partial, or ambiguous server-side effects; there is no rollback. Retrying the full canonical target list is safe because each invalidation script advances its watermark monotonically and never lowers it. +Batch invalidation is one non-atomic semantic client operation, not a guarantee of fewer network round trips or higher throughput. Exact dispatch and timing depend on the adapter, topology, client auto-pipelining, script-cache state, and target count. Standalone pipelines can partially execute if a connection fails, and Redis Cluster work executes independently across nodes; topology or cold-script recovery can also replay an affected chunk. The bundled adapters run chunks sequentially within each execution group, and a failed chunk prevents later chunks in that group from being submitted. Node-redis Cluster owner and unmapped-scalar groups start concurrently, while GLIDE submits one native chunk at a time and lets GLIDE route that chunk. DialCache waits for every already-started node-redis group before rejecting. A rejected call can still have complete, partial, or ambiguous server-side effects; there is no rollback. Retrying the full canonical target list is safe because each invalidation script advances its watermark monotonically and never lowers it. 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. @@ -872,14 +874,14 @@ The command builds `dist` before reporting ten scenarios: sequential request-loc ### Batch-invalidation benchmark -With a live standalone Redis available, compare one batch-invalidation call with concurrent scalar calls through the node-redis adapter: +With a live standalone Redis available, compare one batch-invalidation call with concurrent scalar calls through both bundled adapters: ```bash # Defaults to redis://127.0.0.1:6379. DIALCACHE_BENCH_REDIS_URL=redis://127.0.0.1:6379 pnpm benchmark:batch-invalidation ``` -The command builds `dist`, warms the Lua protocol, and reports median timings over five repetitions for 10, 100, and 1,000 unique targets using `invalidateRemoteMany(...)` versus `Promise.all(targets.map(invalidateRemote))`. Each repetition uses fresh targets, alternates which path runs first, and verifies that every expected watermark was written. Set `DIALCACHE_BENCH_REPETITIONS` to a positive integer to adjust the sample count. The script uses a unique namespace and never flushes Redis; its watermark keys expire under the normal invalidation protocol. Results are directional and depend on Redis topology, network latency, and client configuration, so the script intentionally asserts no performance threshold. The maintainer-only script is not included in the published package. +The command builds `dist`, creates standalone node-redis and Valkey GLIDE clients, warms the Lua protocol for each adapter, and reports one row per adapter and target count. Each row contains median timings over five repetitions for 10, 100, and 1,000 unique targets using `invalidateRemoteMany(...)` versus `Promise.all(targets.map(invalidateRemote))`. Each repetition uses fresh targets, alternates which path runs first, and verifies that every expected watermark was written. The benchmark gives both clients 10,000-command/request queue headroom so the 1,000-way scalar leg is admissible; this is benchmark configuration, not a production sizing recommendation. Set `DIALCACHE_BENCH_REPETITIONS` to a positive integer to adjust the sample count. The script uses a unique namespace and never flushes Redis; its watermark keys expire under the normal invalidation protocol. It measures warm standalone behavior only, not Redis Cluster, cold-script recovery, or requests above 1,000 targets. Results are directional and depend on Redis topology, network latency, and client configuration, so the script intentionally asserts no performance threshold. The maintainer-only script is not included in the published package. ### Releasing diff --git a/scripts/benchmark-batch-invalidation.mjs b/scripts/benchmark-batch-invalidation.mjs index efcccc0..585c93d 100644 --- a/scripts/benchmark-batch-invalidation.mjs +++ b/scripts/benchmark-batch-invalidation.mjs @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { performance } from "node:perf_hooks"; +import * as valkeyGlide from "@valkey/valkey-glide"; import { createClient } from "redis"; import { DialCache, invalidationPrefix, redisClusterHashTag } from "../dist/index.js"; @@ -8,6 +9,7 @@ import { createNodeRedisDialCacheClient, dialcacheRedisScripts, } from "../dist/node-redis.js"; +import { createValkeyGlideDialCacheClient } from "../dist/valkey-glide.js"; const redisUrl = process.env.DIALCACHE_BENCH_REDIS_URL ?? "redis://127.0.0.1:6379"; const sizes = [10, 100, 1_000]; @@ -23,57 +25,74 @@ const redisClient = createClient({ redisClient.on("error", () => undefined); await redisClient.connect(); +let glideClient; +let glideAdapter; try { - const namespace = `dialcache-batch-invalidation-benchmark-${runId}`; - const dialcache = new DialCache({ - namespace, - redis: { client: createNodeRedisDialCacheClient(redisClient) }, - }); + glideClient = await valkeyGlide.GlideClient.createClient(glideConfiguration(redisUrl)); + glideAdapter = createValkeyGlideDialCacheClient(glideClient, valkeyGlide); + const adapters = [ + { name: "node-redis", client: createNodeRedisDialCacheClient(redisClient) }, + { name: "Valkey GLIDE", client: glideAdapter }, + ]; const results = []; - // Pay one-time connection and Lua loading costs before the measured runs. - await dialcache.invalidateRemote("benchmark_warmup", runId); - - for (const size of sizes) { - const scalarSamples = []; - const batchSamples = []; - for (let repetition = 0; repetition < repetitions; repetition += 1) { - const scalarTargets = targetsFor(`scalar-${size}-${repetition}`, size); - const batchTargets = targetsFor(`batch-${size}-${repetition}`, size); - const runScalar = async () => { - const startedAt = performance.now(); - await Promise.all( - scalarTargets.map(({ keyType, id }) => dialcache.invalidateRemote(keyType, id)), - ); - scalarSamples.push(performance.now() - startedAt); - assert.equal(await countWatermarks(namespace, scalarTargets), size); - }; - const runBatch = async () => { - const startedAt = performance.now(); - await dialcache.invalidateRemoteMany(batchTargets); - batchSamples.push(performance.now() - startedAt); - assert.equal(await countWatermarks(namespace, batchTargets), size); - }; - - if (repetition % 2 === 0) { - await runScalar(); - await runBatch(); - } else { - await runBatch(); - await runScalar(); + for (const { name, client } of adapters) { + const namespace = `dialcache-batch-invalidation-benchmark-${name}-${runId}`; + const dialcache = new DialCache({ + namespace, + redis: { client }, + }); + + // Pay one-time connection and Lua loading costs before the measured runs. + await dialcache.invalidateRemote("benchmark_warmup", runId); + + for (const size of sizes) { + const scalarSamples = []; + const batchSamples = []; + for (let repetition = 0; repetition < repetitions; repetition += 1) { + const scalarTargets = targetsFor(`${name}-scalar-${size}-${repetition}`, size); + const batchTargets = targetsFor(`${name}-batch-${size}-${repetition}`, size); + const runScalar = async () => { + const startedAt = performance.now(); + const operations = scalarTargets.map(({ keyType, id }) => + dialcache.invalidateRemote(keyType, id)); + try { + await Promise.all(operations); + } catch (error) { + await Promise.allSettled(operations); + throw error; + } + scalarSamples.push(performance.now() - startedAt); + assert.equal(await countWatermarks(namespace, scalarTargets), size); + }; + const runBatch = async () => { + const startedAt = performance.now(); + await dialcache.invalidateRemoteMany(batchTargets); + batchSamples.push(performance.now() - startedAt); + assert.equal(await countWatermarks(namespace, batchTargets), size); + }; + + if (repetition % 2 === 0) { + await runScalar(); + await runBatch(); + } else { + await runBatch(); + await runScalar(); + } } + const scalarMedianMs = median(scalarSamples); + const batchMedianMs = median(batchSamples); + + results.push({ + adapter: name, + targets: size, + repetitions, + "Promise.all scalar median (ms)": scalarMedianMs.toFixed(2), + "batch median (ms)": batchMedianMs.toFixed(2), + "median scalar / batch": (scalarMedianMs / batchMedianMs).toFixed(2), + }); } - const scalarMedianMs = median(scalarSamples); - const batchMedianMs = median(batchSamples); - - results.push({ - targets: size, - repetitions, - "Promise.all scalar median (ms)": scalarMedianMs.toFixed(2), - "batch median (ms)": batchMedianMs.toFixed(2), - "median scalar / batch": (scalarMedianMs / batchMedianMs).toFixed(2), - }); } console.table(results); @@ -81,7 +100,48 @@ try { "Directional maintainer benchmark only: results depend on Redis topology, client configuration, and network conditions; no timing threshold is asserted.", ); } finally { - await redisClient.quit(); + try { + glideAdapter?.dispose(); + } finally { + try { + glideClient?.close(); + } finally { + await redisClient.quit(); + } + } +} + +function glideConfiguration(value) { + const url = new URL(value); + if (url.protocol !== "redis:" && url.protocol !== "rediss:") { + throw new Error("DIALCACHE_BENCH_REDIS_URL must use redis: or rediss:"); + } + if (url.username !== "" && url.password === "") { + throw new Error("DIALCACHE_BENCH_REDIS_URL cannot specify a username without a password"); + } + + const databasePath = url.pathname.replace(/^\//, ""); + const databaseId = databasePath === "" ? 0 : Number(databasePath); + if (!Number.isSafeInteger(databaseId) || databaseId < 0) { + throw new Error("DIALCACHE_BENCH_REDIS_URL must contain a nonnegative database id"); + } + + return { + addresses: [{ host: url.hostname, port: url.port === "" ? 6379 : Number(url.port) }], + databaseId, + useTLS: url.protocol === "rediss:", + requestTimeout: 10_000, + inflightRequestsLimit: 10_000, + advancedConfiguration: { connectionTimeout: 2_000 }, + ...(url.password === "" + ? {} + : { + credentials: { + ...(url.username === "" ? {} : { username: decodeURIComponent(url.username) }), + password: decodeURIComponent(url.password), + }, + }), + }; } function targetsFor(prefix, count) { diff --git a/src/redis-client.ts b/src/redis-client.ts index c238fe8..d414fb7 100644 --- a/src/redis-client.ts +++ b/src/redis-client.ts @@ -111,12 +111,13 @@ export interface DialCacheRedisClient { */ invalidate(request: RedisInvalidationRequest): Awaitable; /** - * Advance multiple watermarks as one client-side batch when supported. + * Advance multiple watermarks as one semantic client operation when supported. * - * Each invalidation remains independently atomic, but the batch is not - * atomic as a whole and can partially complete. A rejected operation can - * also have an ambiguous server-side outcome. Retrying the complete batch is - * safe because watermark advancement is monotonic. + * Adapters own physical dispatch and may use multiple native chunks. Each + * invalidation remains independently atomic, but the operation is not atomic + * as a whole and can partially complete. A rejected operation can also have + * an ambiguous server-side outcome. Retrying the complete target set is safe + * because watermark advancement is monotonic. */ invalidateMany?(requests: readonly RedisInvalidationRequest[]): Awaitable; } diff --git a/src/valkey-glide.ts b/src/valkey-glide.ts index f1a5714..316f762 100644 --- a/src/valkey-glide.ts +++ b/src/valkey-glide.ts @@ -99,8 +99,23 @@ function isScriptCacheMiss(error: unknown): boolean { || /\bNoScriptError:\s*No matching script(?:\.|\s|$)/.test(error.message); } +// A native batch is one protobuf request regardless of its command count, and +// scalar wrappers can exhaust GLIDE's in-flight limit. Bound both paths. +const MAX_INVALIDATION_COMMANDS_PER_CHUNK = 1_000; + +function validateInvalidationBatchReplies(raw: unknown, expectedReplies: number): void { + if (!Array.isArray(raw) || raw.length !== expectedReplies) { + throw new DialCacheRedisProtocolError( + `Invalid DialCache Redis invalidate batch reply; expected ${expectedReplies} replies`, + ); + } + for (const reply of raw) { + validateRedisScriptInvalidationReply(reply); + } +} + export interface ValkeyGlideDialCacheClient extends DialCacheRedisClient { - /** Advance multiple watermarks in one non-atomic GLIDE batch. */ + /** Advance multiple watermarks as one semantic operation through non-atomic GLIDE chunks. */ invalidateMany(requests: readonly RedisInvalidationRequest[]): Promise; /** Release the adapter-owned GLIDE Script handles. Does not close the wrapped GLIDE client. */ dispose(): void; @@ -199,22 +214,29 @@ export function createValkeyGlideDialCacheClient { - await awaitAll( - requests.map(async ({ watermarkKey, futureBufferMs }) => { - const raw = await client.invokeScript(scripts.invalidate, { - keys: [watermarkKey], - args: [String(futureBufferMs)], - decoder: glide.Decoder.Bytes, - }); - validateRedisScriptInvalidationReply(raw); - }), - "Multiple DialCache invalidations failed", - ); + for ( + let index = 0; + index < requests.length; + index += MAX_INVALIDATION_COMMANDS_PER_CHUNK + ) { + const chunk = requests.slice(index, index + MAX_INVALIDATION_COMMANDS_PER_CHUNK); + await awaitAll( + chunk.map(async ({ watermarkKey, futureBufferMs }) => { + const raw = await client.invokeScript(scripts.invalidate, { + keys: [watermarkKey], + args: [String(futureBufferMs)], + decoder: glide.Decoder.Bytes, + }); + validateRedisScriptInvalidationReply(raw); + }), + "Multiple DialCache invalidations failed", + ); + } }); return; } - const raw = await invokeTracked(async () => { + await invokeTracked(async () => { const isCluster = client instanceof GlideClusterClient; const Batch = isCluster ? ClusterBatch : StandaloneBatch; const options: ValkeyGlideBatchExecutionOptions = isCluster @@ -226,9 +248,13 @@ export function createValkeyGlideDialCacheClient { + const executeBatch = async ( + chunk: readonly RedisInvalidationRequest[], + script: string, + command: "EVAL" | "EVALSHA", + ) => { const batch = new Batch(false); - for (const { watermarkKey, futureBufferMs } of requests) { + for (const { watermarkKey, futureBufferMs } of chunk) { batch.customCommand([ command, script, @@ -241,26 +267,28 @@ export function createValkeyGlideDialCacheClient { }); }); - it("batches unique canonical invalidation targets through an optimized client", async () => { + it("batches unique canonical invalidation targets through a batch-capable client", async () => { const invalidate = vi.fn(async () => undefined); const invalidateMany = vi.fn(async () => undefined); const redis: DialCacheRedisClient = { diff --git a/test/valkey-glide.test.ts b/test/valkey-glide.test.ts index ff14422..db4b1b6 100644 --- a/test/valkey-glide.test.ts +++ b/test/valkey-glide.test.ts @@ -106,6 +106,13 @@ function fakeClient(...replies: unknown[]) { }; } +function invalidationRequests(count: number) { + return Array.from({ length: count }, (_, index) => ({ + watermarkKey: `cache:{${index}}:watermark`, + futureBufferMs: index, + })); +} + async function expectProtocolError(operation: Promise, message: string): Promise { let rejection: unknown; try { @@ -237,6 +244,48 @@ describe("Valkey GLIDE adapter", () => { expect(scriptInstances[4]?.getHash).toHaveBeenCalledTimes(1); }); + it("bounds native standalone batches and dispatches their chunks sequentially", async () => { + let resolveFirstChunk: ((value: unknown[]) => void) | undefined; + let resolveSecondChunk: ((value: unknown[]) => void) | undefined; + const client = fakeClient(); + client.exec + .mockImplementationOnce( + async () => await new Promise((resolve) => { + resolveFirstChunk = resolve; + }), + ) + .mockImplementationOnce( + async () => await new Promise((resolve) => { + resolveSecondChunk = resolve; + }), + ); + const adapter = createValkeyGlideDialCacheClient(client, mockGlide); + + const invalidation = adapter.invalidateMany(invalidationRequests(1_001)); + await vi.waitFor(() => { + expect(client.exec).toHaveBeenCalledTimes(1); + }); + expect(batchInstances).toHaveLength(1); + expect(batchInstances[0]?.commands).toHaveLength(1_000); + expect(() => adapter.dispose()).toThrow( + "Cannot dispose Valkey GLIDE DialCache client while operations are in flight", + ); + + resolveFirstChunk?.(Array.from({ length: 1_000 }, () => 1)); + await vi.waitFor(() => { + expect(client.exec).toHaveBeenCalledTimes(2); + }); + expect(batchInstances).toHaveLength(2); + expect(batchInstances[1]?.commands).toHaveLength(1); + expect(() => adapter.dispose()).toThrow( + "Cannot dispose Valkey GLIDE DialCache client while operations are in flight", + ); + + resolveSecondChunk?.([1]); + await expect(invalidation).resolves.toBeUndefined(); + adapter.dispose(); + }); + it.each([ ["standard Redis", new Error("NOSCRIPT No matching script. Please use EVAL.")], [ @@ -267,6 +316,23 @@ describe("Valkey GLIDE adapter", () => { ]); }); + it("retries a script miss within only the affected native chunk", async () => { + const client = fakeClient(); + client.exec + .mockResolvedValueOnce(Array.from({ length: 1_000 }, () => 1)) + .mockRejectedValueOnce(new Error("NOSCRIPT No matching script. Please use EVAL.")) + .mockResolvedValueOnce([1]); + const adapter = createValkeyGlideDialCacheClient(client, mockGlide); + + await expect(adapter.invalidateMany(invalidationRequests(1_001))).resolves.toBeUndefined(); + + expect(client.exec).toHaveBeenCalledTimes(3); + expect(batchInstances.map(({ commands }) => commands.length)).toEqual([1_000, 1, 1]); + expect(batchInstances[0]?.commands[0]?.[0]).toBe("EVALSHA"); + expect(batchInstances[1]?.commands[0]?.[0]).toBe("EVALSHA"); + expect(batchInstances[2]?.commands[0]?.[0]).toBe("EVAL"); + }); + it("preserves unrelated native batch errors without retrying", async () => { const failure = new Error("ERR invalid command arguments"); const client = fakeClient(); @@ -337,6 +403,61 @@ describe("Valkey GLIDE adapter", () => { ); }); + it("bounds legacy scalar invalidations and dispatches their windows sequentially", async () => { + let resolveFirstWindow: ((value: number) => void) | undefined; + const firstWindow = new Promise((resolve) => { + resolveFirstWindow = resolve; + }); + const client = { + invokeScript: vi.fn(async () => { + if (client.invokeScript.mock.calls.length <= 1_000) { + return await firstWindow; + } + return 1; + }), + }; + const legacyGlide = { + Decoder: { Bytes: decoderBytes }, + Script: MockScript, + }; + const adapter = createValkeyGlideDialCacheClient(client, legacyGlide); + + const invalidation = adapter.invalidateMany(invalidationRequests(1_001)); + await vi.waitFor(() => { + expect(client.invokeScript).toHaveBeenCalledTimes(1_000); + }); + expect(() => adapter.dispose()).toThrow( + "Cannot dispose Valkey GLIDE DialCache client while operations are in flight", + ); + + resolveFirstWindow?.(1); + await expect(invalidation).resolves.toBeUndefined(); + expect(client.invokeScript).toHaveBeenCalledTimes(1_001); + adapter.dispose(); + }); + + it("settles a failed scalar window without dispatching later windows", async () => { + const failure = new Error("first scalar invalidation failed"); + const client = { + invokeScript: vi.fn(async () => { + if (client.invokeScript.mock.calls.length === 1) { + throw failure; + } + return 1; + }), + }; + const legacyGlide = { + Decoder: { Bytes: decoderBytes }, + Script: MockScript, + }; + const adapter = createValkeyGlideDialCacheClient(client, legacyGlide); + + await expect(adapter.invalidateMany(invalidationRequests(1_001))).rejects.toBe(failure); + + expect(client.invokeScript).toHaveBeenCalledTimes(1_000); + adapter.dispose(); + }); + it("uses one native cluster batch for invalidations across hash slots", async () => { const client = new MockClusterClient(); client.exec.mockResolvedValueOnce([1, 1]); @@ -363,6 +484,46 @@ describe("Valkey GLIDE adapter", () => { ); }); + it("uses bounded sequential native ClusterBatch chunks", async () => { + const client = new MockClusterClient(); + client.exec.mockImplementation(async (batch) => batch.commands.map(() => 1)); + const adapter = createValkeyGlideDialCacheClient(client, mockGlide); + + await expect(adapter.invalidateMany(invalidationRequests(1_001))).resolves.toBeUndefined(); + + expect(clusterBatchInstances).toHaveLength(2); + expect(clusterBatchInstances.map(({ commands }) => commands.length)).toEqual([1_000, 1]); + expect(client.exec).toHaveBeenCalledTimes(2); + for (const batch of clusterBatchInstances) { + expect(client.exec).toHaveBeenCalledWith( + batch, + true, + { + decoder: decoderBytes, + retryStrategy: { + retryServerError: true, + retryConnectionError: true, + }, + }, + ); + } + }); + + it("validates a native chunk before dispatching the next one", async () => { + const client = fakeClient(); + client.exec.mockResolvedValueOnce(Array.from({ length: 999 }, () => 1)); + const adapter = createValkeyGlideDialCacheClient(client, mockGlide); + + await expectProtocolError( + adapter.invalidateMany(invalidationRequests(1_001)), + "Invalid DialCache Redis invalidate batch reply; expected 1000 replies", + ); + + expect(client.exec).toHaveBeenCalledTimes(1); + expect(batchInstances).toHaveLength(1); + adapter.dispose(); + }); + it("rejects malformed invalidation batch replies", async () => { const invalidationMessage = "Invalid DialCache Redis invalidate reply; expected integer 1";