Skip to content

Commit d1ccf7e

Browse files
nkaradzhovclaude
andcommitted
feat(client): wire multi_shard splitter into cluster dispatch
Make the multi_shard request policy actually split commands per hash slot instead of sending the full command to one client per key. - Routers now return a plan (RoutedCommand[]) rather than bare clients: pass-through policies set `client`; routeMultiShard calls the splitter and builds a sub-parser per slot (buildSubParser marks keys at the splitter's new keyPositions so `firstKey` routes each sub-command). - The engine runs each plan entry through core `_execute` with its own sub-parser, so every shard gets its own sub-args; MOVED/ASK use the sub-command's key. - Response reducers gain optional positionHints; reduceDefaultKeyed scatters split replies by groupIndices so MGET preserves caller key order across slots. Aggregating reducers (agg_sum, all_succeeded) ignore the hint. - Splitter SubCommand now carries keyPositions for sub-parser key marking. Also merge _executePolicyPlan into _executeWithPolicies — the old name implied a plan object that never existed; it is now the single engine (resolve policy -> plan -> execute -> reduce). Router/reducer types are intentionally non-generic (routing sits below the typed command surface; clients are opaque pass-through), erased to the base cluster types instead of `any`; the engine re-narrows the client at the `_execute` boundary. Working end-to-end: DEL/UNLINK/EXISTS/TOUCH, MSET/MSETEX, MGET. Raw sendCommand multi_shard and cluster-docker integration tests remain. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d83ec08 commit d1ccf7e

5 files changed

Lines changed: 251 additions & 112 deletions

File tree

packages/client/lib/cluster/index.ts

Lines changed: 33 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { RedisClientOptions, RedisClientType, WithFunctions, WithModules, WithScripts } from '../client';
22
import { CommandOptions } from '../client/commands-queue';
3-
import { Command, CommandArguments, CommanderConfig, CommandSignature, TypeMapping, RedisArgument, RedisFunction, RedisFunctions, RedisModules, RedisScript, RedisScripts, ReplyUnion, RespVersions, TransformReply } from '../RESP/types';
3+
import { Command, CommandArguments, CommanderConfig, CommandSignature, TypeMapping, RedisArgument, RedisFunction, RedisFunctions, RedisModules, RedisScript, RedisScripts, ReplyUnion, RespVersions } from '../RESP/types';
44
import { NON_STICKY_COMMANDS } from '../commands';
55
import { EventEmitter } from 'node:events';
66
import { attachConfig, functionArgumentsPrefix, getTransformReply, scriptArgumentsPrefix } from '../commander';
@@ -166,14 +166,14 @@ export default class RedisCluster<
166166
const transformReply = getTransformReply(command, resp);
167167

168168
return async function (this: ProxyCluster, ...args: Array<unknown>) {
169-
const parser = new BasicCommandParser();
169+
const parser = new BasicCommandParser();
170170
command.parseCommand(parser, ...args);
171171

172172
return this._self._executeWithPolicies(
173173
parser,
174+
command.IS_READ_ONLY,
174175
this._commandOptions,
175-
command,
176-
transformReply
176+
p => (client, opts) => client._executeCommand(command, p, opts, transformReply)
177177
);
178178
};
179179
}
@@ -187,9 +187,9 @@ export default class RedisCluster<
187187

188188
return this._self._executeWithPolicies(
189189
parser,
190+
command.IS_READ_ONLY,
190191
this._self._commandOptions,
191-
command,
192-
transformReply
192+
p => (client, opts) => client._executeCommand(command, p, opts, transformReply)
193193
);
194194
};
195195
}
@@ -205,9 +205,9 @@ export default class RedisCluster<
205205

206206
return this._self._executeWithPolicies(
207207
parser,
208+
fn.IS_READ_ONLY,
208209
this._self._commandOptions,
209-
fn,
210-
transformReply
210+
p => (client, opts) => client._executeCommand(fn, p, opts, transformReply)
211211
);
212212
};
213213
}
@@ -221,11 +221,11 @@ export default class RedisCluster<
221221
parser.push(...prefix);
222222
script.parseCommand(parser, ...args);
223223

224-
return this._self._executeScriptWithPolicies(
224+
return this._self._executeWithPolicies(
225225
parser,
226+
script.IS_READ_ONLY,
226227
this._commandOptions,
227-
script,
228-
transformReply
228+
p => (client, opts) => client._executeScript(script, p, opts, transformReply)
229229
);
230230
};
231231
}
@@ -451,37 +451,10 @@ export default class RedisCluster<
451451
* Resolves the command's policies and executes it accordingly: the request
452452
* policy picks the target clients, each target runs through the core
453453
* `_execute` transport primitive, and the response policy aggregates the
454-
* replies. Call sites pass data — fn closures are created here.
454+
* replies. Call sites pass a `makeFn` factory that builds the per-client
455+
* execution closure (command, script, or raw `sendCommand`).
455456
*/
456457
async _executeWithPolicies<T>(
457-
parser: CommandParser,
458-
options: ClusterCommandOptions | undefined,
459-
command: Command | RedisFunction,
460-
transformReply: TransformReply | undefined
461-
): Promise<T> {
462-
return this._executePolicyPlan(
463-
parser,
464-
command.IS_READ_ONLY,
465-
options,
466-
p => (client, opts) => client._executeCommand(command, p, opts as CommandOptions<TYPE_MAPPING> | undefined, transformReply)
467-
);
468-
}
469-
470-
async _executeScriptWithPolicies<T>(
471-
parser: CommandParser,
472-
options: ClusterCommandOptions | undefined,
473-
script: RedisScript,
474-
transformReply: TransformReply | undefined
475-
): Promise<T> {
476-
return this._executePolicyPlan(
477-
parser,
478-
script.IS_READ_ONLY,
479-
options,
480-
p => (client, opts) => client._executeScript(script, p, opts as CommandOptions<TYPE_MAPPING> | undefined, transformReply)
481-
);
482-
}
483-
484-
async _executePolicyPlan<T>(
485458
parser: CommandParser,
486459
isReadonly: boolean | undefined,
487460
options: ClusterCommandOptions | undefined,
@@ -503,29 +476,39 @@ export default class RedisCluster<
503476
if (!router) {
504477
throw new Error(`Unknown request policy ${requestPolicy}`);
505478
}
506-
const clients: Array<RedisClientType<M, F, S, RESP, TYPE_MAPPING>> =
507-
await router(this._slots, parser, isReadonly);
479+
// Routers are typed against the erased base cluster types (routing is
480+
// below the typed command surface); bridge this instantiation's slots in.
481+
const plan = await router(
482+
this._slots as unknown as Parameters<typeof router>[0],
483+
parser,
484+
isReadonly,
485+
policyResult.value.keySpecs
486+
);
508487

509-
if (clients.length === 0) {
488+
if (plan.length === 0) {
510489
throw new Error(`Request policy ${requestPolicy} produced no target nodes`);
511490
}
512491

513-
const responsePromises = clients.map(
514-
client => this._execute(parser, isReadonly, options, makeFn(parser), client)
515-
);
492+
const responsePromises = plan.map(entry => {
493+
const entryParser = entry.parser ?? parser;
494+
// Re-narrow the opaque routed client to this cluster's instantiation.
495+
const client = entry.client as RedisClientType<M, F, S, RESP, TYPE_MAPPING> | undefined;
496+
return this._execute(entryParser, isReadonly, options, makeFn(entryParser), client);
497+
});
516498

517499
const reducer = RESPONSE_REDUCERS[responsePolicy];
518500
if (!reducer) {
519501
throw new Error(`Unknown response policy ${responsePolicy}`);
520502
}
521-
return reducer(responsePromises, parser) as Promise<T>;
503+
const positionHints = plan.map(entry => entry.groupIndices);
504+
return reducer(responsePromises, parser, positionHints) as Promise<T>;
522505
}
523506

524507
/**
525508
* Core transport primitive: sends one command to one client — resolved by
526509
* the parser's first key unless `pinnedClient` is given — with MOVED/ASK
527510
* redirect handling. Policy-free; fan-out and aggregation live in
528-
* `_executePolicyPlan`.
511+
* `_executeWithPolicies`.
529512
*/
530513
async _execute<T>(
531514
parser: CommandParser,
@@ -621,12 +604,12 @@ export default class RedisCluster<
621604
}
622605

623606
const parser = new BasicCommandParser();
624-
firstKey && parser.push(firstKey)
607+
if (firstKey) parser.push(firstKey);
625608
args.forEach(arg => parser.push(arg));
626609

627610
// Raw path: no command object, so readonly-ness stays an explicit caller
628611
// argument and the reply is returned untransformed.
629-
return this._self._executePolicyPlan(
612+
return this._self._executeWithPolicies(
630613
parser,
631614
isReadonly,
632615
opts,
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { strict as assert } from 'node:assert';
2+
import type { CommandParser } from '../../client/parser';
3+
import { reduceDefaultKeyed } from './dispatch';
4+
5+
// The reducer ignores the parser; a stub keeps the calls readable.
6+
const PARSER = {} as CommandParser;
7+
8+
describe('reduceDefaultKeyed', () => {
9+
it('passes the sole reply through when not split (no hints)', async () => {
10+
const reply = await reduceDefaultKeyed([Promise.resolve(['v1', 'v2'])], PARSER);
11+
assert.deepEqual(reply, ['v1', 'v2']);
12+
});
13+
14+
it('passes through when hints are all undefined (single-key command)', async () => {
15+
const reply = await reduceDefaultKeyed([Promise.resolve('v1')], PARSER, [undefined]);
16+
assert.equal(reply, 'v1');
17+
});
18+
19+
it('passes through a single-slot multi_shard reply unchanged', async () => {
20+
const reply = await reduceDefaultKeyed(
21+
[Promise.resolve(['v0', 'v1', 'v2'])],
22+
PARSER,
23+
[[0, 1, 2]]
24+
);
25+
assert.deepEqual(reply, ['v0', 'v1', 'v2']);
26+
});
27+
28+
it('scatters interleaved sub-replies back into original key order (MGET A,B,A,B)', async () => {
29+
// keys hash to A,B,A,B -> slot A holds groups [0,2], slot B holds [1,3].
30+
const reply = await reduceDefaultKeyed(
31+
[
32+
Promise.resolve(['a0', 'a2']),
33+
Promise.resolve(['b1', 'b3'])
34+
],
35+
PARSER,
36+
[[0, 2], [1, 3]]
37+
);
38+
assert.deepEqual(reply, ['a0', 'b1', 'a2', 'b3']);
39+
});
40+
41+
it('places each sub-reply by its hint regardless of plan order', async () => {
42+
// slot B (groups [1]) listed before slot A (groups [0,2]).
43+
const reply = await reduceDefaultKeyed(
44+
[
45+
Promise.resolve(['b1']),
46+
Promise.resolve(['a0', 'a2'])
47+
],
48+
PARSER,
49+
[[1], [0, 2]]
50+
);
51+
assert.deepEqual(reply, ['a0', 'b1', 'a2']);
52+
});
53+
54+
it('preserves null replies (missing keys) at their positions', async () => {
55+
const reply = await reduceDefaultKeyed(
56+
[
57+
Promise.resolve(['a0', null]),
58+
Promise.resolve([null])
59+
],
60+
PARSER,
61+
[[0, 2], [1]]
62+
);
63+
assert.deepEqual(reply, ['a0', null, null]);
64+
});
65+
66+
it('throws when a split reply is missing its position hint', async () => {
67+
await assert.rejects(
68+
reduceDefaultKeyed(
69+
[Promise.resolve(['a0']), Promise.resolve(['b1'])],
70+
PARSER,
71+
[[0], undefined]
72+
),
73+
/missing position hints/
74+
);
75+
});
76+
});

0 commit comments

Comments
 (0)