Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
b521844
import POC
nkaradzhov Jun 10, 2025
42c4cb8
use lowercase for command matching
nkaradzhov Jun 10, 2025
fd26bdf
expose commandName getter
nkaradzhov Jun 10, 2025
d65353e
pass down command name and search policy
nkaradzhov Jun 10, 2025
d689168
partially parse tips and key specs [skip ci]
nkaradzhov Jun 11, 2025
da87a62
use response policies [skip ci]
nkaradzhov Jun 11, 2025
0ac2a53
parse subcommands, update static policies
nkaradzhov Jun 11, 2025
22b0015
fix tests
nkaradzhov Jun 11, 2025
2bba4d1
resolve commands and subcommands
nkaradzhov Jun 18, 2025
aff4b1f
add comments to all policies
nkaradzhov Jun 18, 2025
9ba43bf
POC partial implementation of request-response routing and aggregation
nkaradzhov Jun 18, 2025
964df62
extract request/response policy branches into named dispatch helpers
nkaradzhov Jun 8, 2026
aff4a0d
replace policy switches with strategy registries in `_execute`
nkaradzhov Jun 8, 2026
e907daa
stabilize policy resolver: consistent casing + safer subcommand handling
nkaradzhov Jun 8, 2026
55b5586
align Search policy entries with the HLD command routing table
nkaradzhov Jun 8, 2026
2ecb8af
feat(client): add static-policies-data generator
nkaradzhov Jun 11, 2026
8a95c2b
fix(client): scan all tips when parsing command policies
nkaradzhov Jun 11, 2026
53825ff
feat(client): parse COMMAND key specs into CommandReply
nkaradzhov Jun 11, 2026
3308b93
feat(client): carry key specs into multi_shard command policies
nkaradzhov Jun 11, 2026
4549506
feat(client): add multi_shard command splitter
nkaradzhov Jun 12, 2026
f339e62
refactor(client): extract policy dispatch into _executeWithPolicies
nkaradzhov Jun 12, 2026
ab36c4f
fix(client): correct default policy reducers
nkaradzhov Jun 12, 2026
b1a47cb
fix(client): connect nodes lazily in all-shards/all-nodes fan-out
nkaradzhov Jun 12, 2026
bbc3101
feat(client): wire multi_shard splitter into cluster dispatch
nkaradzhov Jun 16, 2026
f1c7df5
fix(client): fall back to default policy for unknown cluster commands
nkaradzhov Jun 16, 2026
212ee31
fix(client): route and split the raw cluster sendCommand path correctly
nkaradzhov Jun 16, 2026
7c38ce5
feat(client): implement special-policy reducers + safe fallback
nkaradzhov Jul 1, 2026
fb6d15e
feat(client): sticky FT.CURSOR routing via special request policy
nkaradzhov Jul 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions examples/lua-multi-incr.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const client = createClient({
scripts: {
mincr: defineScript({
NUMBER_OF_KEYS: 2,
// TODO add RequestPolicy: ,
SCRIPT:
'return {' +
'redis.pcall("INCRBY", KEYS[1], ARGV[1]),' +
Expand Down
31 changes: 31 additions & 0 deletions packages/client/lib/client/parser.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,4 +160,35 @@ describe('BasicCommandParser', () => {
}
});
});

describe('markRoutingKey', () => {
it('sets firstKey without appending to redisArgs', () => {
const parser = new BasicCommandParser();
parser.push('MGET', 'k1', 'k2');
parser.markRoutingKey('k1');

// redisArgs stays an exact copy of what was pushed (the wire command).
assert.deepEqual(parser.redisArgs, ['MGET', 'k1', 'k2']);
// ...but the key is registered for routing.
assert.deepEqual(parser.keys, ['k1']);
assert.equal(parser.firstKey, 'k1');
});

it('leaves keys empty when never called (keyless raw command)', () => {
const parser = new BasicCommandParser();
parser.push('PING');

assert.deepEqual(parser.redisArgs, ['PING']);
assert.deepEqual(parser.keys, []);
assert.equal(parser.firstKey, undefined);
});

it('keeps commandIdentifier pointing at the command name', () => {
const parser = new BasicCommandParser();
parser.push('GET', 'k1');
parser.markRoutingKey('k1');

assert.deepEqual(parser.commandIdentifier, { command: 'GET', subcommand: 'k1' });
});
});
});
24 changes: 24 additions & 0 deletions packages/client/lib/client/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,14 @@ export function prefixKeys(keyPrefix: RedisArgument | undefined, keys: RedisVari
: [prefixKey(keyPrefix, keys)];
}

export type CommandIdentifier = { command: string, subcommand: string | undefined };

export interface CommandParser {
redisArgs: ReadonlyArray<RedisArgument>;
keys: ReadonlyArray<RedisArgument>;
firstKey: RedisArgument | undefined;
preserve: unknown;
commandIdentifier: CommandIdentifier;

push: (...arg: Array<RedisArgument>) => unknown;
pushVariadic: (vals: RedisVariadicArgument) => unknown;
Expand Down Expand Up @@ -90,6 +93,16 @@ export class BasicCommandParser implements CommandParser {
return tmp.join('_');
}

get commandIdentifier(): CommandIdentifier {
const rawCommand = this.#redisArgs[0];
const rawSubcommand = this.#redisArgs[1];
const command = rawCommand instanceof Buffer ? rawCommand.toString() : rawCommand;
const subcommand = rawSubcommand === undefined
? undefined
: rawSubcommand instanceof Buffer ? rawSubcommand.toString() : rawSubcommand;
return { command, subcommand };
}

push(...arg: Array<RedisArgument>) {
this.#redisArgs.push(...arg);
};
Expand Down Expand Up @@ -137,6 +150,17 @@ export class BasicCommandParser implements CommandParser {
this.#addKey(key, applyPrefix);
}

/**
* Records a routing key whose value is already present in the pushed args,
* without appending it again. Used by the raw cluster `sendCommand` path,
* where the caller supplies the routing key separately from the full,
* already-assembled argument list — so `firstKey` resolves for routing while
* `redisArgs` stays an exact copy of the command sent on the wire.
*/
markRoutingKey(key: RedisArgument) {
this.#keys.push(key);
}

pushKeysLength(keys: RedisVariadicArgument) {
if (Array.isArray(keys)) {
this.#redisArgs.push(keys.length.toString());
Expand Down
83 changes: 83 additions & 0 deletions packages/client/lib/cluster/cluster-slots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,26 @@ export type NodeAddressMap = {

export const RESUBSCRIBE_LISTENERS_EVENT = '__resubscribeListeners'

/**
* Sticky-cursor binding: which node served a RediSearch cursor. FT.CURSOR
* READ/DEL carry no key, so hash-slot routing can't reach the coordinator that
* minted the cursor — we pin by `address` ("host:port"), the durable handle
* (clients are recreated on reconnect/topology refresh, addresses aren't).
*/
export interface CursorBinding {
address: string;
createdAt: number;
maxIdleMs?: number;
}

/**
* Fallback idle TTL for the opportunistic cursor-binding sweep when the
* FT.AGGREGATE didn't declare MAXIDLE. Mirrors the RediSearch default (300s)
* so abandoned cursors don't leak the binding map (timer-free, like
* `smigratedSeqIdsSeen`).
*/
const DEFAULT_CURSOR_MAX_IDLE_MS = 300_000;

export interface Node<
M extends RedisModules,
F extends RedisFunctions,
Expand Down Expand Up @@ -121,6 +141,8 @@ export default class RedisClusterSlots<
pubSubNode?: PubSubNode<M, F, S, RESP, TYPE_MAPPING>;
clientSideCache?: PooledClientSideCacheProvider;
smigratedSeqIdsSeen = new Set<number>;
/** Per-instance sticky-cursor bindings, keyed `${index}:${cursorId}`. */
readonly cursorBindings = new Map<string, CursorBinding>();
#topologyRefreshPromise?: Promise<boolean | void>;

#isOpen = false;
Expand Down Expand Up @@ -792,6 +814,23 @@ export default class RedisClusterSlots<
this.#emit('disconnect');
}

/**
* All node clients (masters and replicas), connecting lazily — with
* `minimizeConnections` nodes have no client until first use, and skipping
* them would silently fan commands out to a subset of the cluster.
* Excludes dedicated PubSub connections: they cannot run regular commands.
*/
getAllClients(): Promise<Array<RedisClientType<M, F, S, RESP, TYPE_MAPPING>>> {
return Promise.all([
...this.masters.map(master => this.nodeClient(master)),
...this.replicas.map(replica => this.nodeClient(replica))
]);
}

getAllMasterClients(): Promise<Array<RedisClientType<M, F, S, RESP, TYPE_MAPPING>>> {
return Promise.all(this.masters.map(master => this.nodeClient(master)));
}

async getClientAndSlotNumber(
firstKey: RedisArgument | undefined,
isReadonly: boolean | undefined
Expand Down Expand Up @@ -901,6 +940,50 @@ export default class RedisClusterSlots<
return this.nodeClient(master);
}

/**
* Reverse-resolve a routed client to its node address. FT.AGGREGATE is
* keyless, so the plan carries only the client; we need its address to bind
* the cursor. Clients are few per cluster, so the linear scan is negligible.
*/
nodeAddressByClient(client: RedisClientType<M, F, S, RESP, TYPE_MAPPING>): string | undefined {
for (const [address, node] of this.nodeByAddress) {
if (node.client === client) return address;
}
return undefined;
}

#cursorKey(index: string, cursorId: number) {
return `${index}:${cursorId}`;
}

/**
* Drop bindings idle past their MAXIDLE (or the default TTL). Opportunistic —
* runs on each `bindCursor` so there's no timer to manage (see
* `smigratedSeqIdsSeen`). Cheap: the map holds only live cursors.
*/
#sweepStaleCursors(now: number) {
for (const [key, binding] of this.cursorBindings) {
const ttl = binding.maxIdleMs ?? DEFAULT_CURSOR_MAX_IDLE_MS;
if (now - binding.createdAt > ttl) {
this.cursorBindings.delete(key);
}
}
}

bindCursor(index: string, cursorId: number, address: string, maxIdleMs?: number) {
const now = Date.now();
this.#sweepStaleCursors(now);
this.cursorBindings.set(this.#cursorKey(index, cursorId), { address, createdAt: now, maxIdleMs });
}

lookupCursor(index: string, cursorId: number): CursorBinding | undefined {
return this.cursorBindings.get(this.#cursorKey(index, cursorId));
}

evictCursor(index: string, cursorId: number) {
this.cursorBindings.delete(this.#cursorKey(index, cursorId));
}

getPubSubClient(): Promise<RedisClientType<M, F, S, RESP, TYPE_MAPPING>> {
if (!this.pubSubNode) return this.#initiatePubSubClient();

Expand Down
Loading
Loading