feat: add cluster-aware batch invalidation - #114
Conversation
lan17
left a comment
There was a problem hiding this comment.
Review: cluster-aware batch invalidation
Reviewed at d4d7c62, verified against the pinned peers in node_modules (redis@4.7.1 / @redis/client@1.6.1, @valkey/valkey-glide@2.4.2) rather than from the diff alone.
Up front: I found no correctness bug in either adapter. Nothing crashes, nothing writes a wrong watermark, no error is swallowed. Every finding below is performance, robustness, or API consistency. The most consequential one is that the node-redis Cluster path — the PR's headline capability — is correct but doesn't actually batch.
Verified as claimed
- The EVAL-first pipeline trick is real.
@redis/client/dist/lib/multi-command.js:37 addScriptemitsEVAL <source>for a script's first use within a multi andEVALSHAthereafter, tracked per-multi viascriptsInUse. Redis executes a pipeline in order on one connection, so the leadingEVALpopulates the script cache before the trailingEVALSHAs run.SCRIPT FLUSHrecovery works with no extra round trip. - Cluster detection and routing are correct for the pinned peer.
slotsis a getter returningShard[]onRedisClusterand absent onRedisClient.MULTI(routing)threadsroutinginto#firstKey, whichexecAsPipelineuses for node selection. Peer is pinned~4.7.1, so v5 divergence is out of scope. - Per-command Redis errors are not masked.
#addMultiCommandsrejects on the firstErrorReply(client/commands-queue.js:63), so a realMOVED/NOSCRIPT/OOMpropagates, andMOVEDtriggersrediscover()plus an idempotent whole-pipeline retry inside#execute(up tomaxCommandRedirections, default 16). The reply-count and shape validation inexecuteInvalidationPipelineis defensive-only, which is fine. redisClusterSlotmatches Redis exactly: first{, first}after it, non-empty span, else whole key; CRC16/XMODEM over UTF-8 bytes;% 16384is equivalent to& 16383. The liveCLUSTER KEYSLOTparity assertions (includingfoo{}{bar}and Unicode) are the right way to prove this.RedisClusterMultiCommanddoes no client-side slot validation.#firstKeyis assigned once via??and later commands only append — noCROSSSLOTpre-check. This matters for the fix proposed below.- Failure semantics.
awaitAllsettles every launched operation before rejecting, preserves a single error's identity, and produces an order-deterministicAggregateErrorotherwise. GLIDE batch work sits insideinvokeTracked, sodispose()cannot release a live script handle. Tests pin all of this.
1. node-redis Cluster: correct, but it does not batch — and costs ~19x the bytes
Watermark keys are {namespace:keyType:id}#watermark, so the hash tag is unique per target and slots are effectively random. Grouping by exact slot yields roughly 16384 * (1 - (1 - 1/16384)^N) partitions: ~99.7 for 100 targets, ~970 for 1,000. So invalidateMany issues ~N separate multi().execAsPipeline() calls of one command each.
Worse, each partition's single command is that pipeline's first script use, so it goes out as EVAL <full source>. INVALIDATE_CACHE_SCRIPT is ~1.7 KB; the scalar path sends EVALSHA (~90 B). Compared against Promise.all(targets.map(invalidateRemote)):
- round trips: no reduction —
#tick()corks the socket and drains the entire pending queue in one write, so concurrently issued scalar commands are already coalesced - bytes on the wire: ~19x more
- behavior: identical
That socket-level coalescing is also why the standalone benchmark only shows 1.01x / 1.07x / 1.36x. The pipeline's real gain there is per-command bookkeeping, not round trips.
Fix: partition by owning primary, not by slot
The key insight is that CROSSSLOT is a per-command constraint, not a per-batch one. A pipeline is not a transaction; it is N independent commands sharing one socket write. Redis only rejects CROSSSLOT when a single command spans slots. Every command here is one EVAL with numkeys=1, so a partition only needs each key to live on the same primary, not in the same slot — and a primary owns ~5,461 slots in a 3-primary cluster.
Type the slot map instead of unknown:
interface NodeRedisClusterShard {
readonly master: { readonly id: string };
}
interface NodeRedisScriptClient {
// ...existing script members...
multi(routing?: NodeRedisArgument): NodeRedisMultiCommand;
readonly slots?: readonly NodeRedisClusterShard[];
}RedisClusterType.slots is Shard[] whose master carries id: string, so it stays structurally assignable; standalone RedisClientType has no slots and the property is optional. This replaces isNodeRedisClusterClient — detection and lookup become the same read.
Partition by primary, with a pipeline size cap:
type NodeRedisInvalidationRequest = {
readonly watermarkKey: string;
readonly futureBufferMs: number;
};
// A pipeline is not a transaction, so a partition only needs every key to live on
// one primary rather than in one slot. Cap commands per pipeline so a large batch
// cannot monopolize a connection or overflow commandsQueueMaxLength.
const MAX_PIPELINE_COMMANDS = 1_000;
function invalidationPartitions(
client: NodeRedisScriptClient,
requests: readonly NodeRedisInvalidationRequest[],
): NodeRedisInvalidationRequest[][] {
// Read the slot map once: rediscovery replaces the array wholesale
// (cluster-slots.js:171 `this.slots = new Array(SLOTS)`), so one snapshot keeps
// the whole grouping consistent with a single topology view.
const shards = Array.isArray(client.slots) ? client.slots : undefined;
if (shards === undefined) {
return [[...requests]];
}
const byPrimary = new Map<string, NodeRedisInvalidationRequest[]>();
for (const request of requests) {
const slot = redisClusterSlot(request.watermarkKey);
// An unmapped slot degrades to its own key-routed partition rather than
// guessing an owner or failing the batch.
const primary = shards[slot]?.master.id ?? `slot:${slot}`;
const partition = byPrimary.get(primary);
if (partition === undefined) {
byPrimary.set(primary, [request]);
} else {
partition.push(request);
}
}
return [...byPrimary.values()];
}
function* chunked<T>(items: readonly T[], size: number): Generator<readonly T[]> {
for (let index = 0; index < items.length; index += size) {
yield items.slice(index, index + size);
}
}Dispatch (executeInvalidationPipeline stays exactly as written):
async invalidateMany(requests) {
if (requests.length === 0) {
return;
}
await awaitAll(
invalidationPartitions(client, requests).map(async (partition) => {
// Concurrent across primaries, sequential within one, so a single
// connection never queues an unbounded number of commands.
for (const chunk of chunked(partition, MAX_PIPELINE_COMMANDS)) {
await executeInvalidationPipeline(client, chunk);
}
}),
"Multiple DialCache invalidation partitions failed",
);
},Why each edge case holds:
- Routing.
executeInvalidationPipelinealready passesclient.multi(first.watermarkKey), so node-redis resolves the target viaslots.getClient(firstKey)— the same primary the partition was grouped for. SinceRedisClusterMultiCommandperforms no slot validation, the mixed-slot pipeline is accepted as-is. - Stale topology. A wrong snapshot means the affected command replies
MOVED, which rejects the pipeline'sPromise.all, which#executecatches →rediscover()→ whole-pipeline retry. Re-running a monotonic watermark advance is a no-op, so the retry is free. - Unassigned slots. Mid-resharding or pre-connect,
shards[slot]isundefinedand that request becomes its own key-routed partition — today's behavior, but only for the keys that need it. - Standalone. No
slots→ one partition → chunked pipelines. Same as today, plus the size cap. - Script payload. Each
multi()gets a freshscriptsInUse, so every chunk pays oneEVALand the rest areEVALSHA. At 1,000 commands per chunk that is ~0.2% overhead, versus 100% today on Cluster.
Net for 1,000 targets on 3 primaries: 3 pipelines instead of ~970.
Tests this needs
fakeBatchClient({ cluster: true }) currently sets slots: [], so every lookup misses and only the fallback path runs — the existing exact-slot test would still pass against the new code without proving anything. Give the fake a real map:
function fakeClusterSlots(assignments: ReadonlyMap<number, string>): unknown[] {
const slots = new Array(16_384);
for (const [slot, id] of assignments) {
slots[slot] = { master: { id } };
}
return slots;
}Then add:
- Groups by primary, not slot — three keys in three distinct slots, two of which map to
primary-aand one toprimary-b; assertclient.multiis called twice and check partition contents. This is the test whose absence let the current behavior through. - Unmapped slot isolates — a key whose slot has no assignment gets its own pipeline while mapped keys still group.
- Chunking is sequential per primary —
MAX_PIPELINE_COMMANDS + 1requests on one primary produce two pipelines; gate the firstexecAsPipelineon a deferred to assert the secondmulti()is not created until the first resolves. - Integration — reuse
selectCrossPrimaryBatchIds, spy oncluster.multi, and assert the call count is<= masters.lengthwhile every watermark still advances and tracked values refresh. That upgrades the existing live test from "it works" to "it batches."
Deletion
groupByRedisClusterSlot becomes unused — delete it from src/internal/redis-cluster-slot.ts and keep only redisClusterSlot. Move its test case ("groups different hash tags that collide on the same exact slot") into test/node-redis.test.ts as the by-primary grouping test; the CRC16 and hash-tag parity cases stay put.
That split also gets the layering right: CRC16 is client-agnostic and belongs in internal/, while slots[].master.id is node-redis-specific and belongs in the adapter.
2. GLIDE inlines the entire Lua script into every batch command
src/valkey-glide.ts:202-208 builds customCommand(["EVAL", INVALIDATE_CACHE_SCRIPT, "1", key, buffer]) per target — ~1.7 KB x N, so a 1,000-target batch ships ~1.7 MB of Lua text versus ~90 KB via EVALSHA.
BaseBatch genuinely has no script method (no invokeScript), so customCommand is forced. But GLIDE's native Script exposes getHash() (build-ts/native.d.ts:114). Emit EVALSHA <hash> and, if exec rejects with a NOSCRIPT error, retry the whole batch with EVAL. Retry is safe by the same monotonicity argument already documented. That needs an optional getHash?(): string on ValkeyGlideScriptHandle with an EVAL fallback when absent — consistent with how Batch/exec are already treated as optional capabilities.
Worth noting the GLIDE path is otherwise the right design: because each EVAL declares numkeys=1, glide-core routes per key, groups by node, pipelines per node, and reassembles replies in queue order. It does for free exactly what finding 1 asks the node-redis adapter to do by hand. Not passing route in the options is correct — route would pin the whole batch to one node and defeat that.
3. The GLIDE batch path is less resilient than the scalar path it replaces
Batch retries are opt-in via ClusterBatchRetryStrategy and default off, so invalidateMany currently gets no TRYAGAIN and no connection-error retry, where single invokeScript calls go through the core's normal redirect handling.
DialCache is the rare workload where GLIDE's documented cautions do not apply: reordering across slots is meaningless because each watermark key is independent, and duplicate execution is harmless because the script only advances a watermark monotonically. Passing retryStrategy: { retryServerError: true, retryConnectionError: true } buys transparent recovery from slot-migration TRYAGAIN and connection blips for free.
4. NodeRedisScriptClient.multi is required while GLIDE's exec is optional
multi was added as a required member of the parameter type for createNodeRedisDialCacheClient. Real RedisClientType/RedisClusterType both have it, so production callers are fine — but any hand-rolled narrow structural object or test double now fails to compile. The GLIDE side deliberately avoided this by making exec? optional with scalar fallback, and the README advertises that compatibility for GLIDE. The asymmetry is unexplained; make it multi? and fall back to the scalar invalidate loop when absent (awaitAll is already there).
scripts/test-package.mjs never constructs a narrow node-redis client, so nothing catches this — worth adding beside the existing scalar-only DialCacheRedisClient consumer check.
5. GLIDE cluster detection misses wrappers that forward exec
client instanceof GlideClusterClient (src/valkey-glide.ts:197) is false for a structural wrapper around a cluster client, so such a wrapper receives StandaloneBatch. Harmless in 2.4.2 — GlideClusterClient.exec reads only batch.commands, batch.isAtomic, and batch.setCommandsIndexes, all from BaseBatch — but that is a coincidence inside a ^2.4.2 range, and the types keep the classes separate precisely because ClusterBatch carries cluster routing options. One README sentence for wrapper authors would cover it.
6. The Cluster integration fixture is mutated suite-wide
configureAdvertisedClusterEndpoint sets cluster-announce-hostname, cluster-preferred-endpoint-type, and cluster-announce-port on every container in beforeAll, so every pre-existing node-redis Cluster test in the file now runs against hostname-advertised nodes with host-mapped ports in MOVED replies. Green today, but if it regresses it will look like an unrelated flake. A separate fixture would isolate the blast radius; at minimum extend the existing comment to say the change is suite-wide.
7. Benchmark methodology
One iteration per size, always scalar-then-batch on the same connection, no interleaving or repetition — so the batch leg always runs on warmer client state. The script is explicit that results are directional and asserts no threshold, which covers it, but 1.01x and 1.07x are inside plausible noise; a couple of alternating repetitions reporting a median would make the numbers mean something. It also only covers standalone node-redis, the one topology where the design is uncontroversial. A Cluster leg would have surfaced finding 1.
8. Minor
awaitAllbuilds and returnsvalues: T[], and all four call sites discard it. Dead accumulation on a path that handles 1,000-element batches — make itPromise<void>.- Three layered empty-batch guards (
invalidateRemoteMany,RedisCache.invalidateMany, each adapter). Keep the adapter guards (direct adapter use is legitimate) and drop the now-unreachable one inRedisCache. RedisCache.invalidateManyduplicatesinvalidate's body instead of the scalar delegating to it; they must now stay in sync as the request shape evolves.redisClusterSlotreimplementscluster-key-slot@1.1.2, which node-redis itself uses for routing (cluster-slots.js:22). Hand-rolling correctly keepssrc/internal/dependency-free since core and GLIDE share that directory, and the live parity test covers drift — noting it so the choice reads as deliberate.- Docs say each unique target "records one invalidation metric." It records one per attempt — emitted before dispatch, retained on failure, and (per the test at
test/dialcache-invalidation.test.ts) emitted even for targets whose key derivation later throws. Consistent with scalarinvalidateRemote, so no change needed, but "attempted invalidation targets" is the accurate counter description. ValkeyGlideClusterClientConstructortyped as{ [Symbol.hasInstance](value: unknown): boolean }is the right workaround for a class with no public constructor, but the doc comment does not say that. One line saves the next reader a detour.
Test coverage
Strong, and better than the feature strictly needed: canonical dedup across 1/"1"/1n, empty-batch no-op, pre-dispatch validation, single-error identity vs. AggregateError, settle-before-reject for both the scalar fallback and Cluster partitions, malformed/short/null batch replies for both adapters, dispose-during-batch, and live three-primary Cluster coverage for node-redis and GLIDE including all-node SCRIPT FLUSH recovery and CLUSTER KEYSLOT parity. expect(executeBatch).toHaveBeenCalledOnce() is the right way to prove GLIDE took the native path rather than silently falling back.
The gap is partition-count assertions — nothing checks how many pipelines a Cluster batch produces, which is exactly why finding 1 slipped through.
Security
Nothing new. No key-format, Lua-protocol, or metric-label changes. Error metrics stay bounded by distinct key type rather than id, so cardinality is safe under large batches. Adding EVALSHA per finding 2 would not change that.
Verdict
Sound design, unusually good docs, and the hard parts — script-cache recovery, slot math, settle-before-reject — are right. Nothing blocks on correctness. What blocks on value is finding 1: the PR is titled "cluster-aware batch invalidation," and on node-redis Cluster it currently produces roughly one pipeline per target with a full script body each time, which is strictly more expensive than the loop it replaces.
Findings 1 and 2 before merge; 3 and 4 are cheap enough to take in the same pass; the rest can follow.
Implementation plan after reviewWe aligned on the key requirement: node-redis Cluster batching should produce approximately M pipelines for M targeted primaries, not one pipeline per unique hash slot. DialCache will calculate slots and form the groups inside the adapter, while node-redis will continue choosing and routing to the actual node from each pipeline's first key. 1. Rework node-redis Cluster partitioning
2. Handle topology changes explicitlyWe should not rely on the review's claim that node-redis can always repair a mixed-slot pipeline through its native whole-pipeline
3. Preserve node-redis structural compatibility
4. Reduce GLIDE batch script payloads
5. Expand tests around the actual batching guaranteenode-redis unit tests
live Redis Cluster integration
GLIDE tests
6. Documentation, benchmark, and small consistency fixes
7. Explicitly out of scope for this pass
8. Validation and handoffRun on Node.js 22.22.0:
Then push the validated update, refresh the PR body/issue wording and benchmark numbers, and summarize which review findings were implemented versus deliberately deferred. |
d4d7c62 to
7ccccc4
Compare
7ccccc4 to
b9d5d81
Compare
Review follow-up implementedImplemented the agreed plan at What changed
Deliberately deferred
Validation
The PR body and #46 now describe primary-owner grouping and conservative topology recovery instead of exact-slot partitioning. |
lan17
left a comment
There was a problem hiding this comment.
Re-review after perf: optimize batch invalidation adapters
Reviewed at b9d5d81 (rebased onto 34bd022 / 0.14.0). The delta since my last review is one commit, +639/−153 across 12 files. CI green. Verified against the pinned peers in node_modules (@redis/client@1.6.1, @valkey/valkey-glide@2.4.2) rather than from the diff alone.
What landed
Finding 1 (node-redis Cluster) — fixed, and better than what I proposed. partitionClusterInvalidations groups by slots[slot]?.master?.id, preserves first-encounter partition order, and isolates unmapped slots. The integration test now asserts client.multi is called exactly targetedPrimaryOwners.size times — precisely the assertion whose absence hid the original behavior.
The part I did not suggest and should have: the isRedisClusterRedirection → executeScalarInvalidations recovery. That closes a hole in my own recommendation. node-redis's redirect loop re-resolves only #firstKey (cluster/index.js:216), so if a non-first key in a by-owner partition has moved, retrying re-sends the same pipeline to the same node and receives the same MOVED — 16 times — then throws. Grouping by owner is what creates that possibility. The scalar fallback converts a guaranteed failure into success, and it is safe because registered scalar calls route each key independently and watermark advancement is idempotent. Good catch.
Finding 2 (GLIDE script payload) — fixed. EVALSHA via scripts.invalidate.getHash?.(), one EVAL retry on a script-cache miss, and direct EVAL when the runtime's handle exposes no getHash. The integration test is the right shape: two exec calls for the first batch after scriptFlush (miss, then retry), then one for the warm batch.
Finding 3 (GLIDE retry strategy) — fixed, with a detail I would have gotten wrong. Both flags on for cluster, and correctly not for standalone: BatchOptions carries no retryStrategy because GLIDE scopes retries to ClusterBatchOptions. The README states exactly that.
Findings 4, 5, 7, 8 — fixed. multi? optional with scalar fallback plus a narrow node-redis client in test-package.mjs; the instanceof wrapper limitation documented as explicitly unsupported, with a code comment; benchmark now reports medians over five alternating repetitions with DIALCACHE_BENCH_REPETITIONS; awaitAll returns void; groupByRedisClusterSlot deleted along with its test; Symbol.hasInstance comment added; metric docs and Prometheus help text say "attempted unique."
Finding 6 — the suite-wide fixture mutation is now documented rather than isolated. Reasonable call, and the comment says so plainly.
Still open
1. No batch size cap — now the top item
This was minor when Cluster partitions held ~1 command each. By-owner grouping changes that: 100k targets across 3 primaries becomes three ~33k-command pipelines. That trips commandsQueueMaxLength where configured, and otherwise blocks a primary for the duration. It also bounds the new recovery path's worst case — a 33k-command partition that hits MOVED currently degrades to 33k scalar calls, each with its own 16-redirect budget.
// A pipeline is not a transaction, so partition size is a resource decision, not
// a correctness one. Cap it so one batch cannot monopolize a connection.
const MAX_PIPELINE_COMMANDS = 1_000;
function* chunked<T>(items: readonly T[], size: number): Generator<readonly T[]> {
for (let index = 0; index < items.length; index += size) {
yield items.slice(index, index + size);
}
}await awaitAll(
partitions.map(async (partition) => {
// Concurrent across primaries, sequential within one.
for (const chunk of chunked(partition, MAX_PIPELINE_COMMANDS)) {
await executeInvalidationPipeline(client, chunk);
}
}),
"Multiple DialCache invalidation partitions failed",
);At 1,000 commands per chunk the extra EVAL per chunk is ~0.2% overhead. This also covers standalone, which is currently one unbounded pipeline.
2. The MultiErrorReply branch in isRedisClusterRedirection is unreachable
execAsPipeline routes through #addMultiCommands, whose Promise.all rejects with the first raw ErrorReply (client/commands-queue.js:63) before transformReplies can construct a MultiErrorReply. So with node-redis 4.7.1 only isDirectRedisClusterRedirection can fire from a pipeline; the replies/errorIndexes path is reachable only for exec() transactions, which this code never issues.
The two new tests build synthetic Object.assign(new Error(...), { replies, errorIndexes }) objects, so they pass without exercising anything reachable. Either drop the branch or add a comment marking it forward-defensive — given the project's no-dead-API stance, I would drop it.
3. The recovery burns the full redirect budget first
Because #execute retries MOVED/ASK up to maxCommandRedirections (default 16) before rethrowing, the scalar fallback only engages after 16 pipeline round trips for that partition. Correct, but operators sizing timeouts through a reshard will care. Worth one clause in the README paragraph that already describes the fallback.
4. Unmapped slots would be better served by the scalar path
An isolated one-command pipeline sends EVAL with the full ~1.7 KB source, whereas the registered scalar call sends EVALSHA (~90 B) with its own NOSCRIPT recovery. Since executeScalarInvalidations already exists, routing unmapped requests there is both smaller and cheaper than building a pipeline for them.
Low priority: I checked whether a rediscovery window could make this common, and it cannot. #resetSlots() and the repopulation loop run synchronously inside #discover with no await between them, so there is no observable empty-map window. This only affects genuinely unassigned slots.
5. Leftovers from the previous review
- Three layered empty-batch guards (
invalidateRemoteMany,RedisCache.invalidateMany, each adapter); theRedisCacheone is unreachable from the core path. RedisCache.invalidateManystill duplicatesinvalidate's body rather than delegating to it, so the two must stay in sync as the request shape evolves.
6. isScriptCacheMiss is the one message-format-gated behavior in either adapter
The two-pattern match (raw NOSCRIPT plus GLIDE's NoScriptError: No matching script) is the right hedge, a false positive costs only a wasted EVAL retry, and the integration test pins the real GLIDE string — so this is fine as built. Noting it only so the coupling is known if GLIDE reformats errors.
Assessment
The two findings that mattered are properly fixed rather than papered over, and the tests now assert the mechanism — pipeline count per primary, cold-versus-warm exec count — instead of just the outcome. The redirection recovery is a genuine improvement over what I suggested.
Item 1 is the only thing I would still want before merge. Items 2 through 6 are cleanup.
|
Addressed the re-review in 5e09ac1.
The deterministic unit coverage now includes 1,001-command standalone and Cluster batches, concurrent owners with sequential owner chunks, stop-on-chunk-failure, and 1,001 unmapped scalar requests proving the second window is not dispatched early. Validation on Node.js 22.22.0:
|
lan17
left a comment
There was a problem hiding this comment.
Re-review after 5e09ac1
Read the implementation plan, the follow-up summary, and the commit. CI green. The PR body and issue wording are fully updated — no stale exact-slot or 1.36x claims left anywhere.
Everything from the last review landed
The 1,000-command ceiling is applied across all five dispatch paths (standalone chunks, Cluster owner chunks, unmapped-scalar windows, no-multi fallback, and redirection recovery), the MultiErrorReply branch and its synthetic tests are gone, the 16-retry recovery latency is documented, unmapped slots route through registered scalar scripts, redisInvalidationRequest is centralized, and the GLIDE matcher has its coupling comment.
Two pushbacks were correct
- Declining the
RedisCache.invalidate→invalidateManydelegation. Right call for the right reason. Delegating would route a single invalidation through the batch path — pipeline-embeddedEVALinstead of the registered script'sEVALSHA, plus a different error surface. That is a behavior change dressed up as deduplication. Centralizing only the request construction was the correct read of what was actually duplicated. - The original objection to the cap. "It cannot guarantee compatibility with a caller's lower or already-occupied
commandsQueueMaxLength" is true. Rather than dropping the objection when implementing the cap, the README now documents the limitation instead of overclaiming. That is the honest version.
The unmapped-slot handling also came out better than what I proposed: one scalar partition collecting every unmapped request, rather than my per-request isolation.
The new benchmark numbers change the story
| targets | before (1 sample, batch always second) | after (median of 5, alternating) |
|---|---|---|
| 10 | 1.01x | 0.96x |
| 100 | 1.07x | 1.07x |
| 1,000 | 1.36x | 1.00x |
The 1.36x was measurement artifact — batch always ran second on a warm connection. With alternating order it disappears. That is exactly what the mechanism predicts: #tick() corks the socket and drains the entire pending command queue in one write, so Promise.all(targets.map(invalidateRemote)) is already a pipeline. The standalone batch path's only remaining edge is fewer per-command bookkeeping objects, and that measures as zero.
So: does standalone node-redis batching earn its keep? It is now a chunk loop, a reply-count check, and a protocol error class for 1.00x. Routing non-cluster clients through executeScalarInvalidations instead would delete a path, delete the standalone chunking semantics from the README, and measure identically.
I lean toward keeping it, but for a reason worth stating explicitly rather than for throughput: it is the only path that validates reply count, and it keeps one code shape across topologies. If it stays, the docs should not imply a standalone throughput win — "optimized for fewer client round trips" currently reads as one when the measurement says otherwise. The real value of this feature is the Cluster path (M pipelines for M targeted primaries) and GLIDE, where glide-core genuinely collapses per-node round trips.
Two gaps the cap work exposed
1. The ceiling is node-redis-only
executeBatch in the GLIDE adapter still loops over every request with no chunking, so a 100k-target GLIDE batch is one exec carrying 100k customCommands — one very large protobuf request, and per-node pipelines of roughly 33k on a three-primary cluster. The README's "bounds DialCache's queued contribution per execution chunk" describes node-redis only.
This is defensible: GLIDE has no commandsQueueMaxLength analog and buffers in its Rust core. But "bounded batch work" is now an adapter-specific property while the docs read as a global one. Either bound the GLIDE batch too, or scope the claim.
2. There is no GLIDE benchmark at all
Every number in the PR is node-redis standalone — the one topology where batching demonstrably does nothing. The GLIDE path is where the mechanism should actually pay off: one exec fans out to per-node pipelines, and GLIDE's higher per-call protobuf overhead makes amortization more valuable than it is for node-redis. It is also the only path with zero measurement.
Given that the standalone numbers just deflated under better methodology, the same rigor should be pointed at the claim that is actually load-bearing.
The trade the cap introduces, stated plainly
I pushed for the cap, so I should name its cost: 10,000 standalone targets is now 10 sequential round trips where it used to be one socket write, while Promise.all(scalar) stays at roughly one. Above ~1,000 targets the batch path is slower than the loop it replaces on standalone.
I still think the cap is right — it converts an unbounded memory and queue-occupancy risk into graceful degradation, and realistic batch sizes are tens to low hundreds where chunking never triggers. But it does mean the crossover where standalone batching wins is narrow to nonexistent, which is the same conclusion the benchmark reaches independently.
Test quality
The new coverage is the right kind. The 1,001-command boundary cases use gates plus vi.waitFor to prove sequencing — the second chunk is not dispatched until the first resolves — rather than just asserting call counts. The Cluster test proves owner concurrency and per-owner sequencing simultaneously, and stop-on-chunk-failure is pinned.
I do not see a remaining hole: redirection recovery operates on a chunk of at most 1,000 by construction, so it is inherently a single scalar window.
Verdict
Mergeable as-is; nothing outstanding is a correctness issue.
The two things I would still want are a GLIDE leg in the benchmark and a decision on whether the standalone pipeline path stays, with the README adjusted either way. Both are about not carrying a claim the measurements do not support.
|
Addressed the re-review in c8869e4.
Live standalone Redis 7 medians, shown as scalar / batch and scalar-to-batch ratio:
The numbers support the revised wording: warm standalone behavior is near parity and adapter-dependent; the feature does not claim a universal throughput improvement. Additional cleanup corrected the PR/issue wording that had accidentally described core's custom-adapter scalar fallback as bounded. Core keeps its existing all-settled concurrent fallback; the private 1,000 ceiling belongs to the bundled node-redis and GLIDE adapters. Validation on Node.js 22.22.0:
|
Closes #46
Summary
Add a class-level batch form of remote invalidation while preserving the scalar API and keeping Redis topology inside the bundled adapters.
RemoteInvalidationTargetandDialCache.invalidateRemoteMany(targets, futureBufferMs?)DialCacheRedisClient.invalidateManywith an all-settled scalar compatibility fallbackArchitecture
flowchart LR A["invalidateRemoteMany(targets, buffer)"] --> B["validate buffer; canonicalize String(id); deduplicate"] B --> C["derive every watermark request before dispatch"] C --> D{"client.invalidateMany?"} D -- "no" --> E["all-settled concurrent scalar calls"] D -- "node-redis standalone" --> F["sequential pipelines, max 1,000 commands"] D -- "node-redis Cluster" --> G["calculate slot; group by current master.id"] G --> H["owner groups concurrent; chunks per owner sequential"] G --> I["unmapped slots: bounded registered scalar windows"] H -- "final MOVED or ASK" --> J["scalar recovery for affected chunk"] D -- "native GLIDE" --> K["sequential Batch or ClusterBatch chunks, max 1,000 commands"] D -- "scalar GLIDE wrapper" --> M["sequential scalar windows, max 1,000 calls"] K -- "confirmed script miss" --> L["retry affected idempotent chunk once with EVAL"]Each Lua invalidation remains independently atomic and monotonic. The public call is one semantic adapter-dispatch boundary, not a cross-key transaction or a throughput guarantee.
Public and core behavior
String(id). Duplicate(keyType, canonicalId)targets collapse in first-occurrence order.AggregateError.invalidateRemoteand the watermark/key protocol are unchanged. Invalidation remains remote-only.Adapter behavior
node-redis
ceil(targets / 1,000)sequential non-transactional pipelines.slots[slot].master.id.sum(ceil(ownerTargets / 1,000)); it is one pipeline per targeted primary when every owner has at most 1,000 targets.EVALpipelines.MOVEDorASK, that chunk is retried through registered scalar invalidations so every key routes independently. node-redis first exhausts its configuredmaxCommandRedirectionsbudget, 16 retries by default.multiremains optional on the structural input. Narrow pre-batch wrappers use the same bounded scalar path.commandsQueueMaxLength.EVALper pipeline instead of independent full-source recovery afterSCRIPT FLUSH.Valkey GLIDE
Batch(false)/ClusterBatch(false)dispatch and scalar-only compatibility wrappers.invokeScriptcalls and settle every launched call in a window.Script.getHash()is available, commands useEVALSHA. A confirmed RedisNOSCRIPTor GLIDENoScriptErrorretries only the affected monotonic chunk once withEVAL; unrelated failures retain their identity.getHash()retain one-passEVALcompatibility.client instanceof glide.GlideClusterClientfrom the supplied module namespace. Forwarding wrappers must preserve that identity, or expose only scalar scripting and use the compatibility path.Error.message; the matcher intentionally recognizes the observed GLIDE form and Redis's standard token, then fails closed.Compatibility and failure model
invalidateManyis optional, so existing customDialCacheRedisClientimplementations keep compiling.invalidate; shared internal request construction removes duplication without silently routing scalar calls through an adapter's batch method.Review follow-up
MOVED/ASKrecovery is explicit rather than relying on whole-pipeline first-key retriesMultiErrorReplypath and tests were removed for pinned node-redis 4.7.1EVALSHA, cold-cache recovery, and Cluster retry flagsawaitAll(): Promise<void>, andSymbol.hasInstancerationale are coveredValidation
Validated at
c8869e4on Node.js 22.22.0, rebased ontomain@34bd022:corepack pnpm checkcorepack pnpm test:integrationCROSSSLOTEVALSHA/EVALpath and one on the next warm batchgit diff --checkpassed