From b5218444187915a9c72487ca4059514c9516540c Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Tue, 10 Jun 2025 13:08:28 +0300 Subject: [PATCH 01/28] import POC --- examples/lua-multi-incr.js | 1 + packages/client/lib/cluster/index.ts | 10 +- .../command-router.ts | 15 + .../dynamic-policy-resolver-factory.ts | 109 ++++++ .../dynamic-policy-resolver.spec.ts | 317 ++++++++++++++++++ .../request-response-policies/index.ts | 10 + .../policies-constants.ts | 30 ++ .../static-policies-data.ts | 59 ++++ .../static-policy-resolver.ts | 61 ++++ .../request-response-policies/test.spec.ts | 9 + .../request-response-policies/types.ts | 22 ++ .../lib/commands/generic-transformers.ts | 12 +- 12 files changed, 650 insertions(+), 5 deletions(-) create mode 100644 packages/client/lib/cluster/request-response-policies/command-router.ts create mode 100644 packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver-factory.ts create mode 100644 packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver.spec.ts create mode 100644 packages/client/lib/cluster/request-response-policies/index.ts create mode 100644 packages/client/lib/cluster/request-response-policies/policies-constants.ts create mode 100644 packages/client/lib/cluster/request-response-policies/static-policies-data.ts create mode 100644 packages/client/lib/cluster/request-response-policies/static-policy-resolver.ts create mode 100644 packages/client/lib/cluster/request-response-policies/test.spec.ts create mode 100644 packages/client/lib/cluster/request-response-policies/types.ts diff --git a/examples/lua-multi-incr.js b/examples/lua-multi-incr.js index 8f872a1c0a5..645c41b6c5f 100644 --- a/examples/lua-multi-incr.js +++ b/examples/lua-multi-incr.js @@ -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]),' + diff --git a/packages/client/lib/cluster/index.ts b/packages/client/lib/cluster/index.ts index 3861c7bdecb..e669ad26652 100644 --- a/packages/client/lib/cluster/index.ts +++ b/packages/client/lib/cluster/index.ts @@ -16,6 +16,7 @@ import SingleEntryCache from '../single-entry-cache' import { publish, CHANNELS } from '../client/tracing'; import { ClientIdentity, ClientRole, generateClusterClientId } from '../client/identity'; import { DEFAULT_COMMAND_TIMEOUT } from '../defaults'; +import { POLICIES, PolicyResolver, StaticPolicyResolver } from './request-response-policies'; export type ClusterTopologyRefreshOnReconnectionAttemptStrategy = false | @@ -29,7 +30,6 @@ type WithCommands< [P in keyof typeof NON_STICKY_COMMANDS]: CommandSignature<(typeof NON_STICKY_COMMANDS)[P], RESP, TYPE_MAPPING>; }; - interface ClusterCommander< M extends RedisModules, F extends RedisFunctions, @@ -179,6 +179,7 @@ export default class RedisCluster< return async function (this: ProxyCluster, ...args: Array) { const parser = new BasicCommandParser(this._self._keyPrefix); command.parseCommand(parser, ...args); + console.log(parser, parser.redisArgs[0]); return this._self._execute( parser.firstKey, @@ -294,6 +295,7 @@ export default class RedisCluster< private _self = this; private _commandOptions?: ClusterCommandOptions; + private _policyResolver: PolicyResolver; /** * An array of the cluster slots, each slot contain its `master` and `replicas`. @@ -370,6 +372,8 @@ export default class RedisCluster< this.on(RESUBSCRIBE_LISTENERS_EVENT, this.resubscribeAllPubSubListeners.bind(this)); this._commandOptions = { timeout: DEFAULT_COMMAND_TIMEOUT, ...options?.commandOptions }; + + this._policyResolver = new StaticPolicyResolver(POLICIES); } duplicate< @@ -469,7 +473,11 @@ export default class RedisCluster< options: ClusterCommandOptions | undefined, fn: (client: RedisClientType, opts?: ClusterCommandOptions) => Promise ): Promise { + console.log(`executing command `, firstKey, isReadonly, options); const maxCommandRedirections = this._options.maxCommandRedirections ?? 16; + const p = this._policyResolver.resolvePolicy("ping") + console.log(`ping policy `, p); + let { client, slotNumber } = await this._slots.getClientAndSlotNumber(firstKey, isReadonly); let i = 0; diff --git a/packages/client/lib/cluster/request-response-policies/command-router.ts b/packages/client/lib/cluster/request-response-policies/command-router.ts new file mode 100644 index 00000000000..e7dacb51f85 --- /dev/null +++ b/packages/client/lib/cluster/request-response-policies/command-router.ts @@ -0,0 +1,15 @@ +// import { RedisFunctions, RedisModules, RedisScripts, RespVersions, TypeMapping } from "../../RESP/types"; +// import { ShardNode } from "../cluster-slots"; +// import type { Either } from './types'; + +// export interface CommandRouter< +// M extends RedisModules, +// F extends RedisFunctions, +// S extends RedisScripts, +// RESP extends RespVersions, +// TYPE_MAPPING extends TypeMapping> { +// routeCommand( +// command: string, +// policy: RequestPolicy, +// ): Either, 'no-available-nodes' | 'routing-failed'>; +// } \ No newline at end of file diff --git a/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver-factory.ts b/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver-factory.ts new file mode 100644 index 00000000000..1712390a802 --- /dev/null +++ b/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver-factory.ts @@ -0,0 +1,109 @@ +import type { CommandReply } from '../../commands/generic-transformers'; +import type { CommandPolicies } from './policies-constants'; +import { REQUEST_POLICIES_WITH_DEFAULTS, RESPONSE_POLICIES_WITH_DEFAULTS } from './policies-constants'; +import type { PolicyResolver } from './types'; +import { StaticPolicyResolver } from './static-policy-resolver'; +import type { ModulePolicyRecords } from './static-policies-data'; + +/** + * Function type that returns command information from Redis + */ +export type CommandFetcher = () => Promise>; + +/** + * A factory for creating policy resolvers that dynamically build policies based on the Redis server's COMMAND response. + * + * This factory fetches command information from Redis and analyzes the response to determine + * appropriate routing policies for each command, returning a StaticPolicyResolver with the built policies. + */ +export class DynamicPolicyResolverFactory { + /** + * Creates a StaticPolicyResolver by fetching command information from Redis + * and building appropriate policies based on the command characteristics. + * + * @param commandFetcher Function to fetch command information from Redis + * @param fallbackResolver Optional fallback resolver to use when policies are not found + * @returns A new StaticPolicyResolver with the fetched policies + */ + static async create( + commandFetcher: CommandFetcher, + fallbackResolver?: PolicyResolver + ): Promise { + const commands = await commandFetcher(); + const policies: ModulePolicyRecords = {}; + + for (const command of commands) { + const parsed = DynamicPolicyResolverFactory.#parseCommandName(command.name); + + // Skip commands with invalid format (more than one dot) + if (!parsed) { + continue; + } + + const { moduleName, commandName } = parsed; + + // Initialize module if it doesn't exist + if (!policies[moduleName]) { + policies[moduleName] = {}; + } + + // Determine policies for this command + const commandPolicies = DynamicPolicyResolverFactory.#buildCommandPolicies(command); + policies[moduleName][commandName] = commandPolicies; + } + + return new StaticPolicyResolver(policies, fallbackResolver); + } + + /** + * Parses a command name to extract module and command components. + * + * Redis commands can be in format: + * - "ping" -> module: "std", command: "ping" + * - "ft.search" -> module: "ft", command: "search" + * + * Commands with more than one dot are invalid. + */ + static #parseCommandName(fullCommandName: string): { moduleName: string; commandName: string } | null { + const parts = fullCommandName.split('.'); + + if (parts.length === 1) { + return { moduleName: 'std', commandName: fullCommandName }; + } + + if (parts.length === 2) { + return { moduleName: parts[0], commandName: parts[1] }; + } + + // Commands with more than one dot are invalid in Redis + return null; + } + + /** + * Builds CommandPolicies for a command based on its characteristics. + * + * Priority order: + * 1. Use explicit policies from the command if available + * 2. Classify as DEFAULT_KEYLESS if keySpecification is empty + * 3. Classify as DEFAULT_KEYED if keySpecification is not empty + */ + static #buildCommandPolicies(command: CommandReply): CommandPolicies { + // Determine if command is keyless based on keySpecification + const isKeyless = command.keySpecifications === 'keyless'; + + // Determine default policies based on key specification + const defaultRequest = isKeyless + ? REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS + : REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED; + const defaultResponse = isKeyless + ? RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS + : RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED; + + return { + // request: command.policies.request ?? defaultRequest, + // response: command.policies.response ?? defaultResponse + request: defaultRequest, + response: defaultResponse + }; + } +} \ No newline at end of file diff --git a/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver.spec.ts b/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver.spec.ts new file mode 100644 index 00000000000..fe115addb37 --- /dev/null +++ b/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver.spec.ts @@ -0,0 +1,317 @@ +import { strict as assert } from 'node:assert'; +import type { CommandReply } from '../../commands/generic-transformers'; +import { DynamicPolicyResolverFactory, type CommandFetcher, StaticPolicyResolver, REQUEST_POLICIES_WITH_DEFAULTS, RESPONSE_POLICIES_WITH_DEFAULTS } from '.'; +import testUtils, { GLOBAL } from '../../test-utils'; + + +const createMockCommandFetcher = (commands: Array): CommandFetcher => async () => commands; + +describe('DynamicPolicyResolverFactory', () => { + + describe('create', () => { + it('should create StaticPolicyResolver with empty policies', async () => { + const mockCommandFetcher = createMockCommandFetcher([]); + const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher); + assert.ok(resolver instanceof StaticPolicyResolver); + }); + + it('should create StaticPolicyResolver with fallback', async () => { + const mockCommandFetcher = createMockCommandFetcher([]); + const fallbackResolver = new StaticPolicyResolver({ + std: { + ping: { + request: REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS, + response: RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS + } + } + }); + + const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher, fallbackResolver); + assert.ok(resolver instanceof StaticPolicyResolver); + + const result = resolver.resolvePolicy('ping'); + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.value.request, REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS); + assert.equal(result.value.response, RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS); + } + }); + }); + + describe('create with commands', () => { + it('should classify keyless commands correctly', async () => { + const mockCommands: Array = [ + { + name: 'ping', + arity: -1, + flags: new Set(), + firstKeyIndex: 0, + lastKeyIndex: 0, + step: 0, + categories: new Set(), + // policies: { request: undefined, response: undefined }, + keySpecifications: 'keyless' + } + ]; + + const mockCommandFetcher = createMockCommandFetcher(mockCommands); + const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher); + + const result = resolver.resolvePolicy('ping'); + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.value.request, REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS); + assert.equal(result.value.response, RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS); + } + }); + + it('should classify keyed commands correctly', async () => { + const mockCommands: Array = [ + { + name: 'get', + arity: 2, + flags: new Set(), + firstKeyIndex: 1, + lastKeyIndex: 1, + step: 1, + categories: new Set(), + // policies: { request: undefined, response: undefined }, + keySpecifications: [Buffer.from('key')] as any + } + ]; + + const mockCommandFetcher = createMockCommandFetcher(mockCommands); + const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher); + + const result = resolver.resolvePolicy('get'); + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.value.request, REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED); + assert.equal(result.value.response, RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED); + } + }); + + it('should use explicit policies when available', async () => { + const mockCommands: Array = [ + { + name: 'dbsize', + arity: 1, + flags: new Set(), + firstKeyIndex: 0, + lastKeyIndex: 0, + step: 0, + categories: new Set(), + // policies: { request: 'all_shards', response: 'agg_sum' }, + keySpecifications: 'keyless' + } + ]; + + const mockCommandFetcher = createMockCommandFetcher(mockCommands); + const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher); + + const result = resolver.resolvePolicy('dbsize'); + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.value.request, 'all_shards'); + assert.equal(result.value.response, 'agg_sum'); + } + }); + + it('should handle module commands correctly', async () => { + const mockCommands: Array = [ + { + name: 'ft.search', + arity: -2, + flags: new Set(), + firstKeyIndex: 1, + lastKeyIndex: 1, + step: 1, + categories: new Set(), + // policies: { request: 'all_shards', response: 'special' }, + keySpecifications: [Buffer.from('key')] as any + } + ]; + + const mockCommandFetcher = createMockCommandFetcher(mockCommands); + const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher); + + const result = resolver.resolvePolicy('ft.search'); + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.value.request, 'all_shards'); + assert.equal(result.value.response, 'special'); + } + }); + + it('should handle valid module commands', async () => { + const mockCommands: Array = [ + { + name: 'json.get', + arity: 1, + flags: new Set(), + firstKeyIndex: 0, + lastKeyIndex: 0, + step: 0, + categories: new Set(), + // policies: { request: undefined, response: undefined }, + keySpecifications: 'keyless' + } + ]; + + const mockCommandFetcher = createMockCommandFetcher(mockCommands); + const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher); + + const result = resolver.resolvePolicy('json.get'); + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.value.request, REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS); + assert.equal(result.value.response, RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS); + } + }); + }); + + describe('resolvePolicy', () => { + it('should work with created resolver', async () => { + const mockCommands: Array = [ + { + name: 'test', + arity: 1, + flags: new Set(), + firstKeyIndex: 0, + lastKeyIndex: 0, + step: 0, + categories: new Set(), + // policies: { request: undefined, response: undefined }, + keySpecifications: 'keyless' + } + ]; + + const mockCommandFetcher = createMockCommandFetcher(mockCommands); + const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher); + + const result = resolver.resolvePolicy('test'); + assert.equal(result.ok, true); + }); + + it('should handle unknown commands', async () => { + const mockCommandFetcher = createMockCommandFetcher([]); + const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher); + + const result = resolver.resolvePolicy('unknown'); + assert.equal(result.ok, false); + assert.equal(result.error, 'unknown-command'); + }); + + it('should handle unknown modules', async () => { + const mockCommandFetcher = createMockCommandFetcher([]); + const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher); + + const result = resolver.resolvePolicy('unknown.command'); + assert.equal(result.ok, false); + assert.equal(result.error, 'unknown-module'); + }); + + it('should handle invalid command format', async () => { + const mockCommandFetcher = createMockCommandFetcher([]); + const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher); + + const result = resolver.resolvePolicy('too.many.dots.here'); + assert.equal(result.ok, false); + assert.equal(result.error, 'wrong-command-or-module-name'); + }); + }); + + describe('edge cases', () => { + it('should handle commands with partial policies', async () => { + const mockCommands: Array = [ + { + name: 'partial-request', + arity: 1, + flags: new Set(), + firstKeyIndex: 0, + lastKeyIndex: 0, + step: 0, + categories: new Set(), + // policies: { request: 'all_nodes', response: undefined }, + keySpecifications: [Buffer.from('key')] as any + }, + { + name: 'partial-response', + arity: 1, + flags: new Set(), + firstKeyIndex: 0, + lastKeyIndex: 0, + step: 0, + categories: new Set(), + // policies: { request: undefined, response: 'agg_sum' }, + keySpecifications: 'keyless' + } + ]; + + const mockCommandFetcher = createMockCommandFetcher(mockCommands); + const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher); + + // Command with only request policy should fall back to defaults + let result = resolver.resolvePolicy('partial-request'); + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.value.request, REQUEST_POLICIES_WITH_DEFAULTS.ALL_NODES); + assert.equal(result.value.response, RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED); + } + + // Command with only response policy should fall back to defaults + result = resolver.resolvePolicy('partial-response'); + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.value.request, REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS); + assert.equal(result.value.response, RESPONSE_POLICIES_WITH_DEFAULTS.AGG_SUM); + } + }); + + it('should handle empty command list', async () => { + const mockCommandFetcher = createMockCommandFetcher([]); + const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher); + assert.ok(resolver instanceof StaticPolicyResolver); + + const result = resolver.resolvePolicy('any-command'); + assert.equal(result.ok, false); + assert.equal(result.error, 'unknown-command'); + }); + }); + + describe('integration tests', () => { + testUtils.testWithClient('should work with real Redis client', async client => { + const resolver = await DynamicPolicyResolverFactory.create(() => client.command()); + assert.ok(resolver instanceof StaticPolicyResolver); + + // Test that ping command is classified as keyless + const pingResult = resolver.resolvePolicy('ping'); + if (pingResult.ok) { + assert.equal(pingResult.value.request, REQUEST_POLICIES_WITH_DEFAULTS.ALL_SHARDS); + assert.equal(pingResult.value.response, RESPONSE_POLICIES_WITH_DEFAULTS.ALL_SUCCEEDED); + } else { + assert.fail('Expected pingResult.ok to be true'); + } + + // Test that get command is classified as keyed + const getResult = resolver.resolvePolicy('get'); + if (getResult.ok) { + assert.equal(getResult.value.request, REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED); + assert.equal(getResult.value.response, RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED); + } else { + assert.fail('Expected getResult.ok to be true'); + } + + // Test that dbsize command uses explicit policies if available + const dbsizeResult = resolver.resolvePolicy('dbsize'); + + if (dbsizeResult.ok) { + assert.ok( + dbsizeResult.value.request === 'all_shards' && dbsizeResult.value.response === 'agg_sum' + ); + } else { + assert.fail('Expected dbsizeResult.ok to be true'); + } + }, GLOBAL.SERVERS.OPEN); + }); +}); diff --git a/packages/client/lib/cluster/request-response-policies/index.ts b/packages/client/lib/cluster/request-response-policies/index.ts new file mode 100644 index 00000000000..24a1761451a --- /dev/null +++ b/packages/client/lib/cluster/request-response-policies/index.ts @@ -0,0 +1,10 @@ +export type { Either, PolicyResult, PolicyResolver } from './types'; + +export { StaticPolicyResolver } from './static-policy-resolver'; +export { DynamicPolicyResolverFactory, type CommandFetcher } from './dynamic-policy-resolver-factory'; + +export * from './policies-constants'; +export type { ModulePolicyRecords, CommandPolicyRecords } from './static-policies-data'; +export { POLICIES } from './static-policies-data'; + +// export { type CommandRouter } from './command-router'; \ No newline at end of file diff --git a/packages/client/lib/cluster/request-response-policies/policies-constants.ts b/packages/client/lib/cluster/request-response-policies/policies-constants.ts new file mode 100644 index 00000000000..d27c550d755 --- /dev/null +++ b/packages/client/lib/cluster/request-response-policies/policies-constants.ts @@ -0,0 +1,30 @@ +export const REQUEST_POLICIES_WITH_DEFAULTS = { + ALL_NODES: "all_nodes", + ALL_SHARDS: "all_shards", + MULTI_SHARD: "multi_shard", + SPECIAL: "special", + DEFAULT_KEYLESS: "default-keyless", + DEFAULT_KEYED: "default-keyed" +} as const; + +export type RequestPolicyWithDefaults = typeof REQUEST_POLICIES_WITH_DEFAULTS[keyof typeof REQUEST_POLICIES_WITH_DEFAULTS]; + +export const RESPONSE_POLICIES_WITH_DEFAULTS = { + ONE_SUCCEEDED: "one_succeeded", + ALL_SUCCEEDED: "all_succeeded", + AGG_LOGICAL_AND: "agg_logical_and", + AGG_LOGICAL_OR: "agg_logical_or", + AGG_MIN: "agg_min", + AGG_MAX: "agg_max", + AGG_SUM: "agg_sum", + SPECIAL: "special", + DEFAULT_KEYLESS: "default-keyless", + DEFAULT_KEYED: "default-keyed" +} as const; + +export type ResponsePolicyWithDefaults = typeof RESPONSE_POLICIES_WITH_DEFAULTS[keyof typeof RESPONSE_POLICIES_WITH_DEFAULTS]; + +export interface CommandPolicies { + readonly request: RequestPolicyWithDefaults; + readonly response: ResponsePolicyWithDefaults; +} \ No newline at end of file diff --git a/packages/client/lib/cluster/request-response-policies/static-policies-data.ts b/packages/client/lib/cluster/request-response-policies/static-policies-data.ts new file mode 100644 index 00000000000..68c44c3129d --- /dev/null +++ b/packages/client/lib/cluster/request-response-policies/static-policies-data.ts @@ -0,0 +1,59 @@ +import { CommandPolicies, REQUEST_POLICIES_WITH_DEFAULTS, RESPONSE_POLICIES_WITH_DEFAULTS } from './policies-constants'; + +export type CommandPolicyRecords = Record; +// The response of the COMMAND command uses "." to separate the module name from the command name. +// For example, "ft.search" refers to the "search" command in the "ft" module. It is important to use the same naming convention here. +export type ModulePolicyRecords = Record; + +export const POLICIES: ModulePolicyRecords = { + ft: { + create: { + request: REQUEST_POLICIES_WITH_DEFAULTS.ALL_NODES, + response: RESPONSE_POLICIES_WITH_DEFAULTS.ALL_SUCCEEDED + }, + search: { + request: REQUEST_POLICIES_WITH_DEFAULTS.ALL_SHARDS, + response: RESPONSE_POLICIES_WITH_DEFAULTS.SPECIAL + }, + aggregate: { + request: REQUEST_POLICIES_WITH_DEFAULTS.ALL_SHARDS, + response: RESPONSE_POLICIES_WITH_DEFAULTS.SPECIAL + }, + sugadd: { + request: REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED, + response: RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED + }, + sugget: { + request: REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED, + response: RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED + }, + sugdel: { + request: REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED, + response: RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED + }, + suglen: { + request: REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED, + response: RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED + }, + spellcheck: { + request: REQUEST_POLICIES_WITH_DEFAULTS.ALL_SHARDS, + response: RESPONSE_POLICIES_WITH_DEFAULTS.SPECIAL + }, + cursor: { + request: REQUEST_POLICIES_WITH_DEFAULTS.SPECIAL, + response: RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS + }, + dictadd: { + request: REQUEST_POLICIES_WITH_DEFAULTS.ALL_SHARDS, + response: RESPONSE_POLICIES_WITH_DEFAULTS.ALL_SUCCEEDED + }, + dictdel: { + request: REQUEST_POLICIES_WITH_DEFAULTS.ALL_SHARDS, + response: RESPONSE_POLICIES_WITH_DEFAULTS.ALL_SUCCEEDED + }, + dictdump: { + request: REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS, + response: RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS + } + } +} as const; diff --git a/packages/client/lib/cluster/request-response-policies/static-policy-resolver.ts b/packages/client/lib/cluster/request-response-policies/static-policy-resolver.ts new file mode 100644 index 00000000000..2d9472fd95d --- /dev/null +++ b/packages/client/lib/cluster/request-response-policies/static-policy-resolver.ts @@ -0,0 +1,61 @@ +import type { PolicyResult, PolicyResolver } from './types'; +import { POLICIES } from './static-policies-data'; + +export class StaticPolicyResolver implements PolicyResolver { + private readonly fallbackResolver: PolicyResolver | null = null; + + constructor( + private readonly policies = POLICIES, + fallbackResolver?: PolicyResolver + ) { + this.fallbackResolver = fallbackResolver || null; + } + + /** + * Sets a fallback resolver to use when policies are not found in this resolver. + * + * @param fallbackResolver The resolver to fall back to + * @returns A new StaticPolicyResolver with the specified fallback + */ + withFallback(fallbackResolver: PolicyResolver): StaticPolicyResolver { + return new StaticPolicyResolver(this.policies, fallbackResolver); + } + + resolvePolicy(command: string): PolicyResult { + const parts = command.split('.'); + + if (parts.length > 2) { + return { ok: false, error: 'wrong-command-or-module-name' }; + } + + const [moduleName, commandName] = parts.length === 1 + ? ['std', command] + : parts; + + if (!this.policies[moduleName]) { + if (this.fallbackResolver) { + return this.fallbackResolver.resolvePolicy(command); + } + + // For std module commands, return 'unknown-command' instead of 'unknown-module' + // to provide better UX for single-word commands + if (moduleName === 'std') { + return { ok: false, error: 'unknown-command' }; + } + return { ok: false, error: 'unknown-module' }; + } + + if (!this.policies[moduleName][commandName]) { + // Try fallback resolver if available + if (this.fallbackResolver) { + return this.fallbackResolver.resolvePolicy(command); + } + return { ok: false, error: 'unknown-command' }; + } + + return { + ok: true, + value: this.policies[moduleName][commandName] + } + } +} diff --git a/packages/client/lib/cluster/request-response-policies/test.spec.ts b/packages/client/lib/cluster/request-response-policies/test.spec.ts new file mode 100644 index 00000000000..837a837d74a --- /dev/null +++ b/packages/client/lib/cluster/request-response-policies/test.spec.ts @@ -0,0 +1,9 @@ +import testUtils, { GLOBAL } from '../../test-utils'; + +describe('Cluster Request-Response Policies', () => { + testUtils.testWithCluster('should resolve policies correctly', async cluster => { + + await cluster.get('foo') + + }, GLOBAL.CLUSTERS.OPEN); +}); diff --git a/packages/client/lib/cluster/request-response-policies/types.ts b/packages/client/lib/cluster/request-response-policies/types.ts new file mode 100644 index 00000000000..27d2cae8a7b --- /dev/null +++ b/packages/client/lib/cluster/request-response-policies/types.ts @@ -0,0 +1,22 @@ +import type { CommandPolicies } from './policies-constants'; + +export type Either = + | { readonly ok: true; readonly value: TOk } + | { readonly ok: false; readonly error: TError }; + +export type PolicyResult = Either; + +export interface PolicyResolver { + /** + * The response of the COMMAND command uses "." to separate the module name from the command name. + */ + resolvePolicy(command: string): PolicyResult; + + /** + * Sets a fallback resolver to use when policies are not found in this resolver. + * + * @param fallbackResolver The resolver to fall back to + * @returns A new PolicyResolver with the specified fallback + */ + withFallback(fallbackResolver: PolicyResolver): PolicyResolver; +} diff --git a/packages/client/lib/commands/generic-transformers.ts b/packages/client/lib/commands/generic-transformers.ts index badc580c125..b0f217d7ffb 100644 --- a/packages/client/lib/commands/generic-transformers.ts +++ b/packages/client/lib/commands/generic-transformers.ts @@ -342,7 +342,9 @@ export type CommandRawReply = [ firstKeyIndex: number, lastKeyIndex: number, step: number, - categories: Array + categories: Array, + tips: Array, + keySpecifications: string ]; export type CommandReply = { @@ -352,12 +354,13 @@ export type CommandReply = { firstKeyIndex: number, lastKeyIndex: number, step: number, - categories: Set + categories: Set, + keySpecifications: string }; export function transformCommandReply( this: void, - [name, arity, flags, firstKeyIndex, lastKeyIndex, step, categories]: CommandRawReply + [name, arity, flags, firstKeyIndex, lastKeyIndex, step, categories, _tips, keySpecifications]: CommandRawReply ): CommandReply { return { name, @@ -366,7 +369,8 @@ export function transformCommandReply( firstKeyIndex, lastKeyIndex, step, - categories: new Set(categories) + categories: new Set(categories), + keySpecifications }; } From 42c4cb82ce56803358429f77e989086ceeb0ade8 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Tue, 10 Jun 2025 17:46:25 +0300 Subject: [PATCH 02/28] use lowercase for command matching --- .../static-policy-resolver.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/client/lib/cluster/request-response-policies/static-policy-resolver.ts b/packages/client/lib/cluster/request-response-policies/static-policy-resolver.ts index 2d9472fd95d..9d7772d40f5 100644 --- a/packages/client/lib/cluster/request-response-policies/static-policy-resolver.ts +++ b/packages/client/lib/cluster/request-response-policies/static-policy-resolver.ts @@ -22,19 +22,22 @@ export class StaticPolicyResolver implements PolicyResolver { } resolvePolicy(command: string): PolicyResult { - const parts = command.split('.'); + const parts = command.toLowerCase().split('.'); + if (parts.length > 2) { return { ok: false, error: 'wrong-command-or-module-name' }; } const [moduleName, commandName] = parts.length === 1 - ? ['std', command] + ? ['std', parts[0]] : parts; + console.log(`module name `, moduleName, `command name `, commandName); + if (!this.policies[moduleName]) { if (this.fallbackResolver) { - return this.fallbackResolver.resolvePolicy(command); + return this.fallbackResolver.resolvePolicy(commandName); } // For std module commands, return 'unknown-command' instead of 'unknown-module' @@ -48,7 +51,7 @@ export class StaticPolicyResolver implements PolicyResolver { if (!this.policies[moduleName][commandName]) { // Try fallback resolver if available if (this.fallbackResolver) { - return this.fallbackResolver.resolvePolicy(command); + return this.fallbackResolver.resolvePolicy(commandName); } return { ok: false, error: 'unknown-command' }; } From fd26bdfc4354f0491750ffb43f3fc9ebcdc8bcfb Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Tue, 10 Jun 2025 17:46:39 +0300 Subject: [PATCH 03/28] expose commandName getter --- packages/client/lib/client/parser.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/client/lib/client/parser.ts b/packages/client/lib/client/parser.ts index bc45f5e4651..74ddaea5920 100644 --- a/packages/client/lib/client/parser.ts +++ b/packages/client/lib/client/parser.ts @@ -90,6 +90,14 @@ export class BasicCommandParser implements CommandParser { return tmp.join('_'); } + get commandName(): string | undefined { + let cmdName = this.#redisArgs[0]; + if (cmdName instanceof Buffer) { + return cmdName.toString(); + } + return cmdName; + } + push(...arg: Array) { this.#redisArgs.push(...arg); }; From d65353e111c69bca2f952703a3add16b991e2c72 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Tue, 10 Jun 2025 17:48:00 +0300 Subject: [PATCH 04/28] pass down command name and search policy --- packages/client/lib/cluster/index.ts | 16 ++++++--- .../request-response-policies/test.spec.ts | 35 +++++++++++++++++-- 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/packages/client/lib/cluster/index.ts b/packages/client/lib/cluster/index.ts index e669ad26652..31490c0b2dd 100644 --- a/packages/client/lib/cluster/index.ts +++ b/packages/client/lib/cluster/index.ts @@ -179,12 +179,12 @@ export default class RedisCluster< return async function (this: ProxyCluster, ...args: Array) { const parser = new BasicCommandParser(this._self._keyPrefix); command.parseCommand(parser, ...args); - console.log(parser, parser.redisArgs[0]); return this._self._execute( parser.firstKey, command.IS_READ_ONLY, this._commandOptions, + parser.commandName!, (client, opts) => client._executeCommand(command, parser, opts, transformReply) ); }; @@ -201,6 +201,7 @@ export default class RedisCluster< parser.firstKey, command.IS_READ_ONLY, this._self._commandOptions, + parser.commandName!, (client, opts) => client._executeCommand(command, parser, opts, transformReply) ); }; @@ -219,6 +220,7 @@ export default class RedisCluster< parser.firstKey, fn.IS_READ_ONLY, this._self._commandOptions, + parser.commandName!, (client, opts) => client._executeCommand(fn, parser, opts, transformReply) ); }; @@ -237,6 +239,7 @@ export default class RedisCluster< parser.firstKey, script.IS_READ_ONLY, this._commandOptions, + parser.commandName!, (client, opts) => client._executeScript(script, parser, opts, transformReply) ); }; @@ -471,12 +474,16 @@ export default class RedisCluster< firstKey: RedisArgument | undefined, isReadonly: boolean | undefined, options: ClusterCommandOptions | undefined, + commandName: string, fn: (client: RedisClientType, opts?: ClusterCommandOptions) => Promise ): Promise { - console.log(`executing command `, firstKey, isReadonly, options); const maxCommandRedirections = this._options.maxCommandRedirections ?? 16; - const p = this._policyResolver.resolvePolicy("ping") - console.log(`ping policy `, p); + const policyResult = this._policyResolver.resolvePolicy(commandName) + if(policyResult.ok) { + //TODO + } else { + //TODO + } let { client, slotNumber } = await this._slots.getClientAndSlotNumber(firstKey, isReadonly); let i = 0; @@ -567,6 +574,7 @@ export default class RedisCluster< firstKey, isReadonly, opts, + args[0] instanceof Buffer ? args[0].toString() : args[0], (client, opts) => client.sendCommand(args, opts) ); } diff --git a/packages/client/lib/cluster/request-response-policies/test.spec.ts b/packages/client/lib/cluster/request-response-policies/test.spec.ts index 837a837d74a..2e3f15c389a 100644 --- a/packages/client/lib/cluster/request-response-policies/test.spec.ts +++ b/packages/client/lib/cluster/request-response-policies/test.spec.ts @@ -1,9 +1,38 @@ import testUtils, { GLOBAL } from '../../test-utils'; +import RediSearch from '@redis/search'; + +import RedisBloomModules from '@redis/bloom'; +import RedisJSON from '@redis/json'; +import RedisTimeSeries from '@redis/time-series'; describe('Cluster Request-Response Policies', () => { - testUtils.testWithCluster('should resolve policies correctly', async cluster => { + testUtils.testWithClient('should resolve policies correctly', async client => { + await client.ft.SUGADD('index', 'string', 1); + await client.ft.dictAdd('index', 'foo'); + }, { + ...GLOBAL.SERVERS.OPEN, + clientOptions: { + modules: { + ft: RediSearch + } + } + }); - await cluster.get('foo') + testUtils.testWithCluster('should resolve policies correctly', async cluster => { - }, GLOBAL.CLUSTERS.OPEN); + await cluster.ft.SUGADD('index', 'string', 1); + await cluster.ft.DICTADD('index', 'foo'); + await cluster.sendCommand(undefined, true, ['ft.dictadd', 'index', 'string']); + + }, { + ...GLOBAL.CLUSTERS.OPEN, + clusterConfiguration: { + modules: { + ft: RediSearch, + // ...RedisBloomModules, + // json: RedisJSON, + // ts: RedisTimeSeries + }, + } + }); }); From d6891685ed905f778581729d1a1f1dff9c7063ea Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Wed, 11 Jun 2025 11:52:49 +0300 Subject: [PATCH 05/28] partially parse tips and key specs [skip ci] for now we only extract: - request and response policy -> from tips - is the command keyless -> from key specs --- .../dynamic-policy-resolver-factory.ts | 2 +- .../dynamic-policy-resolver.spec.ts | 32 ++-- packages/client/lib/commands/COMMAND.spec.ts | 138 ++++++++++++++++-- .../lib/commands/generic-transformers.ts | 26 +++- 4 files changed, 162 insertions(+), 36 deletions(-) diff --git a/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver-factory.ts b/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver-factory.ts index 1712390a802..03d26c98598 100644 --- a/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver-factory.ts +++ b/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver-factory.ts @@ -89,7 +89,7 @@ export class DynamicPolicyResolverFactory { */ static #buildCommandPolicies(command: CommandReply): CommandPolicies { // Determine if command is keyless based on keySpecification - const isKeyless = command.keySpecifications === 'keyless'; + const isKeyless = command.isKeyless // Determine default policies based on key specification const defaultRequest = isKeyless diff --git a/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver.spec.ts b/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver.spec.ts index fe115addb37..f1a4feb16b2 100644 --- a/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver.spec.ts +++ b/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver.spec.ts @@ -49,8 +49,8 @@ describe('DynamicPolicyResolverFactory', () => { lastKeyIndex: 0, step: 0, categories: new Set(), - // policies: { request: undefined, response: undefined }, - keySpecifications: 'keyless' + policies: { request: undefined, response: undefined }, + isKeyless: true } ]; @@ -75,8 +75,8 @@ describe('DynamicPolicyResolverFactory', () => { lastKeyIndex: 1, step: 1, categories: new Set(), - // policies: { request: undefined, response: undefined }, - keySpecifications: [Buffer.from('key')] as any + policies: { request: undefined, response: undefined }, + isKeyless: false } ]; @@ -101,8 +101,8 @@ describe('DynamicPolicyResolverFactory', () => { lastKeyIndex: 0, step: 0, categories: new Set(), - // policies: { request: 'all_shards', response: 'agg_sum' }, - keySpecifications: 'keyless' + policies: { request: 'all_shards', response: 'agg_sum' }, + isKeyless: true } ]; @@ -127,8 +127,8 @@ describe('DynamicPolicyResolverFactory', () => { lastKeyIndex: 1, step: 1, categories: new Set(), - // policies: { request: 'all_shards', response: 'special' }, - keySpecifications: [Buffer.from('key')] as any + policies: { request: 'all_shards', response: 'special' }, + isKeyless: false } ]; @@ -153,8 +153,8 @@ describe('DynamicPolicyResolverFactory', () => { lastKeyIndex: 0, step: 0, categories: new Set(), - // policies: { request: undefined, response: undefined }, - keySpecifications: 'keyless' + policies: { request: undefined, response: undefined }, + isKeyless: true } ]; @@ -181,8 +181,8 @@ describe('DynamicPolicyResolverFactory', () => { lastKeyIndex: 0, step: 0, categories: new Set(), - // policies: { request: undefined, response: undefined }, - keySpecifications: 'keyless' + policies: { request: undefined, response: undefined }, + isKeyless: true } ]; @@ -232,8 +232,8 @@ describe('DynamicPolicyResolverFactory', () => { lastKeyIndex: 0, step: 0, categories: new Set(), - // policies: { request: 'all_nodes', response: undefined }, - keySpecifications: [Buffer.from('key')] as any + policies: { request: 'all_nodes', response: undefined }, + isKeyless: false }, { name: 'partial-response', @@ -243,8 +243,8 @@ describe('DynamicPolicyResolverFactory', () => { lastKeyIndex: 0, step: 0, categories: new Set(), - // policies: { request: undefined, response: 'agg_sum' }, - keySpecifications: 'keyless' + policies: { request: undefined, response: 'agg_sum' }, + isKeyless: true } ]; diff --git a/packages/client/lib/commands/COMMAND.spec.ts b/packages/client/lib/commands/COMMAND.spec.ts index 860ffc30685..8a3900d9d66 100644 --- a/packages/client/lib/commands/COMMAND.spec.ts +++ b/packages/client/lib/commands/COMMAND.spec.ts @@ -1,17 +1,125 @@ -// import { strict as assert } from 'node:assert'; -// import testUtils, { GLOBAL } from '../test-utils'; -// import { transformArguments } from './COMMAND'; -// import { assertPingCommand } from './COMMAND_INFO.spec'; +import { strict as assert } from 'node:assert'; +import testUtils, { GLOBAL } from '../test-utils'; +import { parseArgs, transformCommandReply, CommandFlags, CommandCategories, CommandRawReply } from './generic-transformers'; +import COMMAND from './COMMAND'; -// describe('COMMAND', () => { -// it('transformArguments', () => { -// assert.deepEqual( -// transformArguments(), -// ['COMMAND'] -// ); -// }); +describe('COMMAND', () => { + it('transformArguments', () => { + assert.deepEqual( + parseArgs(COMMAND), + ['COMMAND'] + ); + }); -// testUtils.testWithClient('client.command', async client => { -// assertPingCommand((await client.command()).find(command => command.name === 'ping')); -// }, GLOBAL.SERVERS.OPEN); -// }); + describe('transformCommandReply', () => { + const testCases = [ + { + name: 'without policies', + input: ['ping', -1, [CommandFlags.STALE], 0, 0, 0, [CommandCategories.FAST], [], []] satisfies CommandRawReply, + expected: { + name: 'ping', + arity: -1, + flags: new Set([CommandFlags.STALE]), + firstKeyIndex: 0, + lastKeyIndex: 0, + step: 0, + categories: new Set([CommandCategories.FAST]), + policies: { request: undefined, response: undefined }, + isKeyless: true + } + }, + { + name: 'with valid policies', + input: ['dbsize', 1, [], 0, 0, 0, [], ['request_policy:all_shards', 'response_policy:agg_sum'], []] satisfies CommandRawReply, + expected: { + name: 'dbsize', + arity: 1, + flags: new Set([]), + firstKeyIndex: 0, + lastKeyIndex: 0, + step: 0, + categories: new Set([]), + policies: { request: 'all_shards', response: 'agg_sum' }, + isKeyless: true + } + }, + { + name: 'with invalid policies', + input: ['test', 0, [], 0, 0, 0, [], ['request_policy:invalid', 'response_policy:invalid'], ['some key specification']] satisfies CommandRawReply, + expected: { + name: 'test', + arity: 0, + flags: new Set([]), + firstKeyIndex: 0, + lastKeyIndex: 0, + step: 0, + categories: new Set([]), + policies: { request: undefined, response: undefined }, + isKeyless: false + } + }, + { + name: 'with request policy only', + input: ['test', 0, [], 0, 0, 0, [], ['request_policy:all_nodes'], ['some key specification']] satisfies CommandRawReply, + expected: { + name: 'test', + arity: 0, + flags: new Set([]), + firstKeyIndex: 0, + lastKeyIndex: 0, + step: 0, + categories: new Set([]), + policies: { request: 'all_nodes', response: undefined }, + isKeyless: false + } + }, + { + name: 'with response policy only', + input: ['test', 0, [], 0, 0, 0, [], ['', 'response_policy:agg_max'], []] satisfies CommandRawReply, + expected: { + name: 'test', + arity: 0, + flags: new Set([]), + firstKeyIndex: 0, + lastKeyIndex: 0, + step: 0, + categories: new Set([]), + policies: { request: undefined, response: 'agg_max' }, + isKeyless: true + } + }, + { + name: 'with response policy only', + input: ['test', 0, [], 0, 0, 0, [], ['', 'response_policy:agg_max'], []] satisfies CommandRawReply, + expected: { + name: 'test', + arity: 0, + flags: new Set([]), + firstKeyIndex: 0, + lastKeyIndex: 0, + step: 0, + categories: new Set([]), + policies: { request: undefined, response: 'agg_max' }, + isKeyless: true + } + } + ]; + + testCases.forEach(testCase => { + it(testCase.name, () => { + assert.deepEqual( + transformCommandReply(testCase.input), + testCase.expected + ); + }); + }); + }); + + testUtils.testWithClient('client.command', async client => { + const result = ((await client.command()).find(command => command.name === 'dbsize')); + assert.equal(result?.name, 'dbsize'); + assert.equal(result?.arity, 1); + assert.equal(result?.policies?.request, 'all_shards'); + assert.equal(result?.policies?.response, 'agg_sum'); + }, GLOBAL.SERVERS.OPEN); +}); \ No newline at end of file diff --git a/packages/client/lib/commands/generic-transformers.ts b/packages/client/lib/commands/generic-transformers.ts index b0f217d7ffb..9d3804de952 100644 --- a/packages/client/lib/commands/generic-transformers.ts +++ b/packages/client/lib/commands/generic-transformers.ts @@ -1,4 +1,5 @@ import { BasicCommandParser, CommandParser } from '../client/parser'; +import { REQUEST_POLICIES_WITH_DEFAULTS, RequestPolicyWithDefaults, RESPONSE_POLICIES_WITH_DEFAULTS, ResponsePolicyWithDefaults } from '../cluster/request-response-policies'; import { RESP_TYPES } from '../RESP/decoder'; import { UnwrapReply, ArrayReply, BlobStringReply, BooleanReply, CommandArguments, DoubleReply, NullReply, NumberReply, RedisArgument, ReplyUnion, TuplesReply, MapReply, TypeMapping, Command } from '../RESP/types'; @@ -344,9 +345,10 @@ export type CommandRawReply = [ step: number, categories: Array, tips: Array, - keySpecifications: string + keySpecifications: Array ]; + export type CommandReply = { name: string, arity: number, @@ -355,13 +357,25 @@ export type CommandReply = { lastKeyIndex: number, step: number, categories: Set, - keySpecifications: string + policies: { request: RequestPolicyWithDefaults | undefined, response: ResponsePolicyWithDefaults | undefined } + isKeyless: boolean }; export function transformCommandReply( this: void, - [name, arity, flags, firstKeyIndex, lastKeyIndex, step, categories, _tips, keySpecifications]: CommandRawReply + [name, arity, flags, firstKeyIndex, lastKeyIndex, step, categories, tips, keySpecifications]: CommandRawReply ): CommandReply { + + const requestPolicyRaw = tips[0]?.replace('request_policy:', ''); + const requestPolicy = requestPolicyRaw && Object.values(REQUEST_POLICIES_WITH_DEFAULTS).includes(requestPolicyRaw as RequestPolicyWithDefaults) + ? requestPolicyRaw as RequestPolicyWithDefaults + : undefined; + + const responsePolicyRaw = tips[1]?.replace('response_policy:', ''); + const responsePolicy = responsePolicyRaw && Object.values(RESPONSE_POLICIES_WITH_DEFAULTS).includes(responsePolicyRaw as ResponsePolicyWithDefaults) + ? responsePolicyRaw as ResponsePolicyWithDefaults + : undefined; + return { name, arity, @@ -370,7 +384,11 @@ export function transformCommandReply( lastKeyIndex, step, categories: new Set(categories), - keySpecifications + policies: { + request: requestPolicy, + response: responsePolicy + }, + isKeyless: keySpecifications.length === 0 }; } From da87a626343e9333dfbfafb6d96d86f50cb5acb7 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Wed, 11 Jun 2025 12:15:31 +0300 Subject: [PATCH 06/28] use response policies [skip ci] --- .../dynamic-policy-resolver-factory.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver-factory.ts b/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver-factory.ts index 03d26c98598..993d97d9a7e 100644 --- a/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver-factory.ts +++ b/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver-factory.ts @@ -100,10 +100,8 @@ export class DynamicPolicyResolverFactory { : RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED; return { - // request: command.policies.request ?? defaultRequest, - // response: command.policies.response ?? defaultResponse - request: defaultRequest, - response: defaultResponse + request: command.policies.request ?? defaultRequest, + response: command.policies.response ?? defaultResponse }; } } \ No newline at end of file From 0ac2a53bcfc6c23f1efb76713f665828df3253ad Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Wed, 11 Jun 2025 14:23:44 +0300 Subject: [PATCH 07/28] parse subcommands, update static policies actually fetched from server using the dynamic resolver --- packages/client/lib/cluster/index.ts | 2 + .../dynamic-policy-resolver-factory.ts | 25 +- .../request-response-policies/index.ts | 3 +- .../policies-constants.ts | 2 + .../static-policies-data.ts | 2914 ++++++++++++++++- .../request-response-policies/test.spec.ts | 13 - .../request-response-policies/types.ts | 5 + .../lib/commands/generic-transformers.ts | 14 +- 8 files changed, 2901 insertions(+), 77 deletions(-) diff --git a/packages/client/lib/cluster/index.ts b/packages/client/lib/cluster/index.ts index 31490c0b2dd..e731a16341b 100644 --- a/packages/client/lib/cluster/index.ts +++ b/packages/client/lib/cluster/index.ts @@ -477,8 +477,10 @@ export default class RedisCluster< commandName: string, fn: (client: RedisClientType, opts?: ClusterCommandOptions) => Promise ): Promise { + const maxCommandRedirections = this._options.maxCommandRedirections ?? 16; const policyResult = this._policyResolver.resolvePolicy(commandName) + if(policyResult.ok) { //TODO } else { diff --git a/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver-factory.ts b/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver-factory.ts index 993d97d9a7e..750ee491f8a 100644 --- a/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver-factory.ts +++ b/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver-factory.ts @@ -1,9 +1,8 @@ import type { CommandReply } from '../../commands/generic-transformers'; import type { CommandPolicies } from './policies-constants'; import { REQUEST_POLICIES_WITH_DEFAULTS, RESPONSE_POLICIES_WITH_DEFAULTS } from './policies-constants'; -import type { PolicyResolver } from './types'; +import type { PolicyResolver, ModulePolicyRecords } from './types'; import { StaticPolicyResolver } from './static-policy-resolver'; -import type { ModulePolicyRecords } from './static-policies-data'; /** * Function type that returns command information from Redis @@ -28,7 +27,7 @@ export class DynamicPolicyResolverFactory { static async create( commandFetcher: CommandFetcher, fallbackResolver?: PolicyResolver - ): Promise { + ): Promise { const commands = await commandFetcher(); const policies: ModulePolicyRecords = {}; @@ -98,10 +97,28 @@ export class DynamicPolicyResolverFactory { const defaultResponse = isKeyless ? RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS : RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED; + + let subcommands: Record | undefined; + if(command.subcommands.length > 0) { + subcommands = {}; + for (const subcommand of command.subcommands) { + + // Subcommands are in format "parentCommand|subcommand" + const parts = subcommand.name.split("\|") + if(parts.length !== 2) { + throw new Error(`Invalid subcommand name: ${subcommand.name}`); + } + const subcommandName = parts[1]; + + subcommands[subcommandName] = DynamicPolicyResolverFactory.#buildCommandPolicies(subcommand); + } + } return { request: command.policies.request ?? defaultRequest, - response: command.policies.response ?? defaultResponse + response: command.policies.response ?? defaultResponse, + isKeyless, + subcommands }; } } \ No newline at end of file diff --git a/packages/client/lib/cluster/request-response-policies/index.ts b/packages/client/lib/cluster/request-response-policies/index.ts index 24a1761451a..e4b2410ba89 100644 --- a/packages/client/lib/cluster/request-response-policies/index.ts +++ b/packages/client/lib/cluster/request-response-policies/index.ts @@ -1,10 +1,9 @@ -export type { Either, PolicyResult, PolicyResolver } from './types'; +export type { Either, PolicyResult, PolicyResolver, ModulePolicyRecords, CommandPolicyRecords } from './types'; export { StaticPolicyResolver } from './static-policy-resolver'; export { DynamicPolicyResolverFactory, type CommandFetcher } from './dynamic-policy-resolver-factory'; export * from './policies-constants'; -export type { ModulePolicyRecords, CommandPolicyRecords } from './static-policies-data'; export { POLICIES } from './static-policies-data'; // export { type CommandRouter } from './command-router'; \ No newline at end of file diff --git a/packages/client/lib/cluster/request-response-policies/policies-constants.ts b/packages/client/lib/cluster/request-response-policies/policies-constants.ts index d27c550d755..4045d9955fc 100644 --- a/packages/client/lib/cluster/request-response-policies/policies-constants.ts +++ b/packages/client/lib/cluster/request-response-policies/policies-constants.ts @@ -27,4 +27,6 @@ export type ResponsePolicyWithDefaults = typeof RESPONSE_POLICIES_WITH_DEFAULTS[ export interface CommandPolicies { readonly request: RequestPolicyWithDefaults; readonly response: ResponsePolicyWithDefaults; + readonly subcommands?: Record; + readonly isKeyless: boolean; } \ No newline at end of file diff --git a/packages/client/lib/cluster/request-response-policies/static-policies-data.ts b/packages/client/lib/cluster/request-response-policies/static-policies-data.ts index 68c44c3129d..327541fcd6c 100644 --- a/packages/client/lib/cluster/request-response-policies/static-policies-data.ts +++ b/packages/client/lib/cluster/request-response-policies/static-policies-data.ts @@ -1,59 +1,2865 @@ -import { CommandPolicies, REQUEST_POLICIES_WITH_DEFAULTS, RESPONSE_POLICIES_WITH_DEFAULTS } from './policies-constants'; - -export type CommandPolicyRecords = Record; -// The response of the COMMAND command uses "." to separate the module name from the command name. -// For example, "ft.search" refers to the "search" command in the "ft" module. It is important to use the same naming convention here. -export type ModulePolicyRecords = Record; +import { ModulePolicyRecords } from "./types"; export const POLICIES: ModulePolicyRecords = { - ft: { - create: { - request: REQUEST_POLICIES_WITH_DEFAULTS.ALL_NODES, - response: RESPONSE_POLICIES_WITH_DEFAULTS.ALL_SUCCEEDED - }, - search: { - request: REQUEST_POLICIES_WITH_DEFAULTS.ALL_SHARDS, - response: RESPONSE_POLICIES_WITH_DEFAULTS.SPECIAL - }, - aggregate: { - request: REQUEST_POLICIES_WITH_DEFAULTS.ALL_SHARDS, - response: RESPONSE_POLICIES_WITH_DEFAULTS.SPECIAL - }, - sugadd: { - request: REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED, - response: RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED - }, - sugget: { - request: REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED, - response: RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED - }, - sugdel: { - request: REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED, - response: RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED - }, - suglen: { - request: REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED, - response: RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED - }, - spellcheck: { - request: REQUEST_POLICIES_WITH_DEFAULTS.ALL_SHARDS, - response: RESPONSE_POLICIES_WITH_DEFAULTS.SPECIAL - }, - cursor: { - request: REQUEST_POLICIES_WITH_DEFAULTS.SPECIAL, - response: RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS - }, - dictadd: { - request: REQUEST_POLICIES_WITH_DEFAULTS.ALL_SHARDS, - response: RESPONSE_POLICIES_WITH_DEFAULTS.ALL_SUCCEEDED - }, - dictdel: { - request: REQUEST_POLICIES_WITH_DEFAULTS.ALL_SHARDS, - response: RESPONSE_POLICIES_WITH_DEFAULTS.ALL_SUCCEEDED - }, - dictdump: { - request: REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS, - response: RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS + "std": { + "getrange": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "incr": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "zlexcount": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hincrbyfloat": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "zinterstore": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "zpopmax": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "zdiff": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "waitaof": { + "request": "all_shards", + "response": "agg_min", + "isKeyless": true + }, + "psubscribe": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "geodist": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hdel": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "type": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "flushdb": { + "request": "all_shards", + "response": "all_succeeded", + "isKeyless": true + }, + "lpos": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "xreadgroup": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "pttl": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "sdiff": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hkeys": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "eval": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "substr": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "zremrangebyrank": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "zcount": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "memory": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "subcommands": { + "purge": { + "request": "all_shards", + "response": "all_succeeded", + "isKeyless": true + }, + "doctor": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "stats": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "help": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "malloc-stats": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "usage": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + } + } + }, + "hgetdel": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hpersist": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "persist": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "llen": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "info": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "failover": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "hello": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "exec": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "hpexpiretime": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "acl": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "subcommands": { + "deluser": { + "request": "all_nodes", + "response": "all_succeeded", + "isKeyless": true + }, + "genpass": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "dryrun": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "save": { + "request": "all_nodes", + "response": "all_succeeded", + "isKeyless": true + }, + "cat": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "help": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "users": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "whoami": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "list": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "load": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "log": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "setuser": { + "request": "all_nodes", + "response": "all_succeeded", + "isKeyless": true + }, + "getuser": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + } + } + }, + "sort": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "latency": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "subcommands": { + "history": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "reset": { + "request": "all_nodes", + "response": "agg_sum", + "isKeyless": true + }, + "doctor": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "histogram": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "latest": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "help": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "graph": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + } + } + }, + "zincrby": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "sync": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "rpushx": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "xtrim": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "auth": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "echo": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "georadiusbymember": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "zcard": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "setnx": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hsetex": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "restore": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "geoadd": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "subscribe": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "getex": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "zremrangebyscore": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hmset": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "zremrangebylex": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "watch": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "fcall": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "lset": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hpttl": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "zintercard": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "sort_ro": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "zrandmember": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "discard": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "zpopmin": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "scard": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hrandfield": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hstrlen": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "xinfo": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "subcommands": { + "groups": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "consumers": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "stream": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "help": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + } + } + }, + "flushall": { + "request": "all_shards", + "response": "all_succeeded", + "isKeyless": true + }, + "linsert": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "geopos": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "pexpiretime": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "sdiffstore": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "ping": { + "request": "all_shards", + "response": "all_succeeded", + "isKeyless": true + }, + "zscan": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hget": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "zunionstore": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "ssubscribe": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "zrevrange": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "slaveof": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "bitcount": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "evalsha_ro": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "lpushx": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "sinterstore": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "touch": { + "request": "multi_shard", + "response": "agg_sum", + "isKeyless": false + }, + "bgsave": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "pfcount": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "zdiffstore": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "pubsub": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "subcommands": { + "shardnumsub": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "numpat": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "numsub": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "channels": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "help": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "shardchannels": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + } + } + }, + "lindex": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "georadiusbymember_ro": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "geohash": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "xgroup": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "subcommands": { + "setid": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "create": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "destroy": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "help": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "delconsumer": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "createconsumer": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + } + } + }, + "xadd": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "xrange": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "zrange": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "sscan": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "randomkey": { + "request": "all_shards", + "response": "special", + "isKeyless": true + }, + "bzpopmax": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "bitfield_ro": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "ttl": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hsetnx": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "rename": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "shutdown": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "strlen": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hpexpireat": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "slowlog": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "subcommands": { + "get": { + "request": "all_nodes", + "response": "default-keyless", + "isKeyless": true + }, + "reset": { + "request": "all_nodes", + "response": "all_succeeded", + "isKeyless": true + }, + "len": { + "request": "all_nodes", + "response": "agg_sum", + "isKeyless": true + }, + "help": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + } + } + }, + "setex": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "xack": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "client": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "subcommands": { + "caching": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "setinfo": { + "request": "all_nodes", + "response": "all_succeeded", + "isKeyless": true + }, + "setname": { + "request": "all_nodes", + "response": "all_succeeded", + "isKeyless": true + }, + "list": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "kill": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "no-evict": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "reply": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "tracking": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "unblock": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "trackinginfo": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "unpause": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "info": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "id": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "getredir": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "help": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "pause": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "getname": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "no-touch": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + } + } + }, + "unsubscribe": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "pexpireat": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hgetall": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hlen": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "multi": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "zrevrangebyscore": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "psetex": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "xsetid": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "decr": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "rpop": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "xautoclaim": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "zadd": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "zrangestore": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "get": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "blpop": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "replconf": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "keys": { + "request": "all_shards", + "response": "default-keyless", + "isKeyless": true + }, + "command": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "subcommands": { + "list": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "getkeysandflags": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "info": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "count": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "getkeys": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "help": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "docs": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + } + } + }, + "exists": { + "request": "multi_shard", + "response": "agg_sum", + "isKeyless": false + }, + "sismember": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "function": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "subcommands": { + "dump": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "delete": { + "request": "all_shards", + "response": "all_succeeded", + "isKeyless": true + }, + "stats": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "help": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "restore": { + "request": "all_shards", + "response": "all_succeeded", + "isKeyless": true + }, + "list": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "kill": { + "request": "all_shards", + "response": "one_succeeded", + "isKeyless": true + }, + "load": { + "request": "all_shards", + "response": "all_succeeded", + "isKeyless": true + }, + "flush": { + "request": "all_shards", + "response": "all_succeeded", + "isKeyless": true + } + } + }, + "xread": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "rpush": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "append": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "lpop": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "set": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "move": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "expireat": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "pexpire": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "brpoplpush": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "del": { + "request": "multi_shard", + "response": "agg_sum", + "isKeyless": false + }, + "lmpop": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "setrange": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "sunsubscribe": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "migrate": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "scan": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "lcs": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "quit": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "cluster": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "subcommands": { + "addslotsrange": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "delslots": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "setslot": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "slots": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "links": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "delslotsrange": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "addslots": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "keyslot": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "meet": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "countkeysinslot": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "count-failure-reports": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "shards": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "myshardid": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "myid": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "reset": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "flushslots": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "slaves": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "info": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "replicate": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "nodes": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "failover": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "help": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "saveconfig": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "getkeysinslot": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "set-config-epoch": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "bumpepoch": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "replicas": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "forget": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + } + } + }, + "spop": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "lrange": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "xpending": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "sunionstore": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "select": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "sintercard": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "srandmember": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "bzmpop": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "pfadd": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "msetnx": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "expiretime": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "script": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "subcommands": { + "load": { + "request": "all_nodes", + "response": "all_succeeded", + "isKeyless": true + }, + "kill": { + "request": "all_shards", + "response": "one_succeeded", + "isKeyless": true + }, + "exists": { + "request": "all_shards", + "response": "agg_logical_and", + "isKeyless": true + }, + "flush": { + "request": "all_nodes", + "response": "all_succeeded", + "isKeyless": true + }, + "debug": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "help": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + } + } + }, + "zrem": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "save": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "smove": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "spublish": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "fcall_ro": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "lrem": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "blmove": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "lolwut": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "bzpopmin": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hexpire": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "ltrim": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "asking": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "zrevrangebylex": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "restore-asking": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "setbit": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "smembers": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "xlen": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "expire": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hexpireat": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "srem": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "httl": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "lastsave": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "hmget": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hexists": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "module": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "subcommands": { + "list": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "unload": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "load": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "help": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "loadex": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + } + } + }, + "sadd": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "monitor": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "geosearch": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "copy": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "lmove": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "publish": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "zscore": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "bgrewriteaof": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "zunion": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hpexpire": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "config": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "subcommands": { + "set": { + "request": "all_nodes", + "response": "all_succeeded", + "isKeyless": true + }, + "resetstat": { + "request": "all_nodes", + "response": "all_succeeded", + "isKeyless": true + }, + "get": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "help": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "rewrite": { + "request": "all_nodes", + "response": "all_succeeded", + "isKeyless": true + } + } + }, + "punsubscribe": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "zrangebylex": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "reset": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "xclaim": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "geosearchstore": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "sinter": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "pfdebug": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hscan": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "georadius_ro": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "unwatch": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "unlink": { + "request": "multi_shard", + "response": "agg_sum", + "isKeyless": false + }, + "renamenx": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "brpop": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "zrevrank": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "incrby": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hset": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "object": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "subcommands": { + "encoding": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "refcount": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "idletime": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "freq": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "help": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + } + } + }, + "time": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "zrangebyscore": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "rpoplpush": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hincrby": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "zinter": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "role": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "zrank": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "pfselftest": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "hexpiretime": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "incrbyfloat": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "zmscore": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "zmpop": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "smismember": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "xrevrange": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "bitpos": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hgetex": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "readonly": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "readwrite": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "pfmerge": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "dbsize": { + "request": "all_shards", + "response": "agg_sum", + "isKeyless": true + }, + "dump": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "mget": { + "request": "multi_shard", + "response": "default-keyed", + "isKeyless": false + }, + "mset": { + "request": "multi_shard", + "response": "all_succeeded", + "isKeyless": false + }, + "wait": { + "request": "all_shards", + "response": "agg_min", + "isKeyless": true + }, + "xdel": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "evalsha": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "bitop": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "psync": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "getbit": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "georadius": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "getdel": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "swapdb": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "debug": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "hvals": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "lpush": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "replicaof": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "eval_ro": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "getset": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "decrby": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "bitfield": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "blmpop": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "sunion": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + } + }, + "FT": { + "ALIASADD": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "ALIASUPDATE": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "SPELLCHECK": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "DICTADD": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "_DROPIFX": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "DROP": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "EXPLAINCLI": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "SUGGET": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "SYNADD": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "TAGVALS": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "EXPLAIN": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "ALTER": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "CURSOR": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "_LIST": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "_CREATEIFNX": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "DICTDEL": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "ADD": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "ALIASDEL": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "SEARCH": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "SYNDUMP": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "SUGDEL": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "SUGADD": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "_DROPINDEXIFX": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "SYNUPDATE": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "MGET": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "GET": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "AGGREGATE": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "SUGLEN": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "DEL": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "_ALIASDELIFX": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "_ALIASADDIFNX": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "DROPINDEX": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "_ALTERIFNX": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "PROFILE": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "CREATE": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "INFO": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "DICTDUMP": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + } + }, + "json": { + "strlen": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "mget": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "set": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "clear": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "arrpop": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "arrinsert": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "objkeys": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "type": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "debug": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "strappend": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "get": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "arrtrim": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "del": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "mset": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "numincrby": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "forget": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "arrlen": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "arrindex": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "nummultby": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "objlen": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "numpowby": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "arrappend": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "merge": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "toggle": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "resp": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + } + }, + "cms": { + "initbyprob": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "query": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "merge": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "info": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "incrby": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "initbydim": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + } + }, + "bf": { + "loadchunk": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "debug": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "add": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "madd": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "mexists": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "insert": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "exists": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "card": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "info": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "reserve": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "scandump": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + } + }, + "ts": { + "mrevrange": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "info": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "mrange": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "alter": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "revrange": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "madd": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "createrule": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "del": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "mget": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "get": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "create": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "add": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "range": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "queryindex": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "deleterule": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "decrby": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "incrby": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + } + }, + "tdigest": { + "max": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "byrevrank": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "info": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "byrank": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "create": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "reset": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "merge": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "trimmed_mean": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "add": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "revrank": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "min": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "cdf": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "rank": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "quantile": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + } + }, + "cf": { + "count": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "debug": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "exists": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "compact": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "loadchunk": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "insertnx": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "addnx": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "insert": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "reserve": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "scandump": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "info": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "mexists": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "add": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "del": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + } + }, + "topk": { + "list": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "reserve": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "info": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "incrby": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "query": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "count": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "add": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + } + }, + "search": { + "CLUSTERSET": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "CLUSTERINFO": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "CLUSTERREFRESH": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + } + }, + "_FT": { + "CONFIG": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "SAFEADD": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "DEBUG": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "subcommands": { + "INFO_TAGIDX": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "VECSIM_INFO": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "SPEC_INVIDXES_INFO": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "DUMP_HNSW": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "TTL_PAUSE": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "GC_FORCEBGINVOKE": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "SHARD_CONNECTION_STATES": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "GC_STOP_SCHEDULE": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "DUMP_TAGIDX": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "SET_MONITOR_EXPIRATION": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "DUMP_TERMS": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "GC_CONTINUE_SCHEDULE": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "DUMP_NUMIDX": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "GC_CLEAN_NUMERIC": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "HELP": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "FT.AGGREGATE": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "TTL": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "DUMP_SUFFIX_TRIE": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "DOCINFO": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "_FT.AGGREGATE": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "WORKERS": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "RESUME_TOPOLOGY_UPDATER": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "DUMP_NUMIDXTREE": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "DUMP_INVIDX": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "CLEAR_PENDING_TOPOLOGY": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "_FT.SEARCH": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "BG_SCAN_CONTROLLER": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "PAUSE_TOPOLOGY_UPDATER": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "GIT_SHA": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "IDTODOCID": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "INVIDX_SUMMARY": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "DUMP_GEOMIDX": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "GC_FORCEINVOKE": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "TTL_EXPIRE": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "FT.SEARCH": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "DUMP_PHONETIC_HASH": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "DOCIDTOID": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "DUMP_PREFIX_TRIE": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "DELETE_LOCAL_CURSORS": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "GC_WAIT_FOR_JOBS": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "NUMIDX_SUMMARY": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + } + } + } + }, + "timeseries": { + "REFRESHCLUSTER": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true } } } as const; diff --git a/packages/client/lib/cluster/request-response-policies/test.spec.ts b/packages/client/lib/cluster/request-response-policies/test.spec.ts index 2e3f15c389a..739d892ecbe 100644 --- a/packages/client/lib/cluster/request-response-policies/test.spec.ts +++ b/packages/client/lib/cluster/request-response-policies/test.spec.ts @@ -6,23 +6,10 @@ import RedisJSON from '@redis/json'; import RedisTimeSeries from '@redis/time-series'; describe('Cluster Request-Response Policies', () => { - testUtils.testWithClient('should resolve policies correctly', async client => { - await client.ft.SUGADD('index', 'string', 1); - await client.ft.dictAdd('index', 'foo'); - }, { - ...GLOBAL.SERVERS.OPEN, - clientOptions: { - modules: { - ft: RediSearch - } - } - }); testUtils.testWithCluster('should resolve policies correctly', async cluster => { await cluster.ft.SUGADD('index', 'string', 1); - await cluster.ft.DICTADD('index', 'foo'); - await cluster.sendCommand(undefined, true, ['ft.dictadd', 'index', 'string']); }, { ...GLOBAL.CLUSTERS.OPEN, diff --git a/packages/client/lib/cluster/request-response-policies/types.ts b/packages/client/lib/cluster/request-response-policies/types.ts index 27d2cae8a7b..187c0348c22 100644 --- a/packages/client/lib/cluster/request-response-policies/types.ts +++ b/packages/client/lib/cluster/request-response-policies/types.ts @@ -20,3 +20,8 @@ export interface PolicyResolver { */ withFallback(fallbackResolver: PolicyResolver): PolicyResolver; } + +export type CommandPolicyRecords = Record; +// The response of the COMMAND command uses "." to separate the module name from the command name. +// For example, "ft.search" refers to the "search" command in the "ft" module. It is important to use the same naming convention here. +export type ModulePolicyRecords = Record; diff --git a/packages/client/lib/commands/generic-transformers.ts b/packages/client/lib/commands/generic-transformers.ts index 9d3804de952..35ef69eb867 100644 --- a/packages/client/lib/commands/generic-transformers.ts +++ b/packages/client/lib/commands/generic-transformers.ts @@ -345,7 +345,8 @@ export type CommandRawReply = [ step: number, categories: Array, tips: Array, - keySpecifications: Array + keySpecifications: Array, + subcommands: Array ]; @@ -358,14 +359,16 @@ export type CommandReply = { step: number, categories: Set, policies: { request: RequestPolicyWithDefaults | undefined, response: ResponsePolicyWithDefaults | undefined } - isKeyless: boolean + isKeyless: boolean, + subcommands: Array }; export function transformCommandReply( this: void, - [name, arity, flags, firstKeyIndex, lastKeyIndex, step, categories, tips, keySpecifications]: CommandRawReply + [name, arity, flags, firstKeyIndex, lastKeyIndex, step, categories, tips, keySpecifications, subcommandsReply]: CommandRawReply ): CommandReply { + const requestPolicyRaw = tips[0]?.replace('request_policy:', ''); const requestPolicy = requestPolicyRaw && Object.values(REQUEST_POLICIES_WITH_DEFAULTS).includes(requestPolicyRaw as RequestPolicyWithDefaults) ? requestPolicyRaw as RequestPolicyWithDefaults @@ -376,6 +379,8 @@ export function transformCommandReply( ? responsePolicyRaw as ResponsePolicyWithDefaults : undefined; + const subcommands = subcommandsReply.map(transformCommandReply); + return { name, arity, @@ -388,7 +393,8 @@ export function transformCommandReply( request: requestPolicy, response: responsePolicy }, - isKeyless: keySpecifications.length === 0 + isKeyless: keySpecifications.length === 0, + subcommands }; } From 22b001507b9ea37173b7a6b63ef775e14e135563 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Wed, 11 Jun 2025 16:25:37 +0300 Subject: [PATCH 08/28] fix tests --- .../dynamic-policy-resolver.spec.ts | 27 +++++++++++------ packages/client/lib/commands/COMMAND.spec.ts | 30 +++++++++++-------- 2 files changed, 36 insertions(+), 21 deletions(-) diff --git a/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver.spec.ts b/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver.spec.ts index f1a4feb16b2..6f05b62111b 100644 --- a/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver.spec.ts +++ b/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver.spec.ts @@ -21,7 +21,8 @@ describe('DynamicPolicyResolverFactory', () => { std: { ping: { request: REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS, - response: RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS + response: RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS, + isKeyless: true } } }); @@ -50,7 +51,8 @@ describe('DynamicPolicyResolverFactory', () => { step: 0, categories: new Set(), policies: { request: undefined, response: undefined }, - isKeyless: true + isKeyless: true, + subcommands: [] } ]; @@ -76,7 +78,8 @@ describe('DynamicPolicyResolverFactory', () => { step: 1, categories: new Set(), policies: { request: undefined, response: undefined }, - isKeyless: false + isKeyless: false, + subcommands: [] } ]; @@ -102,7 +105,8 @@ describe('DynamicPolicyResolverFactory', () => { step: 0, categories: new Set(), policies: { request: 'all_shards', response: 'agg_sum' }, - isKeyless: true + isKeyless: true, + subcommands: [] } ]; @@ -128,7 +132,8 @@ describe('DynamicPolicyResolverFactory', () => { step: 1, categories: new Set(), policies: { request: 'all_shards', response: 'special' }, - isKeyless: false + isKeyless: false, + subcommands: [] } ]; @@ -154,7 +159,8 @@ describe('DynamicPolicyResolverFactory', () => { step: 0, categories: new Set(), policies: { request: undefined, response: undefined }, - isKeyless: true + isKeyless: true, + subcommands: [] } ]; @@ -182,7 +188,8 @@ describe('DynamicPolicyResolverFactory', () => { step: 0, categories: new Set(), policies: { request: undefined, response: undefined }, - isKeyless: true + isKeyless: true, + subcommands: [] } ]; @@ -233,7 +240,8 @@ describe('DynamicPolicyResolverFactory', () => { step: 0, categories: new Set(), policies: { request: 'all_nodes', response: undefined }, - isKeyless: false + isKeyless: false, + subcommands: [] }, { name: 'partial-response', @@ -244,7 +252,8 @@ describe('DynamicPolicyResolverFactory', () => { step: 0, categories: new Set(), policies: { request: undefined, response: 'agg_sum' }, - isKeyless: true + isKeyless: true, + subcommands: [] } ]; diff --git a/packages/client/lib/commands/COMMAND.spec.ts b/packages/client/lib/commands/COMMAND.spec.ts index 8a3900d9d66..7ace4de3c70 100644 --- a/packages/client/lib/commands/COMMAND.spec.ts +++ b/packages/client/lib/commands/COMMAND.spec.ts @@ -15,7 +15,7 @@ describe('COMMAND', () => { const testCases = [ { name: 'without policies', - input: ['ping', -1, [CommandFlags.STALE], 0, 0, 0, [CommandCategories.FAST], [], []] satisfies CommandRawReply, + input: ['ping', -1, [CommandFlags.STALE], 0, 0, 0, [CommandCategories.FAST], [], [], []] satisfies CommandRawReply, expected: { name: 'ping', arity: -1, @@ -25,12 +25,13 @@ describe('COMMAND', () => { step: 0, categories: new Set([CommandCategories.FAST]), policies: { request: undefined, response: undefined }, - isKeyless: true + isKeyless: true, + subcommands: [] } }, { name: 'with valid policies', - input: ['dbsize', 1, [], 0, 0, 0, [], ['request_policy:all_shards', 'response_policy:agg_sum'], []] satisfies CommandRawReply, + input: ['dbsize', 1, [], 0, 0, 0, [], ['request_policy:all_shards', 'response_policy:agg_sum'], [], []] satisfies CommandRawReply, expected: { name: 'dbsize', arity: 1, @@ -40,12 +41,13 @@ describe('COMMAND', () => { step: 0, categories: new Set([]), policies: { request: 'all_shards', response: 'agg_sum' }, - isKeyless: true + isKeyless: true, + subcommands: [] } }, { name: 'with invalid policies', - input: ['test', 0, [], 0, 0, 0, [], ['request_policy:invalid', 'response_policy:invalid'], ['some key specification']] satisfies CommandRawReply, + input: ['test', 0, [], 0, 0, 0, [], ['request_policy:invalid', 'response_policy:invalid'], ['some key specification'], []] satisfies CommandRawReply, expected: { name: 'test', arity: 0, @@ -55,12 +57,13 @@ describe('COMMAND', () => { step: 0, categories: new Set([]), policies: { request: undefined, response: undefined }, - isKeyless: false + isKeyless: false, + subcommands: [] } }, { name: 'with request policy only', - input: ['test', 0, [], 0, 0, 0, [], ['request_policy:all_nodes'], ['some key specification']] satisfies CommandRawReply, + input: ['test', 0, [], 0, 0, 0, [], ['request_policy:all_nodes'], ['some key specification'], []] satisfies CommandRawReply, expected: { name: 'test', arity: 0, @@ -70,12 +73,13 @@ describe('COMMAND', () => { step: 0, categories: new Set([]), policies: { request: 'all_nodes', response: undefined }, - isKeyless: false + isKeyless: false, + subcommands: [] } }, { name: 'with response policy only', - input: ['test', 0, [], 0, 0, 0, [], ['', 'response_policy:agg_max'], []] satisfies CommandRawReply, + input: ['test', 0, [], 0, 0, 0, [], ['', 'response_policy:agg_max'], [], []] satisfies CommandRawReply, expected: { name: 'test', arity: 0, @@ -85,12 +89,13 @@ describe('COMMAND', () => { step: 0, categories: new Set([]), policies: { request: undefined, response: 'agg_max' }, - isKeyless: true + isKeyless: true, + subcommands: [] } }, { name: 'with response policy only', - input: ['test', 0, [], 0, 0, 0, [], ['', 'response_policy:agg_max'], []] satisfies CommandRawReply, + input: ['test', 0, [], 0, 0, 0, [], ['', 'response_policy:agg_max'], [], []] satisfies CommandRawReply, expected: { name: 'test', arity: 0, @@ -100,7 +105,8 @@ describe('COMMAND', () => { step: 0, categories: new Set([]), policies: { request: undefined, response: 'agg_max' }, - isKeyless: true + isKeyless: true, + subcommands: [] } } ]; From 2bba4d19f5d00e5a15ace5ec3b9bac09d3d61da6 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Wed, 18 Jun 2025 10:38:12 +0300 Subject: [PATCH 09/28] resolve commands and subcommands --- packages/client/lib/client/parser.ts | 13 ++++++----- .../static-policy-resolver.ts | 23 +++++++++++++++---- .../request-response-policies/types.ts | 4 +++- 3 files changed, 28 insertions(+), 12 deletions(-) diff --git a/packages/client/lib/client/parser.ts b/packages/client/lib/client/parser.ts index 74ddaea5920..0da883db7cd 100644 --- a/packages/client/lib/client/parser.ts +++ b/packages/client/lib/client/parser.ts @@ -33,11 +33,14 @@ export function prefixKeys(keyPrefix: RedisArgument | undefined, keys: RedisVari : [prefixKey(keyPrefix, keys)]; } +export type CommandIdentifier = { command: string, subcommand: string }; + export interface CommandParser { redisArgs: ReadonlyArray; keys: ReadonlyArray; firstKey: RedisArgument | undefined; preserve: unknown; + commandIdentifier: CommandIdentifier; push: (...arg: Array) => unknown; pushVariadic: (vals: RedisVariadicArgument) => unknown; @@ -90,12 +93,10 @@ export class BasicCommandParser implements CommandParser { return tmp.join('_'); } - get commandName(): string | undefined { - let cmdName = this.#redisArgs[0]; - if (cmdName instanceof Buffer) { - return cmdName.toString(); - } - return cmdName; + get commandIdentifier(): CommandIdentifier { + const command = this.#redisArgs[0] instanceof Buffer ? this.#redisArgs[0].toString() : this.#redisArgs[0]; + const subcommand = this.#redisArgs[1] instanceof Buffer ? this.#redisArgs[1].toString() : this.#redisArgs[1]; + return { command, subcommand }; } push(...arg: Array) { diff --git a/packages/client/lib/cluster/request-response-policies/static-policy-resolver.ts b/packages/client/lib/cluster/request-response-policies/static-policy-resolver.ts index 9d7772d40f5..6847ef735d6 100644 --- a/packages/client/lib/cluster/request-response-policies/static-policy-resolver.ts +++ b/packages/client/lib/cluster/request-response-policies/static-policy-resolver.ts @@ -1,5 +1,6 @@ import type { PolicyResult, PolicyResolver } from './types'; import { POLICIES } from './static-policies-data'; +import { CommandIdentifier } from '../../client/parser'; export class StaticPolicyResolver implements PolicyResolver { private readonly fallbackResolver: PolicyResolver | null = null; @@ -21,8 +22,8 @@ export class StaticPolicyResolver implements PolicyResolver { return new StaticPolicyResolver(this.policies, fallbackResolver); } - resolvePolicy(command: string): PolicyResult { - const parts = command.toLowerCase().split('.'); + resolvePolicy(commandIdentifier: CommandIdentifier): PolicyResult { + const parts = commandIdentifier.command.toLowerCase().split('.'); if (parts.length > 2) { @@ -37,7 +38,7 @@ export class StaticPolicyResolver implements PolicyResolver { if (!this.policies[moduleName]) { if (this.fallbackResolver) { - return this.fallbackResolver.resolvePolicy(commandName); + return this.fallbackResolver.resolvePolicy(commandIdentifier); } // For std module commands, return 'unknown-command' instead of 'unknown-module' @@ -51,14 +52,26 @@ export class StaticPolicyResolver implements PolicyResolver { if (!this.policies[moduleName][commandName]) { // Try fallback resolver if available if (this.fallbackResolver) { - return this.fallbackResolver.resolvePolicy(commandName); + return this.fallbackResolver.resolvePolicy(commandIdentifier); } return { ok: false, error: 'unknown-command' }; } + const policy = this.policies[moduleName][commandName]; + + if(policy.subcommands) { + const subcommandPolicy = policy.subcommands[commandIdentifier.subcommand]; + if(subcommandPolicy) { + return { + ok: true, + value: subcommandPolicy + } + } + } + return { ok: true, - value: this.policies[moduleName][commandName] + value: policy } } } diff --git a/packages/client/lib/cluster/request-response-policies/types.ts b/packages/client/lib/cluster/request-response-policies/types.ts index 187c0348c22..027746710a6 100644 --- a/packages/client/lib/cluster/request-response-policies/types.ts +++ b/packages/client/lib/cluster/request-response-policies/types.ts @@ -1,3 +1,4 @@ +import { CommandIdentifier } from '../../client/parser'; import type { CommandPolicies } from './policies-constants'; export type Either = @@ -6,11 +7,12 @@ export type Either = export type PolicyResult = Either; + export interface PolicyResolver { /** * The response of the COMMAND command uses "." to separate the module name from the command name. */ - resolvePolicy(command: string): PolicyResult; + resolvePolicy(commandIdentifier: CommandIdentifier): PolicyResult; /** * Sets a fallback resolver to use when policies are not found in this resolver. From aff4b1f6e8716f7def72279a2fa8363a6eaa6948 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Wed, 18 Jun 2025 10:43:22 +0300 Subject: [PATCH 10/28] add comments to all policies --- .../policies-constants.ts | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/packages/client/lib/cluster/request-response-policies/policies-constants.ts b/packages/client/lib/cluster/request-response-policies/policies-constants.ts index 4045d9955fc..fff861c56af 100644 --- a/packages/client/lib/cluster/request-response-policies/policies-constants.ts +++ b/packages/client/lib/cluster/request-response-policies/policies-constants.ts @@ -1,24 +1,108 @@ export const REQUEST_POLICIES_WITH_DEFAULTS = { + /** + * The client should execute the command on all nodes - masters and replicas alike. + * This tip is in-use by commands that don't accept key name arguments. + * The command operates atomically per shard. + */ ALL_NODES: "all_nodes", + /** + * The client should execute the command on all master shards (e.g., the DBSIZE command). + * This tip is in-use by commands that don't accept key name arguments. + * The command operates atomically per shard. + */ ALL_SHARDS: "all_shards", + /** + * The client should execute the command on several shards. + * The client should split the inputs according to the hash slots of its input key name arguments. + * For example, the command DEL {foo} {foo}1 bar should be split to DEL {foo} {foo}1 and DEL bar. + * If the keys are hashed to more than a single slot, + * the command must be split even if all the slots are managed by the same shard. + * Examples for such commands include MSET, MGET and DEL. + * However, note that SUNIONSTORE isn't considered as multi_shard because all of its keys must belong to the same hash slot. + */ MULTI_SHARD: "multi_shard", + /** + * Indicates a non-trivial form of the client's request policy, such as the SCAN command. + */ SPECIAL: "special", + /** + * The default behavior a client should implement for commands without the request_policy tip is as follows: + * + * 1. The command doesn't accept key name arguments: + * the client can execute the command on an arbitrary shard. + */ DEFAULT_KEYLESS: "default-keyless", + /** + * The default behavior a client should implement for commands without the request_policy tip is as follows: + * + * 2. For commands that accept one or more key name arguments: + * the client should route the command to a single shard, + * as determined by the hash slot of the input keys. + */ DEFAULT_KEYED: "default-keyed" } as const; export type RequestPolicyWithDefaults = typeof REQUEST_POLICIES_WITH_DEFAULTS[keyof typeof REQUEST_POLICIES_WITH_DEFAULTS]; export const RESPONSE_POLICIES_WITH_DEFAULTS = { + /** + * The client should return success if at least one shard didn't reply with an error. + * The client should reply with the first non-error reply it obtains. + * If all shards return an error, the client can reply with any one of these. + * Example: SCRIPT KILL command that's sent to all shards. + */ ONE_SUCCEEDED: "one_succeeded", + /** + * The client should return successfully only if there are no error replies. + * Even a single error reply should disqualify the aggregate and be returned. + * Otherwise, the client should return one of the non-error replies. + * Examples: CONFIG SET, SCRIPT FLUSH and SCRIPT LOAD commands. + */ ALL_SUCCEEDED: "all_succeeded", + /** + * The client should return the result of a logical AND operation on all replies. + * Only applies to integer replies, usually from commands that return either 0 or 1. + * Example: SCRIPT EXISTS command returns 1 only when all shards report that a given script SHA1 sum is in their cache. + */ AGG_LOGICAL_AND: "agg_logical_and", + /** + * The client should return the result of a logical OR operation on all replies. + * Only applies to integer replies, usually from commands that return either 0 or 1. + */ AGG_LOGICAL_OR: "agg_logical_or", + /** + * The client should return the minimal value from the replies. + * Only applies to numerical replies. + * Example: WAIT command should return the minimal number of synchronized replicas from all shards. + */ AGG_MIN: "agg_min", + /** + * The client should return the maximal value from the replies. + * Only applies to numerical replies. + */ AGG_MAX: "agg_max", + /** + * The client should return the sum of replies. + * Only applies to numerical replies. + * Example: DBSIZE command. + */ AGG_SUM: "agg_sum", + /** + * Indicates a non-trivial form of reply policy. + * Example: INFO command with complex aggregation logic. + */ SPECIAL: "special", + /** + * The default behavior for commands without a response_policy tip that don't accept key name arguments: + * the client can aggregate all replies within a single nested data structure. + * Example: KEYS command replies should be packed in a single array in no particular order. + */ DEFAULT_KEYLESS: "default-keyless", + /** + * The default behavior for commands without a response_policy tip that accept one or more key name arguments: + * the client needs to retain the same order of replies as the input key names. + * Example: MGET's aggregated reply should maintain key order. + */ DEFAULT_KEYED: "default-keyed" } as const; From 9ba43bfdb0ef921048e6d3f07a843740b0cd3138 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Wed, 18 Jun 2025 10:43:51 +0300 Subject: [PATCH 11/28] POC partial implementation of request-response routing and aggregation --- packages/client/lib/cluster/cluster-slots.ts | 14 ++ packages/client/lib/cluster/index.ts | 227 ++++++++++++------ .../generic-aggregators.ts | 126 ++++++++++ 3 files changed, 298 insertions(+), 69 deletions(-) create mode 100644 packages/client/lib/cluster/request-response-policies/generic-aggregators.ts diff --git a/packages/client/lib/cluster/cluster-slots.ts b/packages/client/lib/cluster/cluster-slots.ts index e6dc0ba103a..6f1f4ce585d 100644 --- a/packages/client/lib/cluster/cluster-slots.ts +++ b/packages/client/lib/cluster/cluster-slots.ts @@ -792,6 +792,20 @@ export default class RedisClusterSlots< this.#emit('disconnect'); } + getAllClients() { + return Array.from(this.#clients()); + } + + getAllMasterClients() { + const result = []; + for (const master of this.masters) { + if (master.client) { + result.push(master.client); + } + } + return result; + } + async getClientAndSlotNumber( firstKey: RedisArgument | undefined, isReadonly: boolean | undefined diff --git a/packages/client/lib/cluster/index.ts b/packages/client/lib/cluster/index.ts index e731a16341b..698ba84b107 100644 --- a/packages/client/lib/cluster/index.ts +++ b/packages/client/lib/cluster/index.ts @@ -10,13 +10,14 @@ import { PubSubListener, PubSubListeners } from '../client/pub-sub'; import { ErrorReply } from '../errors'; import { RedisTcpSocketOptions } from '../client/socket'; import { ClientSideCacheConfig, PooledClientSideCacheProvider } from '../client/cache'; -import { BasicCommandParser } from '../client/parser'; +import { BasicCommandParser, CommandParser } from '../client/parser'; import { ASKING_CMD } from '../commands/ASKING'; import SingleEntryCache from '../single-entry-cache' import { publish, CHANNELS } from '../client/tracing'; import { ClientIdentity, ClientRole, generateClusterClientId } from '../client/identity'; import { DEFAULT_COMMAND_TIMEOUT } from '../defaults'; -import { POLICIES, PolicyResolver, StaticPolicyResolver } from './request-response-policies'; +import { POLICIES, PolicyResolver, REQUEST_POLICIES_WITH_DEFAULTS, RESPONSE_POLICIES_WITH_DEFAULTS, StaticPolicyResolver } from './request-response-policies'; +import { aggregateLogicalAnd, aggregateLogicalOr, aggregateMax, aggregateMerge, aggregateMin, aggregateSum } from './request-response-policies/generic-aggregators'; export type ClusterTopologyRefreshOnReconnectionAttemptStrategy = false | @@ -181,10 +182,9 @@ export default class RedisCluster< command.parseCommand(parser, ...args); return this._self._execute( - parser.firstKey, + parser, command.IS_READ_ONLY, this._commandOptions, - parser.commandName!, (client, opts) => client._executeCommand(command, parser, opts, transformReply) ); }; @@ -198,10 +198,9 @@ export default class RedisCluster< command.parseCommand(parser, ...args); return this._self._execute( - parser.firstKey, + parser, command.IS_READ_ONLY, this._self._commandOptions, - parser.commandName!, (client, opts) => client._executeCommand(command, parser, opts, transformReply) ); }; @@ -217,10 +216,9 @@ export default class RedisCluster< fn.parseCommand(parser, ...args); return this._self._execute( - parser.firstKey, + parser, fn.IS_READ_ONLY, this._self._commandOptions, - parser.commandName!, (client, opts) => client._executeCommand(fn, parser, opts, transformReply) ); }; @@ -236,10 +234,9 @@ export default class RedisCluster< script.parseCommand(parser, ...args); return this._self._execute( - parser.firstKey, + parser, script.IS_READ_ONLY, this._commandOptions, - parser.commandName!, (client, opts) => client._executeScript(script, parser, opts, transformReply) ); }; @@ -471,92 +468,180 @@ export default class RedisCluster< } async _execute( - firstKey: RedisArgument | undefined, + parser: CommandParser, isReadonly: boolean | undefined, options: ClusterCommandOptions | undefined, - commandName: string, fn: (client: RedisClientType, opts?: ClusterCommandOptions) => Promise ): Promise { const maxCommandRedirections = this._options.maxCommandRedirections ?? 16; - const policyResult = this._policyResolver.resolvePolicy(commandName) - if(policyResult.ok) { - //TODO - } else { - //TODO + const policyResult = this._policyResolver.resolvePolicy(parser.commandIdentifier); + + if(!policyResult.ok) { + throw new Error(`Policy resolution error for ${parser.commandIdentifier}: ${policyResult.error}`); } - let { client, slotNumber } = await this._slots.getClientAndSlotNumber(firstKey, isReadonly); - let i = 0; + const requestPolicy = policyResult.value.request + const responsePolicy = policyResult.value.response - let myFn = fn; + let clients: Array>; + // https://redis.io/docs/latest/develop/reference/command-tips + switch (requestPolicy) { - while (true) { - try { - const opts: ClusterCommandOptions = { ...options, slotNumber }; - return await myFn(client, opts); - } catch (_err) { - const err = _err as Error; - myFn = fn; + case REQUEST_POLICIES_WITH_DEFAULTS.ALL_NODES: + clients = this._slots.getAllClients() + break; + + case REQUEST_POLICIES_WITH_DEFAULTS.ALL_SHARDS: + clients = this._slots.getAllMasterClients() + break; + + case REQUEST_POLICIES_WITH_DEFAULTS.MULTI_SHARD: + clients = await Promise.all( + parser.keys.map(async (key) => (await this._slots.getClientAndSlotNumber(key, isReadonly)).client) + ); + break; + + case REQUEST_POLICIES_WITH_DEFAULTS.SPECIAL: + throw new Error(`Special request policy not implemented for ${parser.commandIdentifier}`); + case REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS: + //TODO handle undefined case? + clients = [this._slots.getRandomNode().client!] + break; - // TODO: error class - if (++i > maxCommandRedirections || !(err instanceof Error)) { - if (err instanceof Error) { + case REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED: + clients = [(await this._slots.getClientAndSlotNumber(parser.firstKey, isReadonly)).client] + break; + + default: + throw new Error(`Unknown request policy ${requestPolicy}`); + } + + const responsePromises = clients.map(async client => { + + let i = 0; + + let myFn = fn; + + while (true) { + try { + return await myFn(client, options); + } catch (_err) { + const err = _err as Error; + myFn = fn; + + // TODO: error class + if (++i > maxCommandRedirections || !(err instanceof Error)) { + if (err instanceof Error) { + publish(CHANNELS.ERROR, () => ({ + error: err, + origin: 'cluster', + internal: false, + clientId: client._clientId, + retryCount: i, + })); + } + throw err; + } + + if (err.message.startsWith('ASK')) { publish(CHANNELS.ERROR, () => ({ error: err, origin: 'cluster', - internal: false, + internal: true, clientId: client._clientId, retryCount: i, })); + const address = err.message.substring(err.message.lastIndexOf(' ') + 1); + let redirectTo = await this._slots.getMasterByAddress(address); + if (!redirectTo) { + await this._slots.rediscover(client); + redirectTo = await this._slots.getMasterByAddress(address); + } + + if (!redirectTo) { + throw new Error(`Cannot find node ${address}`); + } + + client = redirectTo; + myFn = this._handleAsk(fn); + continue; } - throw err; - } - if (err.message.startsWith('ASK')) { - publish(CHANNELS.ERROR, () => ({ - error: err, - origin: 'cluster', - internal: true, - clientId: client._clientId, - retryCount: i, - })); - const address = err.message.substring(err.message.lastIndexOf(' ') + 1); - let redirectTo = await this._slots.getMasterByAddress(address); - if (!redirectTo) { + if (err.message.startsWith('MOVED')) { + publish(CHANNELS.ERROR, () => ({ + error: err, + origin: 'cluster', + internal: true, + clientId: client._clientId, + retryCount: i, + })); await this._slots.rediscover(client); - redirectTo = await this._slots.getMasterByAddress(address); - } - - if (!redirectTo) { - throw new Error(`Cannot find node ${address}`); + client = (await this._slots.getClientAndSlotNumber(parser.firstKey, isReadonly)).client; + continue; } - client = redirectTo; - myFn = this._handleAsk(fn); - continue; + throw err; } + } - if (err.message.startsWith('MOVED')) { - publish(CHANNELS.ERROR, () => ({ - error: err, - origin: 'cluster', - internal: true, - clientId: client._clientId, - retryCount: i, - })); - await this._slots.rediscover(client); - const clientAndSlot = await this._slots.getClientAndSlotNumber(firstKey, isReadonly); - client = clientAndSlot.client; - slotNumber = clientAndSlot.slotNumber; - continue; - } + }) + + switch (responsePolicy) { + case RESPONSE_POLICIES_WITH_DEFAULTS.ONE_SUCCEEDED: { + return Promise.any(responsePromises); + } - throw err; + case RESPONSE_POLICIES_WITH_DEFAULTS.ALL_SUCCEEDED: { + const responses = await Promise.all(responsePromises); + return responses[0] + } + + case RESPONSE_POLICIES_WITH_DEFAULTS.AGG_LOGICAL_AND: { + const responses = await Promise.all(responsePromises) + return aggregateLogicalAnd(responses); + } + + case RESPONSE_POLICIES_WITH_DEFAULTS.AGG_LOGICAL_OR: { + const responses = await Promise.all(responsePromises) + return aggregateLogicalOr(responses); + } + + case RESPONSE_POLICIES_WITH_DEFAULTS.AGG_MIN: { + const responses = await Promise.all(responsePromises); + return aggregateMin(responses); + } + + case RESPONSE_POLICIES_WITH_DEFAULTS.AGG_MAX: { + const responses = await Promise.all(responsePromises); + return aggregateMax(responses); } + + case RESPONSE_POLICIES_WITH_DEFAULTS.AGG_SUM: { + const responses = await Promise.all(responsePromises); + return aggregateSum(responses); + } + + case RESPONSE_POLICIES_WITH_DEFAULTS.SPECIAL: { + throw new Error(`Special response policy not implemented for ${parser.commandIdentifier}`); + } + + case RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS: { + const responses = await Promise.all(responsePromises); + return aggregateMerge(responses); + } + + case RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED: { + const responses = await Promise.all(responsePromises); + return responses as T; + } + + default: + throw new Error(`Unknown response policy ${responsePolicy}`); } + } async sendCommand( @@ -572,11 +657,15 @@ export default class RedisCluster< ...this._commandOptions, ...options } + + const parser = new BasicCommandParser(); + firstKey && parser.push(firstKey) + args.forEach(arg => parser.push(arg)); + return this._self._execute( - firstKey, + parser, isReadonly, opts, - args[0] instanceof Buffer ? args[0].toString() : args[0], (client, opts) => client.sendCommand(args, opts) ); } diff --git a/packages/client/lib/cluster/request-response-policies/generic-aggregators.ts b/packages/client/lib/cluster/request-response-policies/generic-aggregators.ts new file mode 100644 index 00000000000..de4202d2d44 --- /dev/null +++ b/packages/client/lib/cluster/request-response-policies/generic-aggregators.ts @@ -0,0 +1,126 @@ +/** + * Aggregates multiple arrays of numbers using logical AND operation. + * @remarks + * This implementation is specifically designed for Array> type only, + * despite the generic type parameter. It is currently used by the SCRIPT EXISTS command + * which returns an array of 0s and 1s from each shard. + * The generic type parameter T is provided for usage ergonomy, but the actual input structure + * will be validated at runtime. + */ +export const aggregateLogicalAnd = (replies: Array): T => { + if (replies.length === 0) return [] as T; + if ( + !replies.every( + (reply): reply is number[] => + Array.isArray(reply) && + reply.every((value): value is number => typeof value === 'number') + ) + ) { + throw new Error( + 'All replies must be array of numbers for logical AND aggregation' + ); + } + + const result = Array(replies[0].length).fill(1); + + for (const reply of replies) { + for (let i = 0; i < reply.length; i++) { + result[i] = result[i] && reply[i]; + } + } + + return result as T; +}; + +//TODO fix this +export const aggregateLogicalOr = ( + replies: Array +): T => { + const result = Array((replies[0] as Array).length).fill(1); + for (const reply of replies) { + for (let i = 0; i < (reply as Array).length; i++) { + result[i] = result[i] || (reply as Array)[i]; + } + } + return result as T; +}; + +/** + * Aggregates multiple numbers by finding the minimum value. + * @remarks + * This implementation is specifically designed for Array type only, + * despite the generic type parameter. It is used by commands like WAIT + * which returns the minimal number of synchronized replicas from all shards. + * The generic type parameter T is provided for usage ergonomy, but the actual input structure + * will be validated at runtime. + */ +export const aggregateMin = (replies: Array): T => { + if (replies.length === 0) return 0 as T; + if (!replies.every((reply): reply is number => typeof reply === 'number')) { + throw new Error('All replies must be numbers for min aggregation'); + } + return Math.min(...replies) as T; +}; + +/** + * Aggregates multiple numbers by finding the maximum value. + * @remarks + * This implementation is specifically designed for Array type only, + * despite the generic type parameter. The generic type parameter T is provided + * for usage ergonomy, but the actual input structure will be validated at runtime. + */ +export const aggregateMax = (replies: Array): T => { + if (replies.length === 0) return 0 as T; + if (!replies.every((reply): reply is number => typeof reply === 'number')) { + throw new Error('All replies must be numbers for max aggregation'); + } + return Math.max(...replies) as T; +}; + +/** + * Aggregates multiple numbers by finding the sum of all values. + * @remarks + * This implementation is specifically designed for Array type only, + * despite the generic type parameter. The generic type parameter T is provided + * for usage ergonomy, but the actual input structure will be validated at runtime. + */ +export const aggregateSum = (replies: Array): T => { + if (replies.length === 0) return 0 as T; + if (!replies.every((reply): reply is number => typeof reply === 'number')) { + throw new Error('All replies must be numbers for sum aggregation'); + } + return replies.reduce((acc, reply) => acc + reply, 0) as T; +}; + + +export const aggregateMerge = (replies: Array): T => { + if(replies.length === 0) return undefined as T; + + const firstReply = replies[0] + + if(Array.isArray(firstReply)) { + const set = new Set() + for(const reply of replies) { + for(const item of reply as Array) { + set.add(item); + } + } + return Array.from(set) as T; + } + + //TODO, maybe this needs to be plain object + if(firstReply instanceof Map) { + const map = new Map(); + for(const reply of replies) { + for(const [key, value] of reply as Map) { + map.set(key, value); + } + } + return map as T; + } + + //TODO remove + console.log('firstReply', firstReply, typeof firstReply); + throw new Error('Unsupported reply type for merge aggregation'); + +}; From 964df625d5c749b01c829df473c63d8592aab6ce Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Mon, 8 Jun 2026 14:41:59 +0300 Subject: [PATCH 12/28] extract request/response policy branches into named dispatch helpers Move each switch case body in `_execute` into a typed router/reducer function in `request-response-policies/dispatch.ts`. Switches still dispatch by policy enum; behavior unchanged. Prepares for replacing the switches with a strategy registry in the next commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/client/lib/cluster/index.ts | 95 +++++++------- .../request-response-policies/dispatch.ts | 122 ++++++++++++++++++ .../request-response-policies/index.ts | 1 + 3 files changed, 169 insertions(+), 49 deletions(-) create mode 100644 packages/client/lib/cluster/request-response-policies/dispatch.ts diff --git a/packages/client/lib/cluster/index.ts b/packages/client/lib/cluster/index.ts index 698ba84b107..441c9f92b82 100644 --- a/packages/client/lib/cluster/index.ts +++ b/packages/client/lib/cluster/index.ts @@ -17,7 +17,24 @@ import { publish, CHANNELS } from '../client/tracing'; import { ClientIdentity, ClientRole, generateClusterClientId } from '../client/identity'; import { DEFAULT_COMMAND_TIMEOUT } from '../defaults'; import { POLICIES, PolicyResolver, REQUEST_POLICIES_WITH_DEFAULTS, RESPONSE_POLICIES_WITH_DEFAULTS, StaticPolicyResolver } from './request-response-policies'; -import { aggregateLogicalAnd, aggregateLogicalOr, aggregateMax, aggregateMerge, aggregateMin, aggregateSum } from './request-response-policies/generic-aggregators'; +import { + routeAllNodes, + routeAllShards, + routeMultiShard, + routeDefaultKeyless, + routeDefaultKeyed, + routeSpecial, + reduceOneSucceeded, + reduceAllSucceeded, + reduceLogicalAnd, + reduceLogicalOr, + reduceMin, + reduceMax, + reduceSum, + reduceSpecial, + reduceDefaultKeyless, + reduceDefaultKeyed +} from './request-response-policies/dispatch'; export type ClusterTopologyRefreshOnReconnectionAttemptStrategy = false | @@ -490,29 +507,27 @@ export default class RedisCluster< switch (requestPolicy) { case REQUEST_POLICIES_WITH_DEFAULTS.ALL_NODES: - clients = this._slots.getAllClients() + clients = await routeAllNodes(this._slots, parser, isReadonly); break; case REQUEST_POLICIES_WITH_DEFAULTS.ALL_SHARDS: - clients = this._slots.getAllMasterClients() + clients = await routeAllShards(this._slots, parser, isReadonly); break; case REQUEST_POLICIES_WITH_DEFAULTS.MULTI_SHARD: - clients = await Promise.all( - parser.keys.map(async (key) => (await this._slots.getClientAndSlotNumber(key, isReadonly)).client) - ); + clients = await routeMultiShard(this._slots, parser, isReadonly); break; case REQUEST_POLICIES_WITH_DEFAULTS.SPECIAL: - throw new Error(`Special request policy not implemented for ${parser.commandIdentifier}`); + clients = await routeSpecial(this._slots, parser, isReadonly); + break; case REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS: - //TODO handle undefined case? - clients = [this._slots.getRandomNode().client!] + clients = await routeDefaultKeyless(this._slots, parser, isReadonly); break; case REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED: - clients = [(await this._slots.getClientAndSlotNumber(parser.firstKey, isReadonly)).client] + clients = await routeDefaultKeyed(this._slots, parser, isReadonly); break; default: @@ -590,53 +605,35 @@ export default class RedisCluster< }) switch (responsePolicy) { - case RESPONSE_POLICIES_WITH_DEFAULTS.ONE_SUCCEEDED: { - return Promise.any(responsePromises); - } + case RESPONSE_POLICIES_WITH_DEFAULTS.ONE_SUCCEEDED: + return reduceOneSucceeded(responsePromises); - case RESPONSE_POLICIES_WITH_DEFAULTS.ALL_SUCCEEDED: { - const responses = await Promise.all(responsePromises); - return responses[0] - } + case RESPONSE_POLICIES_WITH_DEFAULTS.ALL_SUCCEEDED: + return reduceAllSucceeded(responsePromises); - case RESPONSE_POLICIES_WITH_DEFAULTS.AGG_LOGICAL_AND: { - const responses = await Promise.all(responsePromises) - return aggregateLogicalAnd(responses); - } + case RESPONSE_POLICIES_WITH_DEFAULTS.AGG_LOGICAL_AND: + return reduceLogicalAnd(responsePromises); - case RESPONSE_POLICIES_WITH_DEFAULTS.AGG_LOGICAL_OR: { - const responses = await Promise.all(responsePromises) - return aggregateLogicalOr(responses); - } + case RESPONSE_POLICIES_WITH_DEFAULTS.AGG_LOGICAL_OR: + return reduceLogicalOr(responsePromises); - case RESPONSE_POLICIES_WITH_DEFAULTS.AGG_MIN: { - const responses = await Promise.all(responsePromises); - return aggregateMin(responses); - } - - case RESPONSE_POLICIES_WITH_DEFAULTS.AGG_MAX: { - const responses = await Promise.all(responsePromises); - return aggregateMax(responses); - } + case RESPONSE_POLICIES_WITH_DEFAULTS.AGG_MIN: + return reduceMin(responsePromises); - case RESPONSE_POLICIES_WITH_DEFAULTS.AGG_SUM: { - const responses = await Promise.all(responsePromises); - return aggregateSum(responses); - } + case RESPONSE_POLICIES_WITH_DEFAULTS.AGG_MAX: + return reduceMax(responsePromises); - case RESPONSE_POLICIES_WITH_DEFAULTS.SPECIAL: { - throw new Error(`Special response policy not implemented for ${parser.commandIdentifier}`); - } + case RESPONSE_POLICIES_WITH_DEFAULTS.AGG_SUM: + return reduceSum(responsePromises); - case RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS: { - const responses = await Promise.all(responsePromises); - return aggregateMerge(responses); - } + case RESPONSE_POLICIES_WITH_DEFAULTS.SPECIAL: + return reduceSpecial(responsePromises, parser); - case RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED: { - const responses = await Promise.all(responsePromises); - return responses as T; - } + case RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS: + return reduceDefaultKeyless(responsePromises); + + case RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED: + return reduceDefaultKeyed(responsePromises); default: throw new Error(`Unknown response policy ${responsePolicy}`); diff --git a/packages/client/lib/cluster/request-response-policies/dispatch.ts b/packages/client/lib/cluster/request-response-policies/dispatch.ts new file mode 100644 index 00000000000..e57126e2f38 --- /dev/null +++ b/packages/client/lib/cluster/request-response-policies/dispatch.ts @@ -0,0 +1,122 @@ +import type { CommandParser } from '../../client/parser'; +import type { RedisClientType } from '../../client'; +import type { + RedisModules, RedisFunctions, RedisScripts, RespVersions, TypeMapping +} from '../../RESP/types'; +import type RedisClusterSlots from '../cluster-slots'; +import { + aggregateLogicalAnd, + aggregateLogicalOr, + aggregateMax, + aggregateMerge, + aggregateMin, + aggregateSum +} from './generic-aggregators'; + +type Client< + M extends RedisModules, + F extends RedisFunctions, + S extends RedisScripts, + RESP extends RespVersions, + TM extends TypeMapping +> = RedisClientType; + +type Slots< + M extends RedisModules, + F extends RedisFunctions, + S extends RedisScripts, + RESP extends RespVersions, + TM extends TypeMapping +> = RedisClusterSlots; + +export type RequestRouter< + M extends RedisModules, + F extends RedisFunctions, + S extends RedisScripts, + RESP extends RespVersions, + TM extends TypeMapping +> = ( + slots: Slots, + parser: CommandParser, + isReadonly: boolean | undefined +) => Promise>>; + +export type ResponseReducer = ( + responsePromises: Promise[], + parser: CommandParser +) => Promise; + +// --- request routers --- + +export const routeAllNodes: RequestRouter = + async (slots) => slots.getAllClients(); + +export const routeAllShards: RequestRouter = + async (slots) => slots.getAllMasterClients(); + +export const routeMultiShard: RequestRouter = + async (slots, parser, isReadonly) => + Promise.all( + parser.keys.map(async (key) => (await slots.getClientAndSlotNumber(key, isReadonly)).client) + ); + +export const routeDefaultKeyless: RequestRouter = + async (slots) => [slots.getRandomNode().client!]; + +export const routeDefaultKeyed: RequestRouter = + async (slots, parser, isReadonly) => + [(await slots.getClientAndSlotNumber(parser.firstKey, isReadonly)).client]; + +export const routeSpecial: RequestRouter = + async (_slots, parser) => { + throw new Error(`Special request policy not implemented for ${parser.commandIdentifier}`); + }; + +// --- response reducers --- + +export const reduceOneSucceeded = (promises: Promise[]): Promise => + Promise.any(promises); + +export const reduceAllSucceeded = async (promises: Promise[]): Promise => { + const responses = await Promise.all(promises); + return responses[0]; +}; + +export const reduceLogicalAnd = async (promises: Promise[]): Promise => { + const responses = await Promise.all(promises); + return aggregateLogicalAnd(responses) as T; +}; + +export const reduceLogicalOr = async (promises: Promise[]): Promise => { + const responses = await Promise.all(promises); + return aggregateLogicalOr(responses) as T; +}; + +export const reduceMin = async (promises: Promise[]): Promise => { + const responses = await Promise.all(promises); + return aggregateMin(responses) as T; +}; + +export const reduceMax = async (promises: Promise[]): Promise => { + const responses = await Promise.all(promises); + return aggregateMax(responses) as T; +}; + +export const reduceSum = async (promises: Promise[]): Promise => { + const responses = await Promise.all(promises); + return aggregateSum(responses) as T; +}; + +export const reduceSpecial = async (_promises: Promise[], parser: CommandParser): Promise => { + throw new Error(`Special response policy not implemented for ${parser.commandIdentifier}`); +}; + +export const reduceDefaultKeyless = async (promises: Promise[]): Promise => { + const responses = await Promise.all(promises); + return aggregateMerge(responses) as T; +}; + +export const reduceDefaultKeyed = async (promises: Promise[]): Promise => { + const responses = await Promise.all(promises); + return responses as T; +}; diff --git a/packages/client/lib/cluster/request-response-policies/index.ts b/packages/client/lib/cluster/request-response-policies/index.ts index e4b2410ba89..546637b4414 100644 --- a/packages/client/lib/cluster/request-response-policies/index.ts +++ b/packages/client/lib/cluster/request-response-policies/index.ts @@ -5,5 +5,6 @@ export { DynamicPolicyResolverFactory, type CommandFetcher } from './dynamic-pol export * from './policies-constants'; export { POLICIES } from './static-policies-data'; +export * from './dispatch'; // export { type CommandRouter } from './command-router'; \ No newline at end of file From aff4a0de6eb5349351562c547307980817523d80 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Mon, 8 Jun 2026 14:44:14 +0300 Subject: [PATCH 13/28] replace policy switches with strategy registries in `_execute` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Define `REQUEST_ROUTERS` and `RESPONSE_REDUCERS` as policy-keyed records of the dispatch helpers introduced in the previous commit. The two switches in `_execute` collapse to a single registry lookup each, with `Unknown policy` errors preserved. `satisfies Record` keeps the lookup exhaustive at the type level. Adding a new policy now requires only a new entry in the registry — no `_execute` changes. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/client/lib/cluster/index.ts | 92 +++---------------- .../request-response-policies/dispatch.ts | 30 ++++++ 2 files changed, 41 insertions(+), 81 deletions(-) diff --git a/packages/client/lib/cluster/index.ts b/packages/client/lib/cluster/index.ts index 441c9f92b82..2e1a350ddc6 100644 --- a/packages/client/lib/cluster/index.ts +++ b/packages/client/lib/cluster/index.ts @@ -16,25 +16,8 @@ import SingleEntryCache from '../single-entry-cache' import { publish, CHANNELS } from '../client/tracing'; import { ClientIdentity, ClientRole, generateClusterClientId } from '../client/identity'; import { DEFAULT_COMMAND_TIMEOUT } from '../defaults'; -import { POLICIES, PolicyResolver, REQUEST_POLICIES_WITH_DEFAULTS, RESPONSE_POLICIES_WITH_DEFAULTS, StaticPolicyResolver } from './request-response-policies'; -import { - routeAllNodes, - routeAllShards, - routeMultiShard, - routeDefaultKeyless, - routeDefaultKeyed, - routeSpecial, - reduceOneSucceeded, - reduceAllSucceeded, - reduceLogicalAnd, - reduceLogicalOr, - reduceMin, - reduceMax, - reduceSum, - reduceSpecial, - reduceDefaultKeyless, - reduceDefaultKeyed -} from './request-response-policies/dispatch'; +import { POLICIES, PolicyResolver, StaticPolicyResolver } from './request-response-policies'; +import { REQUEST_ROUTERS, RESPONSE_REDUCERS } from './request-response-policies/dispatch'; export type ClusterTopologyRefreshOnReconnectionAttemptStrategy = false | @@ -502,37 +485,13 @@ export default class RedisCluster< const requestPolicy = policyResult.value.request const responsePolicy = policyResult.value.response - let clients: Array>; // https://redis.io/docs/latest/develop/reference/command-tips - switch (requestPolicy) { - - case REQUEST_POLICIES_WITH_DEFAULTS.ALL_NODES: - clients = await routeAllNodes(this._slots, parser, isReadonly); - break; - - case REQUEST_POLICIES_WITH_DEFAULTS.ALL_SHARDS: - clients = await routeAllShards(this._slots, parser, isReadonly); - break; - - case REQUEST_POLICIES_WITH_DEFAULTS.MULTI_SHARD: - clients = await routeMultiShard(this._slots, parser, isReadonly); - break; - - case REQUEST_POLICIES_WITH_DEFAULTS.SPECIAL: - clients = await routeSpecial(this._slots, parser, isReadonly); - break; - - case REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS: - clients = await routeDefaultKeyless(this._slots, parser, isReadonly); - break; - - case REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED: - clients = await routeDefaultKeyed(this._slots, parser, isReadonly); - break; - - default: - throw new Error(`Unknown request policy ${requestPolicy}`); + const router = REQUEST_ROUTERS[requestPolicy]; + if (!router) { + throw new Error(`Unknown request policy ${requestPolicy}`); } + const clients: Array> = + await router(this._slots, parser, isReadonly); const responsePromises = clients.map(async client => { @@ -604,40 +563,11 @@ export default class RedisCluster< }) - switch (responsePolicy) { - case RESPONSE_POLICIES_WITH_DEFAULTS.ONE_SUCCEEDED: - return reduceOneSucceeded(responsePromises); - - case RESPONSE_POLICIES_WITH_DEFAULTS.ALL_SUCCEEDED: - return reduceAllSucceeded(responsePromises); - - case RESPONSE_POLICIES_WITH_DEFAULTS.AGG_LOGICAL_AND: - return reduceLogicalAnd(responsePromises); - - case RESPONSE_POLICIES_WITH_DEFAULTS.AGG_LOGICAL_OR: - return reduceLogicalOr(responsePromises); - - case RESPONSE_POLICIES_WITH_DEFAULTS.AGG_MIN: - return reduceMin(responsePromises); - - case RESPONSE_POLICIES_WITH_DEFAULTS.AGG_MAX: - return reduceMax(responsePromises); - - case RESPONSE_POLICIES_WITH_DEFAULTS.AGG_SUM: - return reduceSum(responsePromises); - - case RESPONSE_POLICIES_WITH_DEFAULTS.SPECIAL: - return reduceSpecial(responsePromises, parser); - - case RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS: - return reduceDefaultKeyless(responsePromises); - - case RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED: - return reduceDefaultKeyed(responsePromises); - - default: - throw new Error(`Unknown response policy ${responsePolicy}`); + const reducer = RESPONSE_REDUCERS[responsePolicy]; + if (!reducer) { + throw new Error(`Unknown response policy ${responsePolicy}`); } + return reducer(responsePromises, parser) as Promise; } diff --git a/packages/client/lib/cluster/request-response-policies/dispatch.ts b/packages/client/lib/cluster/request-response-policies/dispatch.ts index e57126e2f38..0876d1c25a9 100644 --- a/packages/client/lib/cluster/request-response-policies/dispatch.ts +++ b/packages/client/lib/cluster/request-response-policies/dispatch.ts @@ -12,6 +12,12 @@ import { aggregateMin, aggregateSum } from './generic-aggregators'; +import { + REQUEST_POLICIES_WITH_DEFAULTS, + RESPONSE_POLICIES_WITH_DEFAULTS, + type RequestPolicyWithDefaults, + type ResponsePolicyWithDefaults +} from './policies-constants'; type Client< M extends RedisModules, @@ -120,3 +126,27 @@ export const reduceDefaultKeyed = async (promises: Promise[]): Promise const responses = await Promise.all(promises); return responses as T; }; + +// --- registries --- + +export const REQUEST_ROUTERS = { + [REQUEST_POLICIES_WITH_DEFAULTS.ALL_NODES]: routeAllNodes, + [REQUEST_POLICIES_WITH_DEFAULTS.ALL_SHARDS]: routeAllShards, + [REQUEST_POLICIES_WITH_DEFAULTS.MULTI_SHARD]: routeMultiShard, + [REQUEST_POLICIES_WITH_DEFAULTS.SPECIAL]: routeSpecial, + [REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS]: routeDefaultKeyless, + [REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED]: routeDefaultKeyed +} as const satisfies Record>; + +export const RESPONSE_REDUCERS = { + [RESPONSE_POLICIES_WITH_DEFAULTS.ONE_SUCCEEDED]: reduceOneSucceeded, + [RESPONSE_POLICIES_WITH_DEFAULTS.ALL_SUCCEEDED]: reduceAllSucceeded, + [RESPONSE_POLICIES_WITH_DEFAULTS.AGG_LOGICAL_AND]: reduceLogicalAnd, + [RESPONSE_POLICIES_WITH_DEFAULTS.AGG_LOGICAL_OR]: reduceLogicalOr, + [RESPONSE_POLICIES_WITH_DEFAULTS.AGG_MIN]: reduceMin, + [RESPONSE_POLICIES_WITH_DEFAULTS.AGG_MAX]: reduceMax, + [RESPONSE_POLICIES_WITH_DEFAULTS.AGG_SUM]: reduceSum, + [RESPONSE_POLICIES_WITH_DEFAULTS.SPECIAL]: reduceSpecial, + [RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS]: reduceDefaultKeyless, + [RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED]: reduceDefaultKeyed +} as const satisfies Record>; From e907daacc1f9bb7479c276bb72f5cde4b59ab8e8 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Mon, 8 Jun 2026 15:20:31 +0300 Subject: [PATCH 14/28] stabilize policy resolver: consistent casing + safer subcommand handling - `CommandIdentifier.subcommand` is now `string | undefined`, mirroring the fact that single-word commands have no second arg. The parser emits `undefined` instead of an implicit empty value. - `StaticPolicyResolver` lowercases the entire policy table at construction time, so lookups are case-insensitive regardless of the casing used in the static data file (which today mixes upper- and lowercase module/command keys). - Subcommand lookups also lowercase the incoming identifier and guard against `undefined`, so `MEMORY USAGE` resolves to the `usage` subcommand policy regardless of caller casing. - Drop the stray `console.log` left in the resolver. - Error messages that referenced `parser.commandIdentifier` now print a `command [subcommand]` label instead of `[object Object]`. - Update `dynamic-policy-resolver.spec.ts` to pass `CommandIdentifier` objects to `resolvePolicy` (previously was passing raw strings). - Add `static-policy-resolver.spec.ts` covering FT.SEARCH, MEMORY USAGE, GET, COMMAND INFO, FT.SUGADD, casing, errors and fallback. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/client/lib/client/parser.ts | 10 +- packages/client/lib/cluster/index.ts | 4 +- .../request-response-policies/dispatch.ts | 8 +- .../dynamic-policy-resolver.spec.ts | 32 ++--- .../static-policy-resolver.spec.ts | 122 ++++++++++++++++++ .../static-policy-resolver.ts | 42 ++++-- 6 files changed, 185 insertions(+), 33 deletions(-) create mode 100644 packages/client/lib/cluster/request-response-policies/static-policy-resolver.spec.ts diff --git a/packages/client/lib/client/parser.ts b/packages/client/lib/client/parser.ts index 0da883db7cd..1a6db5be443 100644 --- a/packages/client/lib/client/parser.ts +++ b/packages/client/lib/client/parser.ts @@ -33,7 +33,7 @@ export function prefixKeys(keyPrefix: RedisArgument | undefined, keys: RedisVari : [prefixKey(keyPrefix, keys)]; } -export type CommandIdentifier = { command: string, subcommand: string }; +export type CommandIdentifier = { command: string, subcommand: string | undefined }; export interface CommandParser { redisArgs: ReadonlyArray; @@ -94,8 +94,12 @@ export class BasicCommandParser implements CommandParser { } get commandIdentifier(): CommandIdentifier { - const command = this.#redisArgs[0] instanceof Buffer ? this.#redisArgs[0].toString() : this.#redisArgs[0]; - const subcommand = this.#redisArgs[1] instanceof Buffer ? this.#redisArgs[1].toString() : this.#redisArgs[1]; + 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 }; } diff --git a/packages/client/lib/cluster/index.ts b/packages/client/lib/cluster/index.ts index 2e1a350ddc6..f27ecf983c3 100644 --- a/packages/client/lib/cluster/index.ts +++ b/packages/client/lib/cluster/index.ts @@ -479,7 +479,9 @@ export default class RedisCluster< const policyResult = this._policyResolver.resolvePolicy(parser.commandIdentifier); if(!policyResult.ok) { - throw new Error(`Policy resolution error for ${parser.commandIdentifier}: ${policyResult.error}`); + const { command, subcommand } = parser.commandIdentifier; + const label = subcommand ? `${command} ${subcommand}` : command; + throw new Error(`Policy resolution error for ${label}: ${policyResult.error}`); } const requestPolicy = policyResult.value.request diff --git a/packages/client/lib/cluster/request-response-policies/dispatch.ts b/packages/client/lib/cluster/request-response-policies/dispatch.ts index 0876d1c25a9..39512e65b18 100644 --- a/packages/client/lib/cluster/request-response-policies/dispatch.ts +++ b/packages/client/lib/cluster/request-response-policies/dispatch.ts @@ -75,7 +75,9 @@ export const routeDefaultKeyed: RequestRouter = export const routeSpecial: RequestRouter = async (_slots, parser) => { - throw new Error(`Special request policy not implemented for ${parser.commandIdentifier}`); + const { command, subcommand } = parser.commandIdentifier; + const label = subcommand ? `${command} ${subcommand}` : command; + throw new Error(`Special request policy not implemented for ${label}`); }; // --- response reducers --- @@ -114,7 +116,9 @@ export const reduceSum = async (promises: Promise[]): Promise => { }; export const reduceSpecial = async (_promises: Promise[], parser: CommandParser): Promise => { - throw new Error(`Special response policy not implemented for ${parser.commandIdentifier}`); + const { command, subcommand } = parser.commandIdentifier; + const label = subcommand ? `${command} ${subcommand}` : command; + throw new Error(`Special response policy not implemented for ${label}`); }; export const reduceDefaultKeyless = async (promises: Promise[]): Promise => { diff --git a/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver.spec.ts b/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver.spec.ts index 6f05b62111b..fa79970147b 100644 --- a/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver.spec.ts +++ b/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver.spec.ts @@ -30,7 +30,7 @@ describe('DynamicPolicyResolverFactory', () => { const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher, fallbackResolver); assert.ok(resolver instanceof StaticPolicyResolver); - const result = resolver.resolvePolicy('ping'); + const result = resolver.resolvePolicy({ command: 'ping', subcommand: undefined }); assert.equal(result.ok, true); if (result.ok) { assert.equal(result.value.request, REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS); @@ -59,7 +59,7 @@ describe('DynamicPolicyResolverFactory', () => { const mockCommandFetcher = createMockCommandFetcher(mockCommands); const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher); - const result = resolver.resolvePolicy('ping'); + const result = resolver.resolvePolicy({ command: 'ping', subcommand: undefined }); assert.equal(result.ok, true); if (result.ok) { assert.equal(result.value.request, REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS); @@ -86,7 +86,7 @@ describe('DynamicPolicyResolverFactory', () => { const mockCommandFetcher = createMockCommandFetcher(mockCommands); const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher); - const result = resolver.resolvePolicy('get'); + const result = resolver.resolvePolicy({ command: 'get', subcommand: undefined }); assert.equal(result.ok, true); if (result.ok) { assert.equal(result.value.request, REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED); @@ -113,7 +113,7 @@ describe('DynamicPolicyResolverFactory', () => { const mockCommandFetcher = createMockCommandFetcher(mockCommands); const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher); - const result = resolver.resolvePolicy('dbsize'); + const result = resolver.resolvePolicy({ command: 'dbsize', subcommand: undefined }); assert.equal(result.ok, true); if (result.ok) { assert.equal(result.value.request, 'all_shards'); @@ -140,7 +140,7 @@ describe('DynamicPolicyResolverFactory', () => { const mockCommandFetcher = createMockCommandFetcher(mockCommands); const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher); - const result = resolver.resolvePolicy('ft.search'); + const result = resolver.resolvePolicy({ command: 'ft.search', subcommand: undefined }); assert.equal(result.ok, true); if (result.ok) { assert.equal(result.value.request, 'all_shards'); @@ -167,7 +167,7 @@ describe('DynamicPolicyResolverFactory', () => { const mockCommandFetcher = createMockCommandFetcher(mockCommands); const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher); - const result = resolver.resolvePolicy('json.get'); + const result = resolver.resolvePolicy({ command: 'json.get', subcommand: undefined }); assert.equal(result.ok, true); if (result.ok) { assert.equal(result.value.request, REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS); @@ -196,7 +196,7 @@ describe('DynamicPolicyResolverFactory', () => { const mockCommandFetcher = createMockCommandFetcher(mockCommands); const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher); - const result = resolver.resolvePolicy('test'); + const result = resolver.resolvePolicy({ command: 'test', subcommand: undefined }); assert.equal(result.ok, true); }); @@ -204,7 +204,7 @@ describe('DynamicPolicyResolverFactory', () => { const mockCommandFetcher = createMockCommandFetcher([]); const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher); - const result = resolver.resolvePolicy('unknown'); + const result = resolver.resolvePolicy({ command: 'unknown', subcommand: undefined }); assert.equal(result.ok, false); assert.equal(result.error, 'unknown-command'); }); @@ -213,7 +213,7 @@ describe('DynamicPolicyResolverFactory', () => { const mockCommandFetcher = createMockCommandFetcher([]); const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher); - const result = resolver.resolvePolicy('unknown.command'); + const result = resolver.resolvePolicy({ command: 'unknown.command', subcommand: undefined }); assert.equal(result.ok, false); assert.equal(result.error, 'unknown-module'); }); @@ -222,7 +222,7 @@ describe('DynamicPolicyResolverFactory', () => { const mockCommandFetcher = createMockCommandFetcher([]); const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher); - const result = resolver.resolvePolicy('too.many.dots.here'); + const result = resolver.resolvePolicy({ command: 'too.many.dots.here', subcommand: undefined }); assert.equal(result.ok, false); assert.equal(result.error, 'wrong-command-or-module-name'); }); @@ -261,7 +261,7 @@ describe('DynamicPolicyResolverFactory', () => { const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher); // Command with only request policy should fall back to defaults - let result = resolver.resolvePolicy('partial-request'); + let result = resolver.resolvePolicy({ command: 'partial-request', subcommand: undefined }); assert.equal(result.ok, true); if (result.ok) { assert.equal(result.value.request, REQUEST_POLICIES_WITH_DEFAULTS.ALL_NODES); @@ -269,7 +269,7 @@ describe('DynamicPolicyResolverFactory', () => { } // Command with only response policy should fall back to defaults - result = resolver.resolvePolicy('partial-response'); + result = resolver.resolvePolicy({ command: 'partial-response', subcommand: undefined }); assert.equal(result.ok, true); if (result.ok) { assert.equal(result.value.request, REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS); @@ -282,7 +282,7 @@ describe('DynamicPolicyResolverFactory', () => { const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher); assert.ok(resolver instanceof StaticPolicyResolver); - const result = resolver.resolvePolicy('any-command'); + const result = resolver.resolvePolicy({ command: 'any-command', subcommand: undefined }); assert.equal(result.ok, false); assert.equal(result.error, 'unknown-command'); }); @@ -294,7 +294,7 @@ describe('DynamicPolicyResolverFactory', () => { assert.ok(resolver instanceof StaticPolicyResolver); // Test that ping command is classified as keyless - const pingResult = resolver.resolvePolicy('ping'); + const pingResult = resolver.resolvePolicy({ command: 'ping', subcommand: undefined }); if (pingResult.ok) { assert.equal(pingResult.value.request, REQUEST_POLICIES_WITH_DEFAULTS.ALL_SHARDS); assert.equal(pingResult.value.response, RESPONSE_POLICIES_WITH_DEFAULTS.ALL_SUCCEEDED); @@ -303,7 +303,7 @@ describe('DynamicPolicyResolverFactory', () => { } // Test that get command is classified as keyed - const getResult = resolver.resolvePolicy('get'); + const getResult = resolver.resolvePolicy({ command: 'get', subcommand: undefined }); if (getResult.ok) { assert.equal(getResult.value.request, REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED); assert.equal(getResult.value.response, RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED); @@ -312,7 +312,7 @@ describe('DynamicPolicyResolverFactory', () => { } // Test that dbsize command uses explicit policies if available - const dbsizeResult = resolver.resolvePolicy('dbsize'); + const dbsizeResult = resolver.resolvePolicy({ command: 'dbsize', subcommand: undefined }); if (dbsizeResult.ok) { assert.ok( diff --git a/packages/client/lib/cluster/request-response-policies/static-policy-resolver.spec.ts b/packages/client/lib/cluster/request-response-policies/static-policy-resolver.spec.ts new file mode 100644 index 00000000000..b75109b2b16 --- /dev/null +++ b/packages/client/lib/cluster/request-response-policies/static-policy-resolver.spec.ts @@ -0,0 +1,122 @@ +import { strict as assert } from 'node:assert'; +import { + StaticPolicyResolver, + REQUEST_POLICIES_WITH_DEFAULTS, + RESPONSE_POLICIES_WITH_DEFAULTS +} from '.'; + +describe('StaticPolicyResolver', () => { + const resolver = new StaticPolicyResolver(); + + describe('subcommand detection', () => { + it('FT.SEARCH: second arg is an index name, not a subcommand', () => { + const result = resolver.resolvePolicy({ command: 'FT.SEARCH', subcommand: 'my-index' }); + assert.equal(result.ok, true); + if (result.ok) { + // FT.SEARCH has no subcommands declared → falls back to parent policy + assert.equal(result.value.request, REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS); + assert.equal(result.value.response, RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS); + } + }); + + it('MEMORY USAGE: USAGE is a declared subcommand → uses subcommand policy', () => { + const result = resolver.resolvePolicy({ command: 'MEMORY', subcommand: 'USAGE' }); + assert.equal(result.ok, true); + if (result.ok) { + // MEMORY default is keyless, MEMORY USAGE is keyed + assert.equal(result.value.request, REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED); + assert.equal(result.value.response, RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED); + assert.equal(result.value.isKeyless, false); + } + }); + + it('GET key: second arg is a key, not a subcommand', () => { + const result = resolver.resolvePolicy({ command: 'GET', subcommand: 'foo' }); + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.value.request, REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED); + assert.equal(result.value.response, RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED); + } + }); + + it('COMMAND INFO: INFO is a declared subcommand', () => { + const result = resolver.resolvePolicy({ command: 'COMMAND', subcommand: 'INFO' }); + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.value.request, REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS); + assert.equal(result.value.response, RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS); + assert.equal(result.value.isKeyless, true); + } + }); + + it('FT.SUGADD: keyed module command, no subcommand declared', () => { + const result = resolver.resolvePolicy({ command: 'FT.SUGADD', subcommand: 'mydict' }); + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.value.request, REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED); + assert.equal(result.value.response, RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED); + assert.equal(result.value.isKeyless, false); + } + }); + + it('undefined subcommand on a command with declared subcommands → parent policy', () => { + const result = resolver.resolvePolicy({ command: 'MEMORY', subcommand: undefined }); + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.value.request, REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS); + assert.equal(result.value.response, RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS); + } + }); + }); + + describe('casing', () => { + it('module name uppercase resolves same as lowercase', () => { + const a = resolver.resolvePolicy({ command: 'FT.SEARCH', subcommand: undefined }); + const b = resolver.resolvePolicy({ command: 'ft.search', subcommand: undefined }); + assert.deepEqual(a, b); + }); + + it('subcommand uppercase resolves same as lowercase', () => { + const a = resolver.resolvePolicy({ command: 'MEMORY', subcommand: 'USAGE' }); + const b = resolver.resolvePolicy({ command: 'memory', subcommand: 'usage' }); + assert.deepEqual(a, b); + }); + }); + + describe('errors', () => { + it('unknown command in std module', () => { + const r = resolver.resolvePolicy({ command: 'definitelynotacommand', subcommand: undefined }); + assert.equal(r.ok, false); + if (!r.ok) assert.equal(r.error, 'unknown-command'); + }); + + it('unknown module', () => { + const r = resolver.resolvePolicy({ command: 'fakemodule.something', subcommand: undefined }); + assert.equal(r.ok, false); + if (!r.ok) assert.equal(r.error, 'unknown-module'); + }); + + it('too many dots', () => { + const r = resolver.resolvePolicy({ command: 'a.b.c', subcommand: undefined }); + assert.equal(r.ok, false); + if (!r.ok) assert.equal(r.error, 'wrong-command-or-module-name'); + }); + }); + + describe('fallback', () => { + it('falls back to provided resolver on unknown command', () => { + const fallback = new StaticPolicyResolver({ + std: { + customping: { + request: REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS, + response: RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS, + isKeyless: true + } + } + }); + const chained = resolver.withFallback(fallback); + const r = chained.resolvePolicy({ command: 'customping', subcommand: undefined }); + assert.equal(r.ok, true); + }); + }); +}); diff --git a/packages/client/lib/cluster/request-response-policies/static-policy-resolver.ts b/packages/client/lib/cluster/request-response-policies/static-policy-resolver.ts index 6847ef735d6..4efc5c0254c 100644 --- a/packages/client/lib/cluster/request-response-policies/static-policy-resolver.ts +++ b/packages/client/lib/cluster/request-response-policies/static-policy-resolver.ts @@ -1,14 +1,38 @@ -import type { PolicyResult, PolicyResolver } from './types'; +import type { PolicyResult, PolicyResolver, ModulePolicyRecords, CommandPolicyRecords } from './types'; import { POLICIES } from './static-policies-data'; import { CommandIdentifier } from '../../client/parser'; +import type { CommandPolicies } from './policies-constants'; + +const lowercaseCommandPolicies = (policies: CommandPolicies): CommandPolicies => { + if (!policies.subcommands) return policies; + const subcommands: Record = {}; + for (const [name, sub] of Object.entries(policies.subcommands)) { + subcommands[name.toLowerCase()] = lowercaseCommandPolicies(sub); + } + return { ...policies, subcommands }; +}; + +const lowercaseModulePolicies = (policies: ModulePolicyRecords): ModulePolicyRecords => { + const out: ModulePolicyRecords = {}; + for (const [moduleName, commands] of Object.entries(policies)) { + const normalized: CommandPolicyRecords = {}; + for (const [commandName, policy] of Object.entries(commands)) { + normalized[commandName.toLowerCase()] = lowercaseCommandPolicies(policy); + } + out[moduleName.toLowerCase()] = normalized; + } + return out; +}; export class StaticPolicyResolver implements PolicyResolver { private readonly fallbackResolver: PolicyResolver | null = null; + private readonly policies: ModulePolicyRecords; constructor( - private readonly policies = POLICIES, + policies: ModulePolicyRecords = POLICIES, fallbackResolver?: PolicyResolver ) { + this.policies = lowercaseModulePolicies(policies); this.fallbackResolver = fallbackResolver || null; } @@ -25,7 +49,6 @@ export class StaticPolicyResolver implements PolicyResolver { resolvePolicy(commandIdentifier: CommandIdentifier): PolicyResult { const parts = commandIdentifier.command.toLowerCase().split('.'); - if (parts.length > 2) { return { ok: false, error: 'wrong-command-or-module-name' }; } @@ -34,8 +57,6 @@ export class StaticPolicyResolver implements PolicyResolver { ? ['std', parts[0]] : parts; - console.log(`module name `, moduleName, `command name `, commandName); - if (!this.policies[moduleName]) { if (this.fallbackResolver) { return this.fallbackResolver.resolvePolicy(commandIdentifier); @@ -50,7 +71,6 @@ export class StaticPolicyResolver implements PolicyResolver { } if (!this.policies[moduleName][commandName]) { - // Try fallback resolver if available if (this.fallbackResolver) { return this.fallbackResolver.resolvePolicy(commandIdentifier); } @@ -59,19 +79,19 @@ export class StaticPolicyResolver implements PolicyResolver { const policy = this.policies[moduleName][commandName]; - if(policy.subcommands) { - const subcommandPolicy = policy.subcommands[commandIdentifier.subcommand]; - if(subcommandPolicy) { + if (policy.subcommands && commandIdentifier.subcommand !== undefined) { + const subcommandPolicy = policy.subcommands[commandIdentifier.subcommand.toLowerCase()]; + if (subcommandPolicy) { return { ok: true, value: subcommandPolicy - } + }; } } return { ok: true, value: policy - } + }; } } From 55b5586acd9aa35257fce597d77fa74333f6a8a3 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Mon, 8 Jun 2026 16:02:20 +0300 Subject: [PATCH 15/28] align Search policy entries with the HLD command routing table The static policy table had three problems for the Search module: - The `FT` module entries were upper-cased while every other module uses lower-case keys. - A second `search` module held three RediSearch cluster-admin commands (`CLUSTERSET`, `CLUSTERINFO`, `CLUSTERREFRESH`) that are not part of the public FT.* surface. - A `_FT` module held private debug subcommands (`_FT.CONFIG`, `_FT.DEBUG`, etc.) and a long list of deprecated / private FT.* entries (`_LIST`, `_CREATEIFNX`, `_DROPIFX`, `_DROPINDEXIFX`, `_ALIASADDIFNX`, `_ALIASDELIFX`, `_ALTERIFNX`, `ADD`, `DEL`, `GET`, `MGET`, `SYNADD`) which are not user-facing and shouldn't participate in policy resolution. Replace those three modules with a single lower-case `ft` module containing exactly the 25 commands listed in the HLD Command Routing Policy Table, each with the policy the client should apply per the HLD client-interpretation column. `ft.cursor` is left at `default-keyless`/`default-keyless` for now; the HLD specifies `special` (sticky cursor) but that depends on the not-yet-built special-handler registry and cursor binding map. Add `ft-policies.spec.ts`, a parameterised test that asserts every HLD command resolves to the prescribed `request`/`response` pair and that the dropped debug / admin commands no longer resolve. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ft-policies.spec.ts | 85 +++++ .../static-policies-data.ts | 359 ++---------------- 2 files changed, 114 insertions(+), 330 deletions(-) create mode 100644 packages/client/lib/cluster/request-response-policies/ft-policies.spec.ts diff --git a/packages/client/lib/cluster/request-response-policies/ft-policies.spec.ts b/packages/client/lib/cluster/request-response-policies/ft-policies.spec.ts new file mode 100644 index 00000000000..f95b33adf5b --- /dev/null +++ b/packages/client/lib/cluster/request-response-policies/ft-policies.spec.ts @@ -0,0 +1,85 @@ +import { strict as assert } from 'node:assert'; +import { + StaticPolicyResolver, + REQUEST_POLICIES_WITH_DEFAULTS, + RESPONSE_POLICIES_WITH_DEFAULTS +} from '.'; + +/** + * Snapshot of the HLD "Command Routing Policy Table" — client interpretation column. + * + * `default(keyless)` and `default(hashslot)` from the HLD denote "no policy + * declared"; the client routes by the default rules. They are stored here as + * `default-keyless` / `default-keyed` to match the resolver vocabulary. + * + * `ft.cursor` is mis-labeled `default-keyless` in this table on purpose. + * The HLD specifies `special` request_policy (sticky cursor), which requires + * the special-handler registry and cursor binding state. That work is tracked + * in its own story — once those land, the entry below flips to `special` and + * an extra cursor-routing test goes with it. + */ +const KEYLESS = { + request: REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS, + response: RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS, + isKeyless: true +} as const; + +const KEYED = { + request: REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED, + response: RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED, + isKeyless: false +} as const; + +const HLD_FT_TABLE: Record = { + 'FT.CREATE': KEYLESS, + 'FT.SEARCH': KEYLESS, + 'FT.AGGREGATE': KEYLESS, + 'FT.DICTADD': KEYLESS, + 'FT.DICTDEL': KEYLESS, + 'FT.DICTDUMP': KEYLESS, + 'FT.SUGLEN': KEYED, + 'FT.CURSOR': KEYLESS, + 'FT.SUGADD': KEYED, + 'FT.SUGGET': KEYED, + 'FT.SUGDEL': KEYED, + 'FT.SPELLCHECK': KEYLESS, + 'FT.EXPLAIN': KEYLESS, + 'FT.EXPLAINCLI': KEYLESS, + 'FT.ALIASADD': KEYLESS, + 'FT.ALIASUPDATE': KEYLESS, + 'FT.ALIASDEL': KEYLESS, + 'FT.INFO': KEYLESS, + 'FT.TAGVALS': KEYLESS, + 'FT.SYNDUMP': KEYLESS, + 'FT.SYNUPDATE': KEYLESS, + 'FT.PROFILE': KEYLESS, + 'FT.ALTER': KEYLESS, + 'FT.DROPINDEX': KEYLESS, + 'FT.DROP': KEYLESS +}; + +describe('FT.* policy table matches the HLD', () => { + const resolver = new StaticPolicyResolver(); + + for (const [command, expected] of Object.entries(HLD_FT_TABLE)) { + it(`${command} resolves to the HLD policy`, () => { + const result = resolver.resolvePolicy({ command, subcommand: undefined }); + assert.equal(result.ok, true, `expected ${command} to resolve`); + if (result.ok) { + assert.equal(result.value.request, expected.request, `${command} request`); + assert.equal(result.value.response, expected.response, `${command} response`); + assert.equal(result.value.isKeyless, expected.isKeyless, `${command} isKeyless`); + } + }); + } + + it('does not expose dropped debug commands (e.g. FT._LIST)', () => { + const result = resolver.resolvePolicy({ command: 'FT._LIST', subcommand: undefined }); + assert.equal(result.ok, false); + }); + + it('does not expose stray cluster-admin commands (e.g. FT.CLUSTERSET)', () => { + const result = resolver.resolvePolicy({ command: 'FT.CLUSTERSET', subcommand: undefined }); + assert.equal(result.ok, false); + }); +}); diff --git a/packages/client/lib/cluster/request-response-policies/static-policies-data.ts b/packages/client/lib/cluster/request-response-policies/static-policies-data.ts index 327541fcd6c..ef2c354ef39 100644 --- a/packages/client/lib/cluster/request-response-policies/static-policies-data.ts +++ b/packages/client/lib/cluster/request-response-policies/static-policies-data.ts @@ -1943,188 +1943,128 @@ export const POLICIES: ModulePolicyRecords = { "isKeyless": false } }, - "FT": { - "ALIASADD": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "ALIASUPDATE": { + "ft": { + "create": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "SPELLCHECK": { + "search": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "DICTADD": { + "aggregate": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "_DROPIFX": { + "dictadd": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "DROP": { + "dictdel": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "EXPLAINCLI": { + "dictdump": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "SUGGET": { + "suglen": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "SYNADD": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "TAGVALS": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "EXPLAIN": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "ALTER": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "CURSOR": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "_LIST": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "_CREATEIFNX": { + "cursor": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "DICTDEL": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "ADD": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "ALIASDEL": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "SEARCH": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "SYNDUMP": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true + "sugadd": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false }, - "SUGDEL": { + "sugget": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "SUGADD": { + "sugdel": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "_DROPINDEXIFX": { + "spellcheck": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "SYNUPDATE": { + "explain": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "MGET": { + "explaincli": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "GET": { + "aliasadd": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "AGGREGATE": { + "aliasupdate": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "SUGLEN": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "DEL": { + "aliasdel": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "_ALIASDELIFX": { + "info": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "_ALIASADDIFNX": { + "tagvals": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "DROPINDEX": { + "syndump": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "_ALTERIFNX": { + "synupdate": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "PROFILE": { + "profile": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "CREATE": { + "alter": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "INFO": { + "dropindex": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "DICTDUMP": { + "drop": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true @@ -2614,247 +2554,6 @@ export const POLICIES: ModulePolicyRecords = { "isKeyless": false } }, - "search": { - "CLUSTERSET": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "CLUSTERINFO": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "CLUSTERREFRESH": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - } - }, - "_FT": { - "CONFIG": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "SAFEADD": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "DEBUG": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true, - "subcommands": { - "INFO_TAGIDX": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "VECSIM_INFO": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "SPEC_INVIDXES_INFO": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "DUMP_HNSW": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "TTL_PAUSE": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "GC_FORCEBGINVOKE": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "SHARD_CONNECTION_STATES": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "GC_STOP_SCHEDULE": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "DUMP_TAGIDX": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "SET_MONITOR_EXPIRATION": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "DUMP_TERMS": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "GC_CONTINUE_SCHEDULE": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "DUMP_NUMIDX": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "GC_CLEAN_NUMERIC": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "HELP": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "FT.AGGREGATE": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "TTL": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "DUMP_SUFFIX_TRIE": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "DOCINFO": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "_FT.AGGREGATE": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "WORKERS": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "RESUME_TOPOLOGY_UPDATER": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "DUMP_NUMIDXTREE": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "DUMP_INVIDX": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "CLEAR_PENDING_TOPOLOGY": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "_FT.SEARCH": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "BG_SCAN_CONTROLLER": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "PAUSE_TOPOLOGY_UPDATER": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "GIT_SHA": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "IDTODOCID": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "INVIDX_SUMMARY": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "DUMP_GEOMIDX": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "GC_FORCEINVOKE": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "TTL_EXPIRE": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "FT.SEARCH": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "DUMP_PHONETIC_HASH": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "DOCIDTOID": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "DUMP_PREFIX_TRIE": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "DELETE_LOCAL_CURSORS": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "GC_WAIT_FOR_JOBS": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "NUMIDX_SUMMARY": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - } - } - } - }, "timeseries": { "REFRESHCLUSTER": { "request": "default-keyless", From 2ecb8af5619de198efc03743d64436ddb0f1fc97 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Thu, 11 Jun 2026 13:17:55 +0300 Subject: [PATCH 16/28] feat(client): add static-policies-data generator - extract DynamicPolicyResolverFactory.buildModulePolicyRecords so the generator and the runtime resolver share one derivation path - scripts/generate-static-policies-data.ts (npm run generate:policies -- ) regenerates static-policies-data.ts from a live COMMAND reply; keys lowercased and sorted for stable diffs - HLD curation codified in scripts/static-policies-overrides.ts: internal/deprecated FT commands and _ft/search cluster-admin modules excluded, ft.cursor pinned to default-keyless until the special-handler registry lands - data regenerated against Redis 8.8.0: adds 42 new std commands (msetex was missing entirely), json.debug flips to keyless per server-reported key specs Co-Authored-By: Claude Fable 5 --- .../dynamic-policy-resolver-factory.ts | 15 +- .../static-policies-data.ts | 2556 +++++++++-------- packages/client/package.json | 1 + .../scripts/generate-static-policies-data.ts | 110 + .../scripts/static-policies-overrides.ts | 54 + 5 files changed, 1584 insertions(+), 1152 deletions(-) create mode 100644 packages/client/scripts/generate-static-policies-data.ts create mode 100644 packages/client/scripts/static-policies-overrides.ts diff --git a/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver-factory.ts b/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver-factory.ts index 750ee491f8a..e6fb766a824 100644 --- a/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver-factory.ts +++ b/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver-factory.ts @@ -29,6 +29,19 @@ export class DynamicPolicyResolverFactory { fallbackResolver?: PolicyResolver ): Promise { const commands = await commandFetcher(); + const policies = DynamicPolicyResolverFactory.buildModulePolicyRecords(commands); + + return new StaticPolicyResolver(policies, fallbackResolver); + } + + /** + * Builds module->command policy records from COMMAND replies. + * + * Also used by `scripts/generate-static-policies-data.ts` to regenerate + * `static-policies-data.ts`, so the static data is guaranteed to match what + * this factory would derive at runtime. + */ + static buildModulePolicyRecords(commands: Array): ModulePolicyRecords { const policies: ModulePolicyRecords = {}; for (const command of commands) { @@ -51,7 +64,7 @@ export class DynamicPolicyResolverFactory { policies[moduleName][commandName] = commandPolicies; } - return new StaticPolicyResolver(policies, fallbackResolver); + return policies; } /** diff --git a/packages/client/lib/cluster/request-response-policies/static-policies-data.ts b/packages/client/lib/cluster/request-response-policies/static-policies-data.ts index ef2c354ef39..525d5c25a96 100644 --- a/packages/client/lib/cluster/request-response-policies/static-policies-data.ts +++ b/packages/client/lib/cluster/request-response-policies/static-policies-data.ts @@ -1,611 +1,515 @@ +// This file is auto-generated by scripts/generate-static-policies-data.ts — do not edit manually. +// Source: Redis 8.8.0, 415 commands. import { ModulePolicyRecords } from "./types"; export const POLICIES: ModulePolicyRecords = { - "std": { - "getrange": { + "ft": { + "aggregate": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "aliasadd": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "aliasdel": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "aliasupdate": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "alter": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "create": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "cursor": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "dictadd": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "dictdel": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "dictdump": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "drop": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "dropindex": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "explain": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "explaincli": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "info": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "profile": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "search": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "spellcheck": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "sugadd": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "incr": { + "sugdel": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "zlexcount": { + "sugget": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "hincrbyfloat": { + "suglen": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "zinterstore": { + "syndump": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "synupdate": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "tagvals": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + } + }, + "bf": { + "add": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "zpopmax": { + "card": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "zdiff": { + "debug": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "waitaof": { - "request": "all_shards", - "response": "agg_min", - "isKeyless": true - }, - "psubscribe": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "geodist": { + "exists": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "hdel": { + "info": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "type": { + "insert": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "flushdb": { - "request": "all_shards", - "response": "all_succeeded", - "isKeyless": true - }, - "lpos": { + "loadchunk": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "xreadgroup": { + "madd": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "pttl": { + "mexists": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "sdiff": { + "reserve": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "hkeys": { + "scandump": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false - }, - "eval": { + } + }, + "cf": { + "add": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "substr": { + "addnx": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "zremrangebyrank": { + "compact": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "zcount": { + "count": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "memory": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true, - "subcommands": { - "purge": { - "request": "all_shards", - "response": "all_succeeded", - "isKeyless": true - }, - "doctor": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "stats": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "help": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "malloc-stats": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "usage": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - } - } + "debug": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false }, - "hgetdel": { + "del": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "hpersist": { + "exists": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "persist": { + "info": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "llen": { + "insert": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "info": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true + "insertnx": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false }, - "failover": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true + "loadchunk": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false }, - "hello": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true + "mexists": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false }, - "exec": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true + "reserve": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false }, - "hpexpiretime": { + "scandump": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + } + }, + "cms": { + "incrby": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "acl": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true, - "subcommands": { - "deluser": { - "request": "all_nodes", - "response": "all_succeeded", - "isKeyless": true - }, - "genpass": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "dryrun": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "save": { - "request": "all_nodes", - "response": "all_succeeded", - "isKeyless": true - }, - "cat": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "help": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "users": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "whoami": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "list": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "load": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "log": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "setuser": { - "request": "all_nodes", - "response": "all_succeeded", - "isKeyless": true - }, - "getuser": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - } - } - }, - "sort": { + "info": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "latency": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true, - "subcommands": { - "history": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "reset": { - "request": "all_nodes", - "response": "agg_sum", - "isKeyless": true - }, - "doctor": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "histogram": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "latest": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "help": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "graph": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - } - } - }, - "zincrby": { + "initbydim": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "sync": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "rpushx": { + "initbyprob": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "xtrim": { + "merge": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "auth": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "echo": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true + "query": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + } + }, + "json": { + "arrappend": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false }, - "georadiusbymember": { + "arrindex": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "zcard": { + "arrinsert": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "setnx": { + "arrlen": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "hsetex": { + "arrpop": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "restore": { + "arrtrim": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "geoadd": { + "clear": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "subscribe": { + "debug": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "getex": { + "del": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "zremrangebyscore": { + "forget": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "hmset": { + "get": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "zremrangebylex": { + "merge": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "watch": { + "mget": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "fcall": { + "mset": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "lset": { + "numincrby": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "hpttl": { + "nummultby": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "zintercard": { + "numpowby": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "sort_ro": { + "objkeys": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "zrandmember": { + "objlen": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "discard": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "zpopmin": { + "resp": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "scard": { + "set": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "hrandfield": { + "strappend": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "hstrlen": { + "strlen": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "xinfo": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true, - "subcommands": { - "groups": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "consumers": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "stream": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "help": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - } - } - }, - "flushall": { - "request": "all_shards", - "response": "all_succeeded", - "isKeyless": true - }, - "linsert": { + "toggle": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "geopos": { + "type": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false - }, - "pexpiretime": { + } + }, + "std": { + "vadd": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "sdiffstore": { + "vcard": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "ping": { - "request": "all_shards", - "response": "all_succeeded", - "isKeyless": true - }, - "zscan": { + "vdim": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "hget": { + "vemb": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "zunionstore": { + "vgetattr": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "ssubscribe": { + "vinfo": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "zrevrange": { + "vismember": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "slaveof": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "bitcount": { + "vlinks": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "evalsha_ro": { + "vrandmember": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "lpushx": { + "vrange": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "sinterstore": { + "vrem": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "touch": { - "request": "multi_shard", - "response": "agg_sum", - "isKeyless": false - }, - "bgsave": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "pfcount": { + "vsetattr": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "zdiffstore": { + "vsim": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "pubsub": { + "acl": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true, "subcommands": { - "shardnumsub": { + "cat": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "numpat": { + "deluser": { + "request": "all_nodes", + "response": "all_succeeded", + "isKeyless": true + }, + "dryrun": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "numsub": { + "genpass": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "channels": { + "getuser": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true @@ -615,163 +519,219 @@ export const POLICIES: ModulePolicyRecords = { "response": "default-keyless", "isKeyless": true }, - "shardchannels": { + "list": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "load": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "log": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "save": { + "request": "all_nodes", + "response": "all_succeeded", + "isKeyless": true + }, + "setuser": { + "request": "all_nodes", + "response": "all_succeeded", + "isKeyless": true + }, + "users": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "whoami": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true } } }, - "lindex": { + "append": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "georadiusbymember_ro": { + "arcount": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "geohash": { + "ardel": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "xgroup": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true, - "subcommands": { - "setid": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "create": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "destroy": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "help": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "delconsumer": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "createconsumer": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - } - } + "ardelrange": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false }, - "xadd": { + "arget": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "xrange": { + "argetrange": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "zrange": { + "argrep": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "sscan": { + "arinfo": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "randomkey": { - "request": "all_shards", - "response": "special", - "isKeyless": true + "arinsert": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false }, - "bzpopmax": { + "arlastitems": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "bitfield_ro": { + "arlen": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "ttl": { + "armget": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "hsetnx": { + "armset": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "rename": { + "arnext": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "shutdown": { + "arop": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "arring": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "arscan": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "arseek": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "arset": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "asking": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "strlen": { + "auth": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "bgrewriteaof": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "bgsave": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "bitcount": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "hpexpireat": { + "bitfield": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "slowlog": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true, - "subcommands": { - "get": { - "request": "all_nodes", - "response": "default-keyless", - "isKeyless": true - }, - "reset": { - "request": "all_nodes", - "response": "all_succeeded", - "isKeyless": true - }, - "len": { - "request": "all_nodes", - "response": "agg_sum", - "isKeyless": true - }, - "help": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - } - } + "bitfield_ro": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false }, - "setex": { + "bitop": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "xack": { + "bitpos": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "blmove": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "blmpop": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "blpop": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "brpop": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "brpoplpush": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "bzmpop": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "bzpopmax": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "bzpopmin": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false @@ -786,246 +746,149 @@ export const POLICIES: ModulePolicyRecords = { "response": "default-keyless", "isKeyless": true }, - "setinfo": { - "request": "all_nodes", - "response": "all_succeeded", - "isKeyless": true - }, - "setname": { - "request": "all_nodes", - "response": "all_succeeded", + "getname": { + "request": "default-keyless", + "response": "default-keyless", "isKeyless": true }, - "list": { + "getredir": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "kill": { + "help": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "no-evict": { + "id": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "reply": { + "info": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "tracking": { + "kill": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "unblock": { + "list": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "trackinginfo": { + "no-evict": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "unpause": { + "no-touch": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "info": { + "pause": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "id": { + "reply": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "getredir": { - "request": "default-keyless", - "response": "default-keyless", + "setinfo": { + "request": "all_nodes", + "response": "all_succeeded", "isKeyless": true }, - "help": { + "setname": { + "request": "all_nodes", + "response": "all_succeeded", + "isKeyless": true + }, + "tracking": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "pause": { + "trackinginfo": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "getname": { + "unblock": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "no-touch": { + "unpause": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true } } }, - "unsubscribe": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "pexpireat": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "hgetall": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "hlen": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "multi": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "zrevrangebyscore": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "psetex": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "xsetid": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "decr": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "rpop": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "xautoclaim": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "zadd": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "zrangestore": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "get": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "blpop": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "replconf": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "keys": { - "request": "all_shards", - "response": "default-keyless", - "isKeyless": true - }, - "command": { + "cluster": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true, "subcommands": { - "list": { + "addslots": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "getkeysandflags": { + "addslotsrange": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "info": { + "bumpepoch": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "count": { + "count-failure-reports": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "getkeys": { + "countkeysinslot": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "help": { + "delslots": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "docs": { + "delslotsrange": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true - } - } - }, - "exists": { - "request": "multi_shard", - "response": "agg_sum", - "isKeyless": false - }, - "sismember": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "function": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true, - "subcommands": { - "dump": { + }, + "failover": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "delete": { - "request": "all_shards", - "response": "all_succeeded", + "flushslots": { + "request": "default-keyless", + "response": "default-keyless", "isKeyless": true }, - "stats": { + "forget": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "getkeysinslot": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true @@ -1035,224 +898,520 @@ export const POLICIES: ModulePolicyRecords = { "response": "default-keyless", "isKeyless": true }, - "restore": { - "request": "all_shards", - "response": "all_succeeded", + "info": { + "request": "default-keyless", + "response": "default-keyless", "isKeyless": true }, - "list": { + "keyslot": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "kill": { - "request": "all_shards", - "response": "one_succeeded", + "links": { + "request": "default-keyless", + "response": "default-keyless", "isKeyless": true }, - "load": { - "request": "all_shards", - "response": "all_succeeded", + "meet": { + "request": "default-keyless", + "response": "default-keyless", "isKeyless": true }, - "flush": { - "request": "all_shards", - "response": "all_succeeded", + "migration": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "myid": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "myshardid": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "nodes": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "replicas": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "replicate": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "reset": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "saveconfig": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "set-config-epoch": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "setslot": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "shards": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "slaves": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "slot-stats": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "slots": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "syncslots": { + "request": "default-keyless", + "response": "default-keyless", "isKeyless": true } } }, - "xread": { + "command": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "subcommands": { + "count": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "docs": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "getkeys": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "getkeysandflags": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "help": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "info": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "list": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + } + } + }, + "config": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "subcommands": { + "get": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "help": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "resetstat": { + "request": "all_nodes", + "response": "all_succeeded", + "isKeyless": true + }, + "rewrite": { + "request": "all_nodes", + "response": "all_succeeded", + "isKeyless": true + }, + "set": { + "request": "all_nodes", + "response": "all_succeeded", + "isKeyless": true + } + } + }, + "copy": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "rpush": { + "dbsize": { + "request": "all_shards", + "response": "agg_sum", + "isKeyless": true + }, + "debug": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "decr": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "append": { + "decrby": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "lpop": { - "request": "default-keyed", - "response": "default-keyed", + "del": { + "request": "multi_shard", + "response": "agg_sum", "isKeyless": false }, - "set": { + "delex": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "move": { + "digest": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "expireat": { + "discard": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "dump": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "pexpire": { + "echo": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "eval": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "brpoplpush": { + "eval_ro": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "del": { - "request": "multi_shard", - "response": "agg_sum", + "evalsha": { + "request": "default-keyed", + "response": "default-keyed", "isKeyless": false }, - "lmpop": { + "evalsha_ro": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "setrange": { + "exec": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "exists": { + "request": "multi_shard", + "response": "agg_sum", + "isKeyless": false + }, + "expire": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "sunsubscribe": { + "expireat": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "migrate": { + "expiretime": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "scan": { + "failover": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "lcs": { + "fcall": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "quit": { - "request": "default-keyless", - "response": "default-keyless", + "fcall_ro": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "flushall": { + "request": "all_shards", + "response": "all_succeeded", "isKeyless": true }, - "cluster": { + "flushdb": { + "request": "all_shards", + "response": "all_succeeded", + "isKeyless": true + }, + "function": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true, "subcommands": { - "addslotsrange": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "delslots": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "setslot": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "slots": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "links": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "delslotsrange": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "addslots": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "keyslot": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "meet": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "countkeysinslot": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "count-failure-reports": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "shards": { - "request": "default-keyless", - "response": "default-keyless", + "delete": { + "request": "all_shards", + "response": "all_succeeded", "isKeyless": true }, - "myshardid": { + "dump": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "myid": { - "request": "default-keyless", - "response": "default-keyless", + "flush": { + "request": "all_shards", + "response": "all_succeeded", "isKeyless": true }, - "reset": { + "help": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "flushslots": { - "request": "default-keyless", - "response": "default-keyless", + "kill": { + "request": "all_shards", + "response": "one_succeeded", "isKeyless": true }, - "slaves": { + "list": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "info": { - "request": "default-keyless", - "response": "default-keyless", + "load": { + "request": "all_shards", + "response": "all_succeeded", "isKeyless": true }, - "replicate": { - "request": "default-keyless", - "response": "default-keyless", + "restore": { + "request": "all_shards", + "response": "all_succeeded", "isKeyless": true }, - "nodes": { + "stats": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true - }, - "failover": { + } + } + }, + "geoadd": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "geodist": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "geohash": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "geopos": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "georadius": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "georadius_ro": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "georadiusbymember": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "georadiusbymember_ro": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "geosearch": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "geosearchstore": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "get": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "getbit": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "getdel": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "getex": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "getrange": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "getset": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hdel": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hello": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "hexists": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hexpire": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hexpireat": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hexpiretime": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hget": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hgetall": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hgetdel": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hgetex": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hincrby": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hincrbyfloat": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hkeys": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hlen": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hmget": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hmset": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hotkeys": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "subcommands": { + "get": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true @@ -1262,161 +1421,191 @@ export const POLICIES: ModulePolicyRecords = { "response": "default-keyless", "isKeyless": true }, - "saveconfig": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "getkeysinslot": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "set-config-epoch": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "bumpepoch": { - "request": "default-keyless", + "reset": { + "request": "special", "response": "default-keyless", "isKeyless": true }, - "replicas": { - "request": "default-keyless", + "start": { + "request": "special", "response": "default-keyless", "isKeyless": true }, - "forget": { - "request": "default-keyless", + "stop": { + "request": "special", "response": "default-keyless", "isKeyless": true } } }, - "spop": { + "hpersist": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hpexpire": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "hpexpireat": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "lrange": { + "hpexpiretime": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "xpending": { + "hpttl": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "sunionstore": { + "hrandfield": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "select": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true + "hscan": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false }, - "sintercard": { + "hset": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "srandmember": { + "hsetex": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "bzmpop": { + "hsetnx": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "pfadd": { + "hstrlen": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "msetnx": { + "httl": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "expiretime": { + "hvals": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "script": { + "incr": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "incrby": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "incrbyfloat": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "increx": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "info": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "keys": { + "request": "all_shards", + "response": "default-keyless", + "isKeyless": true + }, + "lastsave": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "latency": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true, "subcommands": { - "load": { - "request": "all_nodes", - "response": "all_succeeded", + "doctor": { + "request": "default-keyless", + "response": "default-keyless", "isKeyless": true }, - "kill": { - "request": "all_shards", - "response": "one_succeeded", + "graph": { + "request": "default-keyless", + "response": "default-keyless", "isKeyless": true }, - "exists": { - "request": "all_shards", - "response": "agg_logical_and", + "help": { + "request": "default-keyless", + "response": "default-keyless", "isKeyless": true }, - "flush": { - "request": "all_nodes", - "response": "all_succeeded", + "histogram": { + "request": "default-keyless", + "response": "default-keyless", "isKeyless": true }, - "debug": { + "history": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "help": { + "latest": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true + }, + "reset": { + "request": "all_nodes", + "response": "agg_sum", + "isKeyless": true } } }, - "zrem": { + "lcs": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "save": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "smove": { + "lindex": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "spublish": { + "linsert": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "fcall_ro": { + "llen": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "lrem": { + "lmove": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "blmove": { + "lmpop": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false @@ -1426,82 +1615,89 @@ export const POLICIES: ModulePolicyRecords = { "response": "default-keyless", "isKeyless": true }, - "bzpopmin": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "hexpire": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "ltrim": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "asking": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "zrevrangebylex": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "restore-asking": { + "lpop": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "setbit": { + "lpos": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "smembers": { + "lpush": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "xlen": { + "lpushx": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "expire": { + "lrange": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "hexpireat": { + "lrem": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "srem": { + "lset": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "httl": { + "ltrim": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "lastsave": { + "memory": { "request": "default-keyless", "response": "default-keyless", - "isKeyless": true + "isKeyless": true, + "subcommands": { + "doctor": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "help": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "malloc-stats": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "purge": { + "request": "all_shards", + "response": "all_succeeded", + "isKeyless": true + }, + "stats": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "usage": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + } + } }, - "hmget": { - "request": "default-keyed", + "mget": { + "request": "multi_shard", "response": "default-keyed", "isKeyless": false }, - "hexists": { + "migrate": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false @@ -1511,12 +1707,12 @@ export const POLICIES: ModulePolicyRecords = { "response": "default-keyless", "isKeyless": true, "subcommands": { - "list": { + "help": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "unload": { + "list": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true @@ -1526,870 +1722,913 @@ export const POLICIES: ModulePolicyRecords = { "response": "default-keyless", "isKeyless": true }, - "help": { + "loadex": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "loadex": { + "unload": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true } } }, - "sadd": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, "monitor": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "geosearch": { + "move": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "copy": { - "request": "default-keyed", - "response": "default-keyed", + "mset": { + "request": "multi_shard", + "response": "all_succeeded", "isKeyless": false }, - "lmove": { - "request": "default-keyed", - "response": "default-keyed", + "msetex": { + "request": "multi_shard", + "response": "all_succeeded", "isKeyless": false }, - "publish": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "zscore": { + "msetnx": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "bgrewriteaof": { + "multi": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "zunion": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "hpexpire": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - }, - "config": { + "object": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true, "subcommands": { - "set": { - "request": "all_nodes", - "response": "all_succeeded", - "isKeyless": true - }, - "resetstat": { - "request": "all_nodes", - "response": "all_succeeded", - "isKeyless": true + "encoding": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false }, - "get": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true + "freq": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false }, "help": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "rewrite": { - "request": "all_nodes", - "response": "all_succeeded", - "isKeyless": true + "idletime": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "refcount": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false } } }, - "punsubscribe": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "zrangebylex": { + "persist": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "reset": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true + "pexpire": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false }, - "xclaim": { + "pexpireat": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "geosearchstore": { + "pexpiretime": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "sinter": { + "pfadd": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "pfdebug": { + "pfcount": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "hscan": { + "pfdebug": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "georadius_ro": { + "pfmerge": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "unwatch": { + "pfselftest": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "unlink": { - "request": "multi_shard", - "response": "agg_sum", - "isKeyless": false + "ping": { + "request": "all_shards", + "response": "all_succeeded", + "isKeyless": true }, - "renamenx": { + "psetex": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "brpop": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false + "psubscribe": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true }, - "zrevrank": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false + "psync": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true }, - "incrby": { + "pttl": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "hset": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false + "publish": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true }, - "object": { + "pubsub": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true, "subcommands": { - "encoding": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false + "channels": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true }, - "refcount": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false + "help": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true }, - "idletime": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false + "numpat": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true }, - "freq": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false + "numsub": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true }, - "help": { + "shardchannels": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "shardnumsub": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true } } }, - "time": { + "punsubscribe": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "zrangebyscore": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false + "quit": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true }, - "rpoplpush": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false + "randomkey": { + "request": "all_shards", + "response": "special", + "isKeyless": true }, - "hincrby": { + "readonly": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "readwrite": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "rename": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "zinter": { + "renamenx": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "role": { + "replconf": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "zrank": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false + "replicaof": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true }, - "pfselftest": { + "reset": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "hexpiretime": { + "restore": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "incrbyfloat": { + "restore-asking": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "zmscore": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false + "role": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true }, - "zmpop": { + "rpop": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "smismember": { + "rpoplpush": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "xrevrange": { + "rpush": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "bitpos": { + "rpushx": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "hgetex": { + "sadd": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "readonly": { + "save": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "readwrite": { + "scan": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "pfmerge": { + "scard": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "dbsize": { - "request": "all_shards", - "response": "agg_sum", - "isKeyless": true + "script": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "subcommands": { + "debug": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "exists": { + "request": "all_shards", + "response": "agg_logical_and", + "isKeyless": true + }, + "flush": { + "request": "all_nodes", + "response": "all_succeeded", + "isKeyless": true + }, + "help": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "kill": { + "request": "all_shards", + "response": "one_succeeded", + "isKeyless": true + }, + "load": { + "request": "all_nodes", + "response": "all_succeeded", + "isKeyless": true + } + } }, - "dump": { + "sdiff": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "mget": { - "request": "multi_shard", + "sdiffstore": { + "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "mset": { - "request": "multi_shard", - "response": "all_succeeded", + "select": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "set": { + "request": "default-keyed", + "response": "default-keyed", "isKeyless": false }, - "wait": { - "request": "all_shards", - "response": "agg_min", - "isKeyless": true + "setbit": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false }, - "xdel": { + "setex": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "evalsha": { + "setnx": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "bitop": { + "setrange": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "psync": { + "shutdown": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "getbit": { + "sinter": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "georadius": { + "sintercard": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "getdel": { + "sinterstore": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "swapdb": { + "sismember": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "slaveof": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "debug": { + "slowlog": { "request": "default-keyless", "response": "default-keyless", - "isKeyless": true + "isKeyless": true, + "subcommands": { + "get": { + "request": "all_nodes", + "response": "default-keyless", + "isKeyless": true + }, + "help": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "len": { + "request": "all_nodes", + "response": "agg_sum", + "isKeyless": true + }, + "reset": { + "request": "all_nodes", + "response": "all_succeeded", + "isKeyless": true + } + } }, - "hvals": { + "smembers": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "lpush": { + "smismember": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "replicaof": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "eval_ro": { + "smove": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "getset": { + "sort": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "decrby": { + "sort_ro": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "bitfield": { + "spop": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "blmpop": { + "spublish": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "sunion": { + "srandmember": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false - } - }, - "ft": { - "create": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "search": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "aggregate": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true }, - "dictadd": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true + "srem": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false }, - "dictdel": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true + "sscan": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false }, - "dictdump": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true + "ssubscribe": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false }, - "suglen": { + "strlen": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "cursor": { + "subscribe": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "sugadd": { + "substr": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "sugget": { + "sunion": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "sugdel": { + "sunionstore": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "spellcheck": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "explain": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true + "sunsubscribe": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false }, - "explaincli": { + "swapdb": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "aliasadd": { + "sync": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "aliasupdate": { + "time": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "aliasdel": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true + "touch": { + "request": "multi_shard", + "response": "agg_sum", + "isKeyless": false }, - "info": { + "trimslots": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "tagvals": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true + "ttl": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false }, - "syndump": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true + "type": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false }, - "synupdate": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true + "unlink": { + "request": "multi_shard", + "response": "agg_sum", + "isKeyless": false }, - "profile": { + "unsubscribe": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "alter": { + "unwatch": { "request": "default-keyless", "response": "default-keyless", "isKeyless": true }, - "dropindex": { - "request": "default-keyless", - "response": "default-keyless", + "wait": { + "request": "all_shards", + "response": "agg_min", "isKeyless": true }, - "drop": { - "request": "default-keyless", - "response": "default-keyless", + "waitaof": { + "request": "all_shards", + "response": "agg_min", "isKeyless": true - } - }, - "json": { - "strlen": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false }, - "mget": { + "watch": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "set": { + "xack": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "clear": { + "xackdel": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "arrpop": { + "xadd": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "arrinsert": { + "xautoclaim": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "objkeys": { + "xcfgset": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "type": { + "xclaim": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "debug": { + "xdel": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "strappend": { + "xdelex": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "get": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false + "xgroup": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "subcommands": { + "create": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "createconsumer": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "delconsumer": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "destroy": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "help": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "setid": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + } + } }, - "arrtrim": { + "xidmprecord": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "del": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false + "xinfo": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true, + "subcommands": { + "consumers": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "groups": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + }, + "help": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "stream": { + "request": "default-keyed", + "response": "default-keyed", + "isKeyless": false + } + } }, - "mset": { + "xlen": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "numincrby": { + "xnack": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "forget": { + "xpending": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "arrlen": { + "xrange": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "arrindex": { + "xread": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "nummultby": { + "xreadgroup": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "objlen": { + "xrevrange": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "numpowby": { + "xsetid": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "arrappend": { + "xtrim": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "merge": { + "zadd": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "toggle": { + "zcard": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "resp": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false - } - }, - "cms": { - "initbyprob": { + "zcount": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "query": { + "zdiff": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "merge": { + "zdiffstore": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "info": { + "zincrby": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "incrby": { + "zinter": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "initbydim": { + "zintercard": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false - } - }, - "bf": { - "loadchunk": { + }, + "zinterstore": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "debug": { + "zlexcount": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "add": { + "zmpop": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "madd": { + "zmscore": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "mexists": { + "zpopmax": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "insert": { + "zpopmin": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "exists": { + "zrandmember": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "card": { + "zrange": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "info": { + "zrangebylex": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "reserve": { + "zrangebyscore": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "scandump": { + "zrangestore": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false - } - }, - "ts": { - "mrevrange": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true }, - "info": { + "zrank": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "mrange": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "alter": { + "zrem": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "revrange": { + "zremrangebylex": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "madd": { + "zremrangebyrank": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "createrule": { + "zremrangebyscore": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "del": { + "zrevrange": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "mget": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "get": { + "zrevrangebylex": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "create": { + "zrevrangebyscore": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "add": { + "zrevrank": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "range": { + "zscan": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "queryindex": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - }, - "deleterule": { + "zscore": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "decrby": { + "zunion": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "incrby": { + "zunionstore": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false } }, "tdigest": { - "max": { + "add": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "byrevrank": { + "byrank": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "info": { + "byrevrank": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "byrank": { + "cdf": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false @@ -2399,131 +2638,138 @@ export const POLICIES: ModulePolicyRecords = { "response": "default-keyed", "isKeyless": false }, - "reset": { + "info": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "merge": { + "max": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "trimmed_mean": { + "merge": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "add": { + "min": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "revrank": { + "quantile": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "min": { + "rank": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "cdf": { + "reset": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "rank": { + "revrank": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "quantile": { + "trimmed_mean": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false } }, - "cf": { - "count": { + "timeseries": { + "refreshcluster": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + } + }, + "topk": { + "add": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "debug": { + "count": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "exists": { + "incrby": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "compact": { + "info": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "loadchunk": { + "list": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "insertnx": { + "query": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "addnx": { + "reserve": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false - }, - "insert": { + } + }, + "ts": { + "add": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "reserve": { + "alter": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "scandump": { + "create": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "info": { + "createrule": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "mexists": { + "decrby": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "add": { + "del": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "del": { + "deleterule": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false - } - }, - "topk": { - "list": { + }, + "get": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "reserve": { + "incrby": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false @@ -2533,32 +2779,40 @@ export const POLICIES: ModulePolicyRecords = { "response": "default-keyed", "isKeyless": false }, - "incrby": { + "madd": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "query": { - "request": "default-keyed", - "response": "default-keyed", - "isKeyless": false + "mget": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true }, - "count": { + "mrange": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "mrevrange": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "queryindex": { + "request": "default-keyless", + "response": "default-keyless", + "isKeyless": true + }, + "range": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false }, - "add": { + "revrange": { "request": "default-keyed", "response": "default-keyed", "isKeyless": false } - }, - "timeseries": { - "REFRESHCLUSTER": { - "request": "default-keyless", - "response": "default-keyless", - "isKeyless": true - } } } as const; diff --git a/packages/client/package.json b/packages/client/package.json index fb39cf4f481..b3a5de301bf 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -11,6 +11,7 @@ "scripts": { "test": "npm run test:types && nyc -r text-summary -r lcov mocha -r tsx --reporter mocha-multi-reporters --reporter-options configFile=mocha-multi-reporter-config.json --exit './lib/**/*.spec.ts'", "test:types": "tsc -p tsconfig.types-test.json", + "generate:policies": "tsx ./scripts/generate-static-policies-data.ts", "release": "release-it" }, "dependencies": { diff --git a/packages/client/scripts/generate-static-policies-data.ts b/packages/client/scripts/generate-static-policies-data.ts new file mode 100644 index 00000000000..f1cea473905 --- /dev/null +++ b/packages/client/scripts/generate-static-policies-data.ts @@ -0,0 +1,110 @@ +/** + * Regenerates `lib/cluster/request-response-policies/static-policies-data.ts` + * from a live Redis server's COMMAND reply. + * + * The policy derivation is shared with `DynamicPolicyResolverFactory`, so the + * generated static data is exactly what the dynamic resolver would build at + * runtime against the same server, minus the HLD curation defined in + * `static-policies-overrides.ts` (internal/deprecated/cluster-admin commands). + * + * Usage: + * npm run generate:policies --workspace=packages/client -- redis://localhost:6379 + * + * The Redis URL is taken from the first CLI argument, then the REDIS_URL + * environment variable, and defaults to redis://localhost:6379. + * + * Run against a server with all bundled modules loaded (e.g. Redis 8.8) so + * module commands (ft, json, bf, ...) are included. + */ +import { writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { createClient } from '../index'; +import { transformCommandReply, type CommandRawReply } from '../lib/commands/generic-transformers'; +import { DynamicPolicyResolverFactory } from '../lib/cluster/request-response-policies/dynamic-policy-resolver-factory'; +import type { CommandPolicyRecords, ModulePolicyRecords } from '../lib/cluster/request-response-policies/types'; +import type { CommandPolicies } from '../lib/cluster/request-response-policies/policies-constants'; +import { EXCLUDED_MODULES, EXCLUDED_COMMANDS, COMMAND_OVERRIDES } from './static-policies-overrides'; + +const OUTPUT_PATH = resolve(__dirname, '../lib/cluster/request-response-policies/static-policies-data.ts'); + +function sortedByKey(record: Record, mapValue: (value: T) => T): Record { + const sorted: Record = {}; + // Lowercased to match StaticPolicyResolver's lookup normalization. + for (const key of Object.keys(record).sort()) { + sorted[key.toLowerCase()] = mapValue(record[key]); + } + return sorted; +} + +function sortCommandPolicies(policies: CommandPolicies): CommandPolicies { + return { + ...policies, + subcommands: policies.subcommands + ? sortedByKey(policies.subcommands, sortCommandPolicies) + : undefined + }; +} + +// Sort modules, commands and subcommands alphabetically so regeneration +// produces stable diffs regardless of the order the server lists commands in. +function sortModulePolicyRecords(records: ModulePolicyRecords): ModulePolicyRecords { + return sortedByKey(records, (commands: CommandPolicyRecords) => + sortedByKey(commands, sortCommandPolicies) + ); +} + +// Applies the HLD curation from static-policies-overrides.ts. Expects +// lowercased records (i.e. run after sortModulePolicyRecords). +function curate(records: ModulePolicyRecords): ModulePolicyRecords { + const curated: ModulePolicyRecords = {}; + + for (const [moduleName, commands] of Object.entries(records)) { + if (EXCLUDED_MODULES.has(moduleName)) continue; + + curated[moduleName] = {}; + for (const [commandName, policies] of Object.entries(commands)) { + const fullName = `${moduleName}.${commandName}`; + if (EXCLUDED_COMMANDS.has(fullName)) continue; + + curated[moduleName][commandName] = COMMAND_OVERRIDES[fullName] ?? policies; + } + } + + return curated; +} + +async function main() { + const url = process.argv[2] ?? process.env.REDIS_URL ?? 'redis://localhost:6379'; + const client = createClient({ url }); + await client.connect(); + + try { + const rawCommands = await client.sendCommand>(['COMMAND']); + const commands = rawCommands.map(transformCommandReply); + const policies = curate(sortModulePolicyRecords( + DynamicPolicyResolverFactory.buildModulePolicyRecords(commands) + )); + + const info = await client.sendCommand(['INFO', 'server']); + const version = /redis_version:(\S+)/.exec(info)?.[1] ?? 'unknown'; + + const content = [ + '// This file is auto-generated by scripts/generate-static-policies-data.ts — do not edit manually.', + `// Source: Redis ${version}, ${Object.values(policies).reduce((sum, commands) => sum + Object.keys(commands).length, 0)} commands.`, + 'import { ModulePolicyRecords } from "./types";', + '', + `export const POLICIES: ModulePolicyRecords = ${JSON.stringify(policies, null, 2)} as const;`, + '' + ].join('\n'); + + writeFileSync(OUTPUT_PATH, content); + console.log(`Wrote ${OUTPUT_PATH} (Redis ${version})`); + } finally { + client.destroy(); + } +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/packages/client/scripts/static-policies-overrides.ts b/packages/client/scripts/static-policies-overrides.ts new file mode 100644 index 00000000000..9c760870c1f --- /dev/null +++ b/packages/client/scripts/static-policies-overrides.ts @@ -0,0 +1,54 @@ +/** + * Curation applied on top of the raw COMMAND dump when regenerating + * `static-policies-data.ts`, keeping the static data aligned with the HLD + * "Command Routing Policy Table" (see ft-policies.spec.ts). + * + * Rationale: the server reports internal, debug, deprecated and cluster-admin + * commands that the HLD deliberately omits from client routing. They are + * excluded here so the static phase refuses to resolve them (they fall through + * to the fallback resolver instead). + */ +import type { CommandPolicies } from '../lib/cluster/request-response-policies/policies-constants'; + +/** Entire modules to drop (internal / cluster-admin command namespaces). */ +export const EXCLUDED_MODULES: ReadonlySet = new Set(['_ft', 'search']); + +/** Individual `module.command` entries to drop. */ +export const EXCLUDED_COMMANDS: ReadonlySet = new Set([ + // FT internal conditional variants + 'ft._aliasaddifnx', + 'ft._aliasdelifx', + 'ft._alterifnx', + 'ft._createifnx', + 'ft._dropifx', + 'ft._dropindexifx', + // FT debug/introspection not in the HLD table + 'ft._list', + 'ft.config', + // FT deprecated legacy (pre-2.0) commands + 'ft.add', + 'ft.del', + 'ft.get', + 'ft.mget', + 'ft.safeadd', + 'ft.synadd', + // Not yet in the HLD routing table + 'ft.hybrid', + // Cluster-admin + 'timeseries.clusterset' +]); + +/** + * Full-entry replacements, keyed by `module.command`. + * + * `ft.cursor` is pinned to plain default-keyless (its `special` request-policy + * subcommands stripped) until the special-handler registry and cursor binding + * state land — see the note in ft-policies.spec.ts. + */ +export const COMMAND_OVERRIDES: Readonly> = { + 'ft.cursor': { + request: 'default-keyless', + response: 'default-keyless', + isKeyless: true + } +}; From 8a95c2bdd043c186c4872166fa546af6074474e1 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Thu, 11 Jun 2026 13:36:33 +0300 Subject: [PATCH 17/28] fix(client): scan all tips when parsing command policies COMMAND tips carry no ordering guarantee; commands like INFO declare nondeterministic_output before their policy tips, so positional parsing silently dropped request/response policies for 13 commands (scan, info, memory/latency subcommands, function stats, cluster slot-stats, hotkeys get) and they fell back to default routing. Scan the whole tips array by prefix instead and regenerate static-policies-data from Redis 8.8.0. Surfaced 'special' policies now reach the throwing routeSpecial / reduceSpecial handlers on cluster dispatch; safe fallback deferred. --- .../static-policies-data.ts | 50 ++++++------ packages/client/lib/commands/COMMAND.spec.ts | 81 ++++++++++++++++++- .../lib/commands/generic-transformers.ts | 28 ++++--- 3 files changed, 123 insertions(+), 36 deletions(-) diff --git a/packages/client/lib/cluster/request-response-policies/static-policies-data.ts b/packages/client/lib/cluster/request-response-policies/static-policies-data.ts index 525d5c25a96..3f752cd198a 100644 --- a/packages/client/lib/cluster/request-response-policies/static-policies-data.ts +++ b/packages/client/lib/cluster/request-response-policies/static-policies-data.ts @@ -979,7 +979,7 @@ export const POLICIES: ModulePolicyRecords = { "isKeyless": true }, "slot-stats": { - "request": "default-keyless", + "request": "all_shards", "response": "default-keyless", "isKeyless": true }, @@ -1240,8 +1240,8 @@ export const POLICIES: ModulePolicyRecords = { "isKeyless": true }, "stats": { - "request": "default-keyless", - "response": "default-keyless", + "request": "all_shards", + "response": "special", "isKeyless": true } } @@ -1412,8 +1412,8 @@ export const POLICIES: ModulePolicyRecords = { "isKeyless": true, "subcommands": { "get": { - "request": "default-keyless", - "response": "default-keyless", + "request": "special", + "response": "special", "isKeyless": true }, "help": { @@ -1524,8 +1524,8 @@ export const POLICIES: ModulePolicyRecords = { "isKeyless": false }, "info": { - "request": "default-keyless", - "response": "default-keyless", + "request": "all_shards", + "response": "special", "isKeyless": true }, "keys": { @@ -1544,13 +1544,13 @@ export const POLICIES: ModulePolicyRecords = { "isKeyless": true, "subcommands": { "doctor": { - "request": "default-keyless", - "response": "default-keyless", + "request": "all_nodes", + "response": "special", "isKeyless": true }, "graph": { - "request": "default-keyless", - "response": "default-keyless", + "request": "all_nodes", + "response": "special", "isKeyless": true }, "help": { @@ -1559,18 +1559,18 @@ export const POLICIES: ModulePolicyRecords = { "isKeyless": true }, "histogram": { - "request": "default-keyless", - "response": "default-keyless", + "request": "all_nodes", + "response": "special", "isKeyless": true }, "history": { - "request": "default-keyless", - "response": "default-keyless", + "request": "all_nodes", + "response": "special", "isKeyless": true }, "latest": { - "request": "default-keyless", - "response": "default-keyless", + "request": "all_nodes", + "response": "special", "isKeyless": true }, "reset": { @@ -1661,8 +1661,8 @@ export const POLICIES: ModulePolicyRecords = { "isKeyless": true, "subcommands": { "doctor": { - "request": "default-keyless", - "response": "default-keyless", + "request": "all_shards", + "response": "special", "isKeyless": true }, "help": { @@ -1671,8 +1671,8 @@ export const POLICIES: ModulePolicyRecords = { "isKeyless": true }, "malloc-stats": { - "request": "default-keyless", - "response": "default-keyless", + "request": "all_shards", + "response": "special", "isKeyless": true }, "purge": { @@ -1681,8 +1681,8 @@ export const POLICIES: ModulePolicyRecords = { "isKeyless": true }, "stats": { - "request": "default-keyless", - "response": "default-keyless", + "request": "all_shards", + "response": "special", "isKeyless": true }, "usage": { @@ -2004,8 +2004,8 @@ export const POLICIES: ModulePolicyRecords = { "isKeyless": true }, "scan": { - "request": "default-keyless", - "response": "default-keyless", + "request": "special", + "response": "special", "isKeyless": true }, "scard": { diff --git a/packages/client/lib/commands/COMMAND.spec.ts b/packages/client/lib/commands/COMMAND.spec.ts index 7ace4de3c70..565dc79a2b1 100644 --- a/packages/client/lib/commands/COMMAND.spec.ts +++ b/packages/client/lib/commands/COMMAND.spec.ts @@ -108,6 +108,75 @@ describe('COMMAND', () => { isKeyless: true, subcommands: [] } + }, + { + // INFO declares tips in this order on a live server; the parser must not + // depend on policy tips being first + name: 'with policies after a non-policy tip', + input: ['info', -1, [], 0, 0, 0, [], ['nondeterministic_output', 'request_policy:all_shards', 'response_policy:special'], [], []] satisfies CommandRawReply, + expected: { + name: 'info', + arity: -1, + flags: new Set([]), + firstKeyIndex: 0, + lastKeyIndex: 0, + step: 0, + categories: new Set([]), + policies: { request: 'all_shards', response: 'special' }, + isKeyless: true, + subcommands: [] + } + }, + { + // CLUSTER SLOT-STATS shape: non-policy tip first, request policy only + name: 'with request policy after a non-policy tip', + input: ['test', 0, [], 0, 0, 0, [], ['nondeterministic_output', 'request_policy:all_shards'], [], []] satisfies CommandRawReply, + expected: { + name: 'test', + arity: 0, + flags: new Set([]), + firstKeyIndex: 0, + lastKeyIndex: 0, + step: 0, + categories: new Set([]), + policies: { request: 'all_shards', response: undefined }, + isKeyless: true, + subcommands: [] + } + }, + { + // KEYS shape: policy first, trailing non-policy tip must not be + // misread as a response policy + name: 'with non-policy tip after request policy', + input: ['keys', 2, [], 0, 0, 0, [], ['request_policy:all_shards', 'nondeterministic_output'], [], []] satisfies CommandRawReply, + expected: { + name: 'keys', + arity: 2, + flags: new Set([]), + firstKeyIndex: 0, + lastKeyIndex: 0, + step: 0, + categories: new Set([]), + policies: { request: 'all_shards', response: undefined }, + isKeyless: true, + subcommands: [] + } + }, + { + name: 'with only non-policy tips', + input: ['test', 0, [], 0, 0, 0, [], ['nondeterministic_output', 'nondeterministic_output_order'], [], []] satisfies CommandRawReply, + expected: { + name: 'test', + arity: 0, + flags: new Set([]), + firstKeyIndex: 0, + lastKeyIndex: 0, + step: 0, + categories: new Set([]), + policies: { request: undefined, response: undefined }, + isKeyless: true, + subcommands: [] + } } ]; @@ -122,10 +191,18 @@ describe('COMMAND', () => { }); testUtils.testWithClient('client.command', async client => { - const result = ((await client.command()).find(command => command.name === 'dbsize')); + const commands = await client.command(); + + const result = commands.find(command => command.name === 'dbsize'); assert.equal(result?.name, 'dbsize'); assert.equal(result?.arity, 1); assert.equal(result?.policies?.request, 'all_shards'); assert.equal(result?.policies?.response, 'agg_sum'); + + // INFO declares 'nondeterministic_output' before its policy tips — + // regression guard for positional tips parsing + const info = commands.find(command => command.name === 'info'); + assert.equal(info?.policies?.request, 'all_shards'); + assert.equal(info?.policies?.response, 'special'); }, GLOBAL.SERVERS.OPEN); -}); \ No newline at end of file +}); diff --git a/packages/client/lib/commands/generic-transformers.ts b/packages/client/lib/commands/generic-transformers.ts index 35ef69eb867..af18a8ba175 100644 --- a/packages/client/lib/commands/generic-transformers.ts +++ b/packages/client/lib/commands/generic-transformers.ts @@ -369,15 +369,25 @@ export function transformCommandReply( ): CommandReply { - const requestPolicyRaw = tips[0]?.replace('request_policy:', ''); - const requestPolicy = requestPolicyRaw && Object.values(REQUEST_POLICIES_WITH_DEFAULTS).includes(requestPolicyRaw as RequestPolicyWithDefaults) - ? requestPolicyRaw as RequestPolicyWithDefaults - : undefined; - - const responsePolicyRaw = tips[1]?.replace('response_policy:', ''); - const responsePolicy = responsePolicyRaw && Object.values(RESPONSE_POLICIES_WITH_DEFAULTS).includes(responsePolicyRaw as ResponsePolicyWithDefaults) - ? responsePolicyRaw as ResponsePolicyWithDefaults - : undefined; + // Tips are free-form hints with no ordering guarantee — commands like INFO + // declare 'nondeterministic_output' before their policy tips, so scan the + // whole array instead of relying on positions. + let requestPolicy: RequestPolicyWithDefaults | undefined; + let responsePolicy: ResponsePolicyWithDefaults | undefined; + + for (const tip of tips) { + if (tip.startsWith('request_policy:')) { + const raw = tip.slice('request_policy:'.length); + if ((Object.values(REQUEST_POLICIES_WITH_DEFAULTS) as string[]).includes(raw)) { + requestPolicy = raw as RequestPolicyWithDefaults; + } + } else if (tip.startsWith('response_policy:')) { + const raw = tip.slice('response_policy:'.length); + if ((Object.values(RESPONSE_POLICIES_WITH_DEFAULTS) as string[]).includes(raw)) { + responsePolicy = raw as ResponsePolicyWithDefaults; + } + } + } const subcommands = subcommandsReply.map(transformCommandReply); From 53825ffd96f95e0ab6ed251ccc877a5d08d6dbb9 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Thu, 11 Jun 2026 15:06:31 +0300 Subject: [PATCH 18/28] feat(client): parse COMMAND key specs into CommandReply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Key specs were reduced to an isKeyless boolean; the begin_search / find_keys structures were discarded. The multi_shard splitter needs them as the reconstruction recipe (key strides, keynum count position, options suffix), so retain them as CommandReply.keySpecs. RESP2 delivers each map level as flat field-value pair arrays; normalize to the RESP3 object shape so both protocols parse identically. Unrecognized or malformed entries parse to { type: 'unknown' } instead of throwing — consumers decide whether unknown is acceptable. --- packages/client/lib/commands/COMMAND.spec.ts | 113 +++++++++++++++++- .../lib/commands/generic-transformers.ts | 105 +++++++++++++++- 2 files changed, 216 insertions(+), 2 deletions(-) diff --git a/packages/client/lib/commands/COMMAND.spec.ts b/packages/client/lib/commands/COMMAND.spec.ts index 565dc79a2b1..6646dffd76a 100644 --- a/packages/client/lib/commands/COMMAND.spec.ts +++ b/packages/client/lib/commands/COMMAND.spec.ts @@ -1,6 +1,6 @@ import { strict as assert } from 'node:assert'; import testUtils, { GLOBAL } from '../test-utils'; -import { parseArgs, transformCommandReply, CommandFlags, CommandCategories, CommandRawReply } from './generic-transformers'; +import { parseArgs, transformCommandReply, transformKeySpec, CommandFlags, CommandCategories, CommandRawReply } from './generic-transformers'; import COMMAND from './COMMAND'; describe('COMMAND', () => { @@ -26,6 +26,7 @@ describe('COMMAND', () => { categories: new Set([CommandCategories.FAST]), policies: { request: undefined, response: undefined }, isKeyless: true, + keySpecs: [], subcommands: [] } }, @@ -42,6 +43,7 @@ describe('COMMAND', () => { categories: new Set([]), policies: { request: 'all_shards', response: 'agg_sum' }, isKeyless: true, + keySpecs: [], subcommands: [] } }, @@ -58,6 +60,7 @@ describe('COMMAND', () => { categories: new Set([]), policies: { request: undefined, response: undefined }, isKeyless: false, + keySpecs: [{ beginSearch: { type: 'unknown' }, findKeys: { type: 'unknown' } }], subcommands: [] } }, @@ -74,6 +77,7 @@ describe('COMMAND', () => { categories: new Set([]), policies: { request: 'all_nodes', response: undefined }, isKeyless: false, + keySpecs: [{ beginSearch: { type: 'unknown' }, findKeys: { type: 'unknown' } }], subcommands: [] } }, @@ -90,6 +94,7 @@ describe('COMMAND', () => { categories: new Set([]), policies: { request: undefined, response: 'agg_max' }, isKeyless: true, + keySpecs: [], subcommands: [] } }, @@ -106,6 +111,7 @@ describe('COMMAND', () => { categories: new Set([]), policies: { request: undefined, response: 'agg_max' }, isKeyless: true, + keySpecs: [], subcommands: [] } }, @@ -124,6 +130,7 @@ describe('COMMAND', () => { categories: new Set([]), policies: { request: 'all_shards', response: 'special' }, isKeyless: true, + keySpecs: [], subcommands: [] } }, @@ -141,6 +148,7 @@ describe('COMMAND', () => { categories: new Set([]), policies: { request: 'all_shards', response: undefined }, isKeyless: true, + keySpecs: [], subcommands: [] } }, @@ -159,6 +167,7 @@ describe('COMMAND', () => { categories: new Set([]), policies: { request: 'all_shards', response: undefined }, isKeyless: true, + keySpecs: [], subcommands: [] } }, @@ -175,6 +184,7 @@ describe('COMMAND', () => { categories: new Set([]), policies: { request: undefined, response: undefined }, isKeyless: true, + keySpecs: [], subcommands: [] } } @@ -190,6 +200,101 @@ describe('COMMAND', () => { }); }); + describe('transformKeySpec', () => { + // Shapes captured from a live Redis 8.8.0 COMMAND INFO reply: RESP3 + // delivers nested objects, RESP2 the same data as flat field-value pair + // arrays. Both must parse to the same (RESP3-like) result. + const testCases = [ + { + name: 'range (MSET)', + resp3: { + flags: ['OW', 'update'], + begin_search: { type: 'index', spec: { index: 1 } }, + find_keys: { type: 'range', spec: { lastkey: -1, keystep: 2, limit: 0 } } + }, + resp2: [ + 'flags', ['OW', 'update'], + 'begin_search', ['type', 'index', 'spec', ['index', 1]], + 'find_keys', ['type', 'range', 'spec', ['lastkey', -1, 'keystep', 2, 'limit', 0]] + ], + expected: { + beginSearch: { type: 'index', index: 1 }, + findKeys: { type: 'range', lastKey: -1, keyStep: 2, limit: 0 } + } + }, + { + name: 'keynum (MSETEX)', + resp3: { + flags: ['OW', 'update'], + begin_search: { type: 'index', spec: { index: 1 } }, + find_keys: { type: 'keynum', spec: { keynumidx: 0, firstkey: 1, keystep: 2 } } + }, + resp2: [ + 'flags', ['OW', 'update'], + 'begin_search', ['type', 'index', 'spec', ['index', 1]], + 'find_keys', ['type', 'keynum', 'spec', ['keynumidx', 0, 'firstkey', 1, 'keystep', 2]] + ], + expected: { + beginSearch: { type: 'index', index: 1 }, + findKeys: { type: 'keynum', keyNumIdx: 0, firstKey: 1, keyStep: 2 } + } + }, + { + name: 'keyword (GEORADIUS STORE)', + resp3: { + flags: ['OW', 'update'], + begin_search: { type: 'keyword', spec: { keyword: 'STORE', startfrom: 6 } }, + find_keys: { type: 'range', spec: { lastkey: 0, keystep: 1, limit: 0 } } + }, + resp2: [ + 'flags', ['OW', 'update'], + 'begin_search', ['type', 'keyword', 'spec', ['keyword', 'STORE', 'startfrom', 6]], + 'find_keys', ['type', 'range', 'spec', ['lastkey', 0, 'keystep', 1, 'limit', 0]] + ], + expected: { + beginSearch: { type: 'keyword', keyword: 'STORE', startFrom: 6 }, + findKeys: { type: 'range', lastKey: 0, keyStep: 1, limit: 0 } + } + }, + { + name: 'unrecognized types', + resp3: { + begin_search: { type: 'future-type', spec: { whatever: 1 } }, + find_keys: { type: 'future-type', spec: { whatever: 1 } } + }, + resp2: [ + 'begin_search', ['type', 'future-type', 'spec', ['whatever', 1]], + 'find_keys', ['type', 'future-type', 'spec', ['whatever', 1]] + ], + expected: { + beginSearch: { type: 'unknown' }, + findKeys: { type: 'unknown' } + } + } + ]; + + testCases.forEach(testCase => { + it(`${testCase.name} - RESP3 shape`, () => { + assert.deepEqual(transformKeySpec(testCase.resp3), testCase.expected); + }); + + it(`${testCase.name} - RESP2 shape`, () => { + assert.deepEqual(transformKeySpec(testCase.resp2), testCase.expected); + }); + }); + + it('malformed entries parse to unknown instead of throwing', () => { + const unknown = { beginSearch: { type: 'unknown' }, findKeys: { type: 'unknown' } }; + assert.deepEqual(transformKeySpec('some key specification'), unknown); + assert.deepEqual(transformKeySpec(null), unknown); + assert.deepEqual(transformKeySpec(['odd', 'pair', 'array']), unknown); + assert.deepEqual(transformKeySpec({ + begin_search: { type: 'index', spec: { index: 'not-a-number' } }, + find_keys: { type: 'range', spec: { lastkey: -1 } } + }), unknown); + }); + }); + testUtils.testWithClient('client.command', async client => { const commands = await client.command(); @@ -204,5 +309,11 @@ describe('COMMAND', () => { const info = commands.find(command => command.name === 'info'); assert.equal(info?.policies?.request, 'all_shards'); assert.equal(info?.policies?.response, 'special'); + + const mset = commands.find(command => command.name === 'mset'); + assert.deepEqual(mset?.keySpecs, [{ + beginSearch: { type: 'index', index: 1 }, + findKeys: { type: 'range', lastKey: -1, keyStep: 2, limit: 0 } + }]); }, GLOBAL.SERVERS.OPEN); }); diff --git a/packages/client/lib/commands/generic-transformers.ts b/packages/client/lib/commands/generic-transformers.ts index af18a8ba175..4abc2ddd571 100644 --- a/packages/client/lib/commands/generic-transformers.ts +++ b/packages/client/lib/commands/generic-transformers.ts @@ -345,10 +345,20 @@ export type CommandRawReply = [ step: number, categories: Array, tips: Array, - keySpecifications: Array, + keySpecifications: Array, subcommands: Array ]; +export type KeySpec = { + beginSearch: + | { type: 'index'; index: number } + | { type: 'keyword'; keyword: string; startFrom: number } + | { type: 'unknown' }; + findKeys: + | { type: 'range'; lastKey: number; keyStep: number; limit: number } + | { type: 'keynum'; keyNumIdx: number; firstKey: number; keyStep: number } + | { type: 'unknown' }; +}; export type CommandReply = { name: string, @@ -360,9 +370,101 @@ export type CommandReply = { categories: Set, policies: { request: RequestPolicyWithDefaults | undefined, response: ResponsePolicyWithDefaults | undefined } isKeyless: boolean, + keySpecs: Array, subcommands: Array }; +/** + * Normalizes one map-shaped level of a key specification to the RESP3 object + * shape. RESP3 already delivers objects; RESP2 delivers the same data as flat + * `[field, value, ...]` pair arrays. + */ +function normalizeKeySpecMap(raw: unknown): Record | undefined { + if (raw === null || typeof raw !== 'object') return undefined; + + if (!Array.isArray(raw)) return raw as Record; + + if (raw.length % 2 !== 0) return undefined; + + const normalized: Record = {}; + for (let i = 0; i < raw.length; i += 2) { + const field = raw[i]; + if (typeof field !== 'string') return undefined; + normalized[field] = raw[i + 1]; + } + return normalized; +} + +function transformNumber(raw: unknown): number | undefined { + const value = Number(raw); + return Number.isInteger(value) ? value : undefined; +} + +const UNKNOWN_KEY_SPEC_PART = { type: 'unknown' } as const; + +function transformBeginSearch(raw: unknown): KeySpec['beginSearch'] { + const beginSearch = normalizeKeySpecMap(raw); + const spec = normalizeKeySpecMap(beginSearch?.spec); + if (!beginSearch || !spec) return UNKNOWN_KEY_SPEC_PART; + + switch (beginSearch.type) { + case 'index': { + const index = transformNumber(spec.index); + if (index !== undefined) return { type: 'index', index }; + break; + } + case 'keyword': { + const startFrom = transformNumber(spec.startfrom); + if (typeof spec.keyword === 'string' && startFrom !== undefined) { + return { type: 'keyword', keyword: spec.keyword, startFrom }; + } + break; + } + } + return UNKNOWN_KEY_SPEC_PART; +} + +function transformFindKeys(raw: unknown): KeySpec['findKeys'] { + const findKeys = normalizeKeySpecMap(raw); + const spec = normalizeKeySpecMap(findKeys?.spec); + if (!findKeys || !spec) return UNKNOWN_KEY_SPEC_PART; + + switch (findKeys.type) { + case 'range': { + const lastKey = transformNumber(spec.lastkey), + keyStep = transformNumber(spec.keystep), + limit = transformNumber(spec.limit); + if (lastKey !== undefined && keyStep !== undefined && limit !== undefined) { + return { type: 'range', lastKey, keyStep, limit }; + } + break; + } + case 'keynum': { + const keyNumIdx = transformNumber(spec.keynumidx), + firstKey = transformNumber(spec.firstkey), + keyStep = transformNumber(spec.keystep); + if (keyNumIdx !== undefined && firstKey !== undefined && keyStep !== undefined) { + return { type: 'keynum', keyNumIdx, firstKey, keyStep }; + } + break; + } + } + return UNKNOWN_KEY_SPEC_PART; +} + +/** + * Parses one COMMAND key-specification entry. Unrecognized or malformed + * shapes parse to `{ type: 'unknown' }` parts instead of throwing — consumers + * (e.g. the multi_shard splitter) decide whether unknown is acceptable. + */ +export function transformKeySpec(raw: unknown): KeySpec { + const entry = normalizeKeySpecMap(raw); + return { + beginSearch: transformBeginSearch(entry?.begin_search), + findKeys: transformFindKeys(entry?.find_keys) + }; +} + export function transformCommandReply( this: void, [name, arity, flags, firstKeyIndex, lastKeyIndex, step, categories, tips, keySpecifications, subcommandsReply]: CommandRawReply @@ -404,6 +506,7 @@ export function transformCommandReply( response: responsePolicy }, isKeyless: keySpecifications.length === 0, + keySpecs: keySpecifications.map(transformKeySpec), subcommands }; } From 3308b937b4ef9074a6e601e42b9b7b153ae76605 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Thu, 11 Jun 2026 16:27:10 +0300 Subject: [PATCH 19/28] feat(client): carry key specs into multi_shard command policies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The multi_shard splitter needs the key-spec reconstruction recipe at resolution time on both resolver paths. Copy CommandReply.keySpecs into CommandPolicies only for multi_shard commands — the same builder produces static-policies-data.ts, and unconditional copying would roughly triple the generated file with specs nothing reads. Regenerated static data (Redis 8.8.0): del, exists, mget, mset, msetex, touch, unlink gained keySpecs; msetex is the only keynum spec. --- .../dynamic-policy-resolver-factory.ts | 11 +- .../dynamic-policy-resolver.spec.ts | 62 ++++++++++ .../policies-constants.ts | 8 ++ .../static-policies-data.ts | 112 ++++++++++++++++-- 4 files changed, 185 insertions(+), 8 deletions(-) diff --git a/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver-factory.ts b/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver-factory.ts index e6fb766a824..c23f1cefa61 100644 --- a/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver-factory.ts +++ b/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver-factory.ts @@ -127,10 +127,19 @@ export class DynamicPolicyResolverFactory { } } + const request = command.policies.request ?? defaultRequest; + return { - request: command.policies.request ?? defaultRequest, + request, response: command.policies.response ?? defaultResponse, isKeyless, + // Only the multi_shard splitter consumes key specs. This builder also + // produces static-policies-data.ts, so copying them unconditionally + // would pollute the generated data with specs nothing reads + // (~tripling the file). + keySpecs: request === REQUEST_POLICIES_WITH_DEFAULTS.MULTI_SHARD + ? command.keySpecs + : undefined, subcommands }; } diff --git a/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver.spec.ts b/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver.spec.ts index fa79970147b..f38843dae25 100644 --- a/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver.spec.ts +++ b/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver.spec.ts @@ -52,6 +52,7 @@ describe('DynamicPolicyResolverFactory', () => { categories: new Set(), policies: { request: undefined, response: undefined }, isKeyless: true, + keySpecs: [], subcommands: [] } ]; @@ -79,6 +80,7 @@ describe('DynamicPolicyResolverFactory', () => { categories: new Set(), policies: { request: undefined, response: undefined }, isKeyless: false, + keySpecs: [], subcommands: [] } ]; @@ -106,6 +108,7 @@ describe('DynamicPolicyResolverFactory', () => { categories: new Set(), policies: { request: 'all_shards', response: 'agg_sum' }, isKeyless: true, + keySpecs: [], subcommands: [] } ]; @@ -121,6 +124,60 @@ describe('DynamicPolicyResolverFactory', () => { } }); + it('should carry keySpecs through for multi_shard commands only', async () => { + const msetKeySpecs = [{ + beginSearch: { type: 'index', index: 1 }, + findKeys: { type: 'range', lastKey: -1, keyStep: 2, limit: 0 } + }] as const; + const mockCommands: Array = [ + { + name: 'mset', + arity: -3, + flags: new Set(), + firstKeyIndex: 1, + lastKeyIndex: -1, + step: 2, + categories: new Set(), + policies: { request: 'multi_shard', response: 'all_succeeded' }, + isKeyless: false, + keySpecs: [...msetKeySpecs], + subcommands: [] + }, + { + name: 'get', + arity: 2, + flags: new Set(), + firstKeyIndex: 1, + lastKeyIndex: 1, + step: 1, + categories: new Set(), + policies: { request: undefined, response: undefined }, + isKeyless: false, + keySpecs: [{ + beginSearch: { type: 'index', index: 1 }, + findKeys: { type: 'range', lastKey: 0, keyStep: 1, limit: 0 } + }], + subcommands: [] + } + ]; + + const mockCommandFetcher = createMockCommandFetcher(mockCommands); + const resolver = await DynamicPolicyResolverFactory.create(mockCommandFetcher); + + const msetResult = resolver.resolvePolicy({ command: 'mset', subcommand: undefined }); + assert.equal(msetResult.ok, true); + if (msetResult.ok) { + assert.deepEqual(msetResult.value.keySpecs, msetKeySpecs); + } + + // non-multi_shard commands never split — no keySpecs on their entries + const getResult = resolver.resolvePolicy({ command: 'get', subcommand: undefined }); + assert.equal(getResult.ok, true); + if (getResult.ok) { + assert.equal(getResult.value.keySpecs, undefined); + } + }); + it('should handle module commands correctly', async () => { const mockCommands: Array = [ { @@ -133,6 +190,7 @@ describe('DynamicPolicyResolverFactory', () => { categories: new Set(), policies: { request: 'all_shards', response: 'special' }, isKeyless: false, + keySpecs: [], subcommands: [] } ]; @@ -160,6 +218,7 @@ describe('DynamicPolicyResolverFactory', () => { categories: new Set(), policies: { request: undefined, response: undefined }, isKeyless: true, + keySpecs: [], subcommands: [] } ]; @@ -189,6 +248,7 @@ describe('DynamicPolicyResolverFactory', () => { categories: new Set(), policies: { request: undefined, response: undefined }, isKeyless: true, + keySpecs: [], subcommands: [] } ]; @@ -241,6 +301,7 @@ describe('DynamicPolicyResolverFactory', () => { categories: new Set(), policies: { request: 'all_nodes', response: undefined }, isKeyless: false, + keySpecs: [], subcommands: [] }, { @@ -253,6 +314,7 @@ describe('DynamicPolicyResolverFactory', () => { categories: new Set(), policies: { request: undefined, response: 'agg_sum' }, isKeyless: true, + keySpecs: [], subcommands: [] } ]; diff --git a/packages/client/lib/cluster/request-response-policies/policies-constants.ts b/packages/client/lib/cluster/request-response-policies/policies-constants.ts index fff861c56af..2abe0ef69f4 100644 --- a/packages/client/lib/cluster/request-response-policies/policies-constants.ts +++ b/packages/client/lib/cluster/request-response-policies/policies-constants.ts @@ -1,3 +1,5 @@ +import type { KeySpec } from '../../commands/generic-transformers'; + export const REQUEST_POLICIES_WITH_DEFAULTS = { /** * The client should execute the command on all nodes - masters and replicas alike. @@ -113,4 +115,10 @@ export interface CommandPolicies { readonly response: ResponsePolicyWithDefaults; readonly subcommands?: Record; readonly isKeyless: boolean; + /** + * COMMAND key specifications — the reconstruction recipe for splitting the + * command per slot. Only populated for `multi_shard` commands; other + * commands never split, so their entries stay lean. + */ + readonly keySpecs?: ReadonlyArray; } \ No newline at end of file diff --git a/packages/client/lib/cluster/request-response-policies/static-policies-data.ts b/packages/client/lib/cluster/request-response-policies/static-policies-data.ts index 3f752cd198a..d043c81449e 100644 --- a/packages/client/lib/cluster/request-response-policies/static-policies-data.ts +++ b/packages/client/lib/cluster/request-response-policies/static-policies-data.ts @@ -1097,7 +1097,21 @@ export const POLICIES: ModulePolicyRecords = { "del": { "request": "multi_shard", "response": "agg_sum", - "isKeyless": false + "isKeyless": false, + "keySpecs": [ + { + "beginSearch": { + "type": "index", + "index": 1 + }, + "findKeys": { + "type": "range", + "lastKey": -1, + "keyStep": 1, + "limit": 0 + } + } + ] }, "delex": { "request": "default-keyed", @@ -1152,7 +1166,21 @@ export const POLICIES: ModulePolicyRecords = { "exists": { "request": "multi_shard", "response": "agg_sum", - "isKeyless": false + "isKeyless": false, + "keySpecs": [ + { + "beginSearch": { + "type": "index", + "index": 1 + }, + "findKeys": { + "type": "range", + "lastKey": -1, + "keyStep": 1, + "limit": 0 + } + } + ] }, "expire": { "request": "default-keyed", @@ -1695,7 +1723,21 @@ export const POLICIES: ModulePolicyRecords = { "mget": { "request": "multi_shard", "response": "default-keyed", - "isKeyless": false + "isKeyless": false, + "keySpecs": [ + { + "beginSearch": { + "type": "index", + "index": 1 + }, + "findKeys": { + "type": "range", + "lastKey": -1, + "keyStep": 1, + "limit": 0 + } + } + ] }, "migrate": { "request": "default-keyed", @@ -1747,12 +1789,40 @@ export const POLICIES: ModulePolicyRecords = { "mset": { "request": "multi_shard", "response": "all_succeeded", - "isKeyless": false + "isKeyless": false, + "keySpecs": [ + { + "beginSearch": { + "type": "index", + "index": 1 + }, + "findKeys": { + "type": "range", + "lastKey": -1, + "keyStep": 2, + "limit": 0 + } + } + ] }, "msetex": { "request": "multi_shard", "response": "all_succeeded", - "isKeyless": false + "isKeyless": false, + "keySpecs": [ + { + "beginSearch": { + "type": "index", + "index": 1 + }, + "findKeys": { + "type": "keynum", + "keyNumIdx": 0, + "firstKey": 1, + "keyStep": 2 + } + } + ] }, "msetnx": { "request": "default-keyed", @@ -2250,7 +2320,21 @@ export const POLICIES: ModulePolicyRecords = { "touch": { "request": "multi_shard", "response": "agg_sum", - "isKeyless": false + "isKeyless": false, + "keySpecs": [ + { + "beginSearch": { + "type": "index", + "index": 1 + }, + "findKeys": { + "type": "range", + "lastKey": -1, + "keyStep": 1, + "limit": 0 + } + } + ] }, "trimslots": { "request": "default-keyless", @@ -2270,7 +2354,21 @@ export const POLICIES: ModulePolicyRecords = { "unlink": { "request": "multi_shard", "response": "agg_sum", - "isKeyless": false + "isKeyless": false, + "keySpecs": [ + { + "beginSearch": { + "type": "index", + "index": 1 + }, + "findKeys": { + "type": "range", + "lastKey": -1, + "keyStep": 1, + "limit": 0 + } + } + ] }, "unsubscribe": { "request": "default-keyless", From 4549506002b2be23f6d1194d8ec786236e138098 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Fri, 12 Jun 2026 12:46:52 +0300 Subject: [PATCH 20/28] feat(client): add multi_shard command splitter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure per-slot splitter driven by COMMAND key specs: range specs cover DEL, UNLINK, EXISTS, TOUCH, MGET, MSET; keynum covers MSETEX (numkeys rewritten per sub-command, options suffix copied verbatim). Single-slot commands pass through unsplit, preserving single-slot atomicity. Each sub-command records its key groups' original ordinals so reply aggregation can restore input key order (MGET). Anything that cannot be split deterministically — missing/multiple/keyword/unknown specs, malformed numkeys, misaligned key region — throws naming the command: a wrong split of a write command means corrupted data, so refusal beats guessing. Routing/aggregation wiring lands separately. --- .../request-response-policies/index.ts | 1 + .../multi-shard-splitter.spec.ts | 191 ++++++++++++++++++ .../multi-shard-splitter.ts | 148 ++++++++++++++ 3 files changed, 340 insertions(+) create mode 100644 packages/client/lib/cluster/request-response-policies/multi-shard-splitter.spec.ts create mode 100644 packages/client/lib/cluster/request-response-policies/multi-shard-splitter.ts diff --git a/packages/client/lib/cluster/request-response-policies/index.ts b/packages/client/lib/cluster/request-response-policies/index.ts index 546637b4414..a06604a8961 100644 --- a/packages/client/lib/cluster/request-response-policies/index.ts +++ b/packages/client/lib/cluster/request-response-policies/index.ts @@ -6,5 +6,6 @@ export { DynamicPolicyResolverFactory, type CommandFetcher } from './dynamic-pol export * from './policies-constants'; export { POLICIES } from './static-policies-data'; export * from './dispatch'; +export { splitMultiShardCommand, type SubCommand } from './multi-shard-splitter'; // export { type CommandRouter } from './command-router'; \ No newline at end of file diff --git a/packages/client/lib/cluster/request-response-policies/multi-shard-splitter.spec.ts b/packages/client/lib/cluster/request-response-policies/multi-shard-splitter.spec.ts new file mode 100644 index 00000000000..15907f4fb74 --- /dev/null +++ b/packages/client/lib/cluster/request-response-policies/multi-shard-splitter.spec.ts @@ -0,0 +1,191 @@ +import { strict as assert } from 'node:assert'; +import calculateSlot from 'cluster-key-slot'; +import type { KeySpec } from '../../commands/generic-transformers'; +import { splitMultiShardCommand } from './multi-shard-splitter'; + +// Real specs of the 7 multi_shard commands (Redis 8.8.0). +const RANGE_STEP_1: Array = [{ + beginSearch: { type: 'index', index: 1 }, + findKeys: { type: 'range', lastKey: -1, keyStep: 1, limit: 0 } +}]; +const RANGE_STEP_2: Array = [{ + beginSearch: { type: 'index', index: 1 }, + findKeys: { type: 'range', lastKey: -1, keyStep: 2, limit: 0 } +}]; +const KEYNUM_STEP_2: Array = [{ + beginSearch: { type: 'index', index: 1 }, + findKeys: { type: 'keynum', keyNumIdx: 0, firstKey: 1, keyStep: 2 } +}]; + +// Hash tags pin keys to slots: {a}* keys share a slot, {b}* keys share +// a different one. +const SLOT_A = calculateSlot('{a}1'); +const SLOT_B = calculateSlot('{b}1'); + +describe('splitMultiShardCommand', () => { + before(() => assert.notEqual(SLOT_A, SLOT_B)); + + it('single-slot fast path returns the original args', () => { + const args = ['DEL', '{a}1', '{a}2', '{a}3']; + const result = splitMultiShardCommand(args, RANGE_STEP_1); + + assert.equal(result.size, 1); + assert.deepEqual(result.get(SLOT_A), { + args: ['DEL', '{a}1', '{a}2', '{a}3'], + groupIndices: [0, 1, 2] + }); + }); + + it('splits DEL per slot (range, keystep 1)', () => { + const result = splitMultiShardCommand(['DEL', '{a}1', '{b}1', '{a}2'], RANGE_STEP_1); + + assert.equal(result.size, 2); + assert.deepEqual(result.get(SLOT_A), { + args: ['DEL', '{a}1', '{a}2'], + groupIndices: [0, 2] + }); + assert.deepEqual(result.get(SLOT_B), { + args: ['DEL', '{b}1'], + groupIndices: [1] + }); + }); + + it('splits MSET keeping values with their keys (range, keystep 2)', () => { + const result = splitMultiShardCommand( + ['MSET', '{a}1', 'v1', '{b}1', 'v2', '{a}2', 'v3'], + RANGE_STEP_2 + ); + + assert.equal(result.size, 2); + assert.deepEqual(result.get(SLOT_A), { + args: ['MSET', '{a}1', 'v1', '{a}2', 'v3'], + groupIndices: [0, 2] + }); + assert.deepEqual(result.get(SLOT_B), { + args: ['MSET', '{b}1', 'v2'], + groupIndices: [1] + }); + }); + + it('splits MSETEX rewriting numkeys and copying the options suffix', () => { + const result = splitMultiShardCommand( + ['MSETEX', '3', '{a}1', 'v1', '{b}1', 'v2', '{a}2', 'v3', 'NX', 'EX', '10'], + KEYNUM_STEP_2 + ); + + assert.equal(result.size, 2); + assert.deepEqual(result.get(SLOT_A), { + args: ['MSETEX', '2', '{a}1', 'v1', '{a}2', 'v3', 'NX', 'EX', '10'], + groupIndices: [0, 2] + }); + assert.deepEqual(result.get(SLOT_B), { + args: ['MSETEX', '1', '{b}1', 'v2', 'NX', 'EX', '10'], + groupIndices: [1] + }); + }); + + it('keynum single-slot fast path keeps original numkeys', () => { + const args = ['MSETEX', '2', '{a}1', 'v1', '{a}2', 'v2', 'KEEPTTL']; + const result = splitMultiShardCommand(args, KEYNUM_STEP_2); + + assert.equal(result.size, 1); + assert.deepEqual(result.get(SLOT_A)?.args, args); + }); + + it('records interleaved group indices for order-preserving reassembly (MGET)', () => { + const result = splitMultiShardCommand( + ['MGET', '{a}1', '{b}1', '{a}2', '{b}2'], + RANGE_STEP_1 + ); + + assert.deepEqual(result.get(SLOT_A)?.groupIndices, [0, 2]); + assert.deepEqual(result.get(SLOT_B)?.groupIndices, [1, 3]); + }); + + it('handles Buffer keys', () => { + const result = splitMultiShardCommand( + ['MGET', Buffer.from('{a}1'), '{b}1'], + RANGE_STEP_1 + ); + + assert.equal(result.size, 2); + assert.deepEqual(result.get(SLOT_A)?.args, ['MGET', Buffer.from('{a}1')]); + }); + + describe('guardrails', () => { + const args = ['DEL', '{a}1', '{b}1']; + + it('rejects missing key specs', () => { + assert.throws(() => splitMultiShardCommand(args, undefined), /Cannot split DEL: command has no key specification/); + assert.throws(() => splitMultiShardCommand(args, []), /no key specification/); + }); + + it('rejects multiple key specs', () => { + assert.throws( + () => splitMultiShardCommand(args, [...RANGE_STEP_1, ...RANGE_STEP_1]), + /multiple key specifications/ + ); + }); + + it('rejects keyword begin_search', () => { + assert.throws( + () => splitMultiShardCommand(args, [{ + beginSearch: { type: 'keyword', keyword: 'STORE', startFrom: 1 }, + findKeys: { type: 'range', lastKey: -1, keyStep: 1, limit: 0 } + }]), + /unsupported begin_search type 'keyword'/ + ); + }); + + it('rejects unknown spec parts', () => { + assert.throws( + () => splitMultiShardCommand(args, [{ beginSearch: { type: 'unknown' }, findKeys: { type: 'unknown' } }]), + /unsupported begin_search type 'unknown'/ + ); + assert.throws( + () => splitMultiShardCommand(args, [{ beginSearch: { type: 'index', index: 1 }, findKeys: { type: 'unknown' } }]), + /unsupported find_keys type 'unknown'/ + ); + }); + + it('rejects bounded ranges', () => { + assert.throws( + () => splitMultiShardCommand(['GET', '{a}1'], [{ + beginSearch: { type: 'index', index: 1 }, + findKeys: { type: 'range', lastKey: 0, keyStep: 1, limit: 0 } + }]), + /unsupported find_keys range/ + ); + }); + + it('rejects malformed numkeys', () => { + for (const numkeys of ['abc', '-1', '0', '2.5', '']) { + assert.throws( + () => splitMultiShardCommand(['MSETEX', numkeys, '{a}1', 'v1'], KEYNUM_STEP_2), + /malformed numkeys/ + ); + } + }); + + it('rejects a key region overrunning the args (numkeys too large)', () => { + assert.throws( + () => splitMultiShardCommand(['MSETEX', '3', '{a}1', 'v1'], KEYNUM_STEP_2), + /key region overruns/ + ); + }); + + it('rejects a key region misaligned with keystep (MSET missing value)', () => { + assert.throws( + () => splitMultiShardCommand(['MSET', '{a}1', 'v1', '{b}1'], RANGE_STEP_2), + /does not align with keystep/ + ); + }); + + it('rejects an empty key region', () => { + assert.throws( + () => splitMultiShardCommand(['DEL'], RANGE_STEP_1), + /key region/ + ); + }); + }); +}); diff --git a/packages/client/lib/cluster/request-response-policies/multi-shard-splitter.ts b/packages/client/lib/cluster/request-response-policies/multi-shard-splitter.ts new file mode 100644 index 00000000000..659cd085ad9 --- /dev/null +++ b/packages/client/lib/cluster/request-response-policies/multi-shard-splitter.ts @@ -0,0 +1,148 @@ +import calculateSlot from 'cluster-key-slot'; +import type { RedisArgument } from '../../RESP/types'; +import type { KeySpec } from '../../commands/generic-transformers'; + +export type SubCommand = { + args: Array; + /** + * 0-based ordinals of this sub-command's key groups in the original + * command, for order-preserving reply reassembly (e.g. MGET). + */ + groupIndices: Array; +}; + +/** + * Splits a multi_shard command's arguments into one sub-command per hash + * slot, using the command's COMMAND key specification as the reconstruction + * recipe ("the command must be split even if all the slots are managed by + * the same shard" — but same-*slot* commands pass through unsplit). + * + * Each key group is the key plus its `keyStep - 1` trailing siblings (e.g. + * MSET's value). A sub-command is prefix + that slot's groups (in original + * relative order) + suffix; for `keynum` specs the numkeys argument in the + * prefix is rewritten to the sub-command's group count. + * + * Throws on anything it cannot split deterministically — a wrong split of a + * write command means corrupted data, so refusal beats guessing. All current + * multi_shard commands (DEL, UNLINK, EXISTS, TOUCH, MGET, MSET, MSETEX) + * declare exactly one supported spec. + */ +export function splitMultiShardCommand( + args: ReadonlyArray, + keySpecs: ReadonlyArray | undefined +): Map { + const label = args.length > 0 ? args[0].toString() : ''; + + if (!keySpecs || keySpecs.length === 0) { + throw new Error(`Cannot split ${label}: command has no key specification`); + } + // TODO(multi-spec): a command whose keys are interchangeable but + // syntactically scattered (e.g. a fixed-position key plus a keyword-tail + // list) could legitimately be multi_shard with several specs, and + // multi-region reconstruction would be deterministic. No such command + // exists, and specs alone cannot distinguish that shape from linked-operand + // specs (GEORADIUS-like) where splitting is meaningless — so refuse until a + // real command motivates multi-region support. + if (keySpecs.length > 1) { + throw new Error(`Cannot split ${label}: multiple key specifications are not supported`); + } + + const { beginSearch, findKeys } = keySpecs[0]; + if (beginSearch.type !== 'index') { + throw new Error(`Cannot split ${label}: unsupported begin_search type '${beginSearch.type}'`); + } + + const start = beginSearch.index; + let keyRegionStart: number; + let keyRegionEnd: number; + let keyStep: number; + // Absolute position of the numkeys argument to rewrite per sub-command. + let keyNumIdx: number | undefined; + + switch (findKeys.type) { + case 'range': { + // All current multi_shard range specs are "until end of args"; bounded + // ranges (lastKey >= 0) and limit can be added when a command needs them. + if (findKeys.lastKey !== -1 || findKeys.limit !== 0) { + throw new Error(`Cannot split ${label}: unsupported find_keys range (lastkey ${findKeys.lastKey}, limit ${findKeys.limit})`); + } + keyStep = findKeys.keyStep; + keyRegionStart = start; + keyRegionEnd = args.length; + break; + } + case 'keynum': { + keyStep = findKeys.keyStep; + keyNumIdx = start + findKeys.keyNumIdx; + keyRegionStart = start + findKeys.firstKey; + if (keyNumIdx >= keyRegionStart) { + throw new Error(`Cannot split ${label}: numkeys argument inside the key region`); + } + const numKeys = parsePositiveInteger(args[keyNumIdx]); + if (numKeys === undefined) { + throw new Error(`Cannot split ${label}: malformed numkeys argument '${args[keyNumIdx]}'`); + } + keyRegionEnd = keyRegionStart + numKeys * keyStep; + break; + } + default: + throw new Error(`Cannot split ${label}: unsupported find_keys type '${findKeys.type}'`); + } + + if (keyStep < 1) { + throw new Error(`Cannot split ${label}: invalid keystep ${keyStep}`); + } + if (keyRegionStart < 1 || keyRegionEnd > args.length) { + throw new Error(`Cannot split ${label}: key region overruns the arguments`); + } + const regionLength = keyRegionEnd - keyRegionStart; + if (regionLength <= 0 || regionLength % keyStep !== 0) { + throw new Error(`Cannot split ${label}: key region does not align with keystep ${keyStep}`); + } + + const groupCount = regionLength / keyStep; + const slotGroups = new Map>(); + for (let group = 0; group < groupCount; group++) { + const slot = calculateSlot(args[keyRegionStart + group * keyStep]); + const groups = slotGroups.get(slot); + if (groups) { + groups.push(group); + } else { + slotGroups.set(slot, [group]); + } + } + + const subCommands = new Map(); + + // Single-slot fast path: nothing to split — pass the original command + // through untouched (also preserves single-slot atomicity). + if (slotGroups.size === 1) { + const [[slot, groupIndices]] = slotGroups; + subCommands.set(slot, { args: [...args], groupIndices }); + return subCommands; + } + + const suffix = args.slice(keyRegionEnd); + for (const [slot, groupIndices] of slotGroups) { + const subArgs = args.slice(0, keyRegionStart); + if (keyNumIdx !== undefined) { + subArgs[keyNumIdx] = groupIndices.length.toString(); + } + for (const group of groupIndices) { + const groupStart = keyRegionStart + group * keyStep; + for (let i = 0; i < keyStep; i++) { + subArgs.push(args[groupStart + i]); + } + } + subArgs.push(...suffix); + subCommands.set(slot, { args: subArgs, groupIndices }); + } + + return subCommands; +} + +function parsePositiveInteger(arg: RedisArgument | undefined): number | undefined { + if (arg === undefined) return undefined; + const value = Number(arg.toString()); + return Number.isInteger(value) && value > 0 ? value : undefined; +} From f339e625ee3498ce926330e1fa57d81b003d902d Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Fri, 12 Jun 2026 16:59:45 +0300 Subject: [PATCH 21/28] refactor(client): extract policy dispatch into _executeWithPolicies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore _execute to a policy-free transport primitive — one command to one client (key-routed unless a pinned client is given) with MOVED/ASK redirect handling. Policy resolution, request-policy fan-out and response-policy aggregation move to _executeWithPolicies, which call sites hand data (parser, command, transformReply) instead of fn closures; fn creation now happens in one place, which the multi_shard splitter wiring will rely on to build per-sub-command closures. No behavior change: cluster test results are identical before and after. Note: _executePolicyPlan is a _-prefixed method rather than a native #private — cluster instances are derived via Object.create, which does not carry private fields. --- packages/client/lib/cluster/index.ts | 109 ++++++++++++++++++++------- 1 file changed, 80 insertions(+), 29 deletions(-) diff --git a/packages/client/lib/cluster/index.ts b/packages/client/lib/cluster/index.ts index f27ecf983c3..b1641e2e936 100644 --- a/packages/client/lib/cluster/index.ts +++ b/packages/client/lib/cluster/index.ts @@ -1,6 +1,6 @@ import { RedisClientOptions, RedisClientType, WithFunctions, WithModules, WithScripts } from '../client'; import { CommandOptions } from '../client/commands-queue'; -import { Command, CommandArguments, CommanderConfig, CommandSignature, TypeMapping, RedisArgument, RedisFunction, RedisFunctions, RedisModules, RedisScript, RedisScripts, ReplyUnion, RespVersions } from '../RESP/types'; +import { Command, CommandArguments, CommanderConfig, CommandSignature, TypeMapping, RedisArgument, RedisFunction, RedisFunctions, RedisModules, RedisScript, RedisScripts, ReplyUnion, RespVersions, TransformReply } from '../RESP/types'; import { NON_STICKY_COMMANDS } from '../commands'; import { EventEmitter } from 'node:events'; import { attachConfig, functionArgumentsPrefix, getTransformReply, scriptArgumentsPrefix } from '../commander'; @@ -181,11 +181,11 @@ export default class RedisCluster< const parser = new BasicCommandParser(this._self._keyPrefix); command.parseCommand(parser, ...args); - return this._self._execute( + return this._self._executeWithPolicies( parser, - command.IS_READ_ONLY, this._commandOptions, - (client, opts) => client._executeCommand(command, parser, opts, transformReply) + command, + transformReply ); }; } @@ -197,11 +197,11 @@ export default class RedisCluster< const parser = new BasicCommandParser(this._self._keyPrefix); command.parseCommand(parser, ...args); - return this._self._execute( + return this._self._executeWithPolicies( parser, - command.IS_READ_ONLY, this._self._commandOptions, - (client, opts) => client._executeCommand(command, parser, opts, transformReply) + command, + transformReply ); }; } @@ -215,11 +215,11 @@ export default class RedisCluster< parser.push(...prefix); fn.parseCommand(parser, ...args); - return this._self._execute( + return this._self._executeWithPolicies( parser, - fn.IS_READ_ONLY, this._self._commandOptions, - (client, opts) => client._executeCommand(fn, parser, opts, transformReply) + fn, + transformReply ); }; } @@ -233,11 +233,11 @@ export default class RedisCluster< parser.push(...prefix); script.parseCommand(parser, ...args); - return this._self._execute( + return this._self._executeScriptWithPolicies( parser, - script.IS_READ_ONLY, this._commandOptions, - (client, opts) => client._executeScript(script, parser, opts, transformReply) + script, + transformReply ); }; } @@ -467,15 +467,46 @@ export default class RedisCluster< }; } - async _execute( + /** + * Resolves the command's policies and executes it accordingly: the request + * policy picks the target clients, each target runs through the core + * `_execute` transport primitive, and the response policy aggregates the + * replies. Call sites pass data — fn closures are created here. + */ + async _executeWithPolicies( parser: CommandParser, - isReadonly: boolean | undefined, options: ClusterCommandOptions | undefined, - fn: (client: RedisClientType, opts?: ClusterCommandOptions) => Promise + command: Command | RedisFunction, + transformReply: TransformReply | undefined ): Promise { + return this._executePolicyPlan( + parser, + command.IS_READ_ONLY, + options, + p => (client, opts) => client._executeCommand(command, p, opts as CommandOptions | undefined, transformReply) + ); + } - const maxCommandRedirections = this._options.maxCommandRedirections ?? 16; + async _executeScriptWithPolicies( + parser: CommandParser, + options: ClusterCommandOptions | undefined, + script: RedisScript, + transformReply: TransformReply | undefined + ): Promise { + return this._executePolicyPlan( + parser, + script.IS_READ_ONLY, + options, + p => (client, opts) => client._executeScript(script, p, opts as CommandOptions | undefined, transformReply) + ); + } + async _executePolicyPlan( + parser: CommandParser, + isReadonly: boolean | undefined, + options: ClusterCommandOptions | undefined, + makeFn: (parser: CommandParser) => (client: RedisClientType, opts?: ClusterCommandOptions) => Promise + ): Promise { const policyResult = this._policyResolver.resolvePolicy(parser.commandIdentifier); if(!policyResult.ok) { @@ -495,7 +526,34 @@ export default class RedisCluster< const clients: Array> = await router(this._slots, parser, isReadonly); - const responsePromises = clients.map(async client => { + const responsePromises = clients.map( + client => this._execute(parser, isReadonly, options, makeFn(parser), client) + ); + + const reducer = RESPONSE_REDUCERS[responsePolicy]; + if (!reducer) { + throw new Error(`Unknown response policy ${responsePolicy}`); + } + return reducer(responsePromises, parser) as Promise; + } + + /** + * Core transport primitive: sends one command to one client — resolved by + * the parser's first key unless `pinnedClient` is given — with MOVED/ASK + * redirect handling. Policy-free; fan-out and aggregation live in + * `_executePolicyPlan`. + */ + async _execute( + parser: CommandParser, + isReadonly: boolean | undefined, + options: ClusterCommandOptions | undefined, + fn: (client: RedisClientType, opts?: ClusterCommandOptions) => Promise, + pinnedClient?: RedisClientType + ): Promise { + const maxCommandRedirections = this._options.maxCommandRedirections ?? 16; + + let client = pinnedClient + ?? (await this._slots.getClientAndSlotNumber(parser.firstKey, isReadonly)).client; let i = 0; @@ -562,15 +620,6 @@ export default class RedisCluster< throw err; } } - - }) - - const reducer = RESPONSE_REDUCERS[responsePolicy]; - if (!reducer) { - throw new Error(`Unknown response policy ${responsePolicy}`); - } - return reducer(responsePromises, parser) as Promise; - } async sendCommand( @@ -591,11 +640,13 @@ export default class RedisCluster< firstKey && parser.push(firstKey) args.forEach(arg => parser.push(arg)); - return this._self._execute( + // Raw path: no command object, so readonly-ness stays an explicit caller + // argument and the reply is returned untransformed. + return this._self._executePolicyPlan( parser, isReadonly, opts, - (client, opts) => client.sendCommand(args, opts) + () => (client, opts) => client.sendCommand(args, opts) ); } From ab36c4f3b8317c20fdcabfa73ead12f7aa706305 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Fri, 12 Jun 2026 17:00:50 +0300 Subject: [PATCH 22/28] fix(client): correct default policy reducers reduceDefaultKeyed returned the responses array instead of the single reply, wrapping every keyed cluster command (GET returned ['value']). reduceDefaultKeyless fed single replies through merge aggregation, which throws on scalars (PUBLISH replies are numbers); merging is only meaningful for fan-out replies, so single-target responses now pass through. Also drop a stray debug console.log from aggregateMerge. --- .../client/lib/cluster/request-response-policies/dispatch.ts | 5 ++++- .../cluster/request-response-policies/generic-aggregators.ts | 2 -- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/client/lib/cluster/request-response-policies/dispatch.ts b/packages/client/lib/cluster/request-response-policies/dispatch.ts index 39512e65b18..31b61a73114 100644 --- a/packages/client/lib/cluster/request-response-policies/dispatch.ts +++ b/packages/client/lib/cluster/request-response-policies/dispatch.ts @@ -123,12 +123,15 @@ export const reduceSpecial = async (_promises: Promise[], parser: CommandP export const reduceDefaultKeyless = async (promises: Promise[]): Promise => { const responses = await Promise.all(promises); + // Merging is only meaningful for fan-out replies (e.g. KEYS under + // all_shards); the single-target case must pass scalar replies through. + if (responses.length === 1) return responses[0]; return aggregateMerge(responses) as T; }; export const reduceDefaultKeyed = async (promises: Promise[]): Promise => { const responses = await Promise.all(promises); - return responses as T; + return responses[0]; }; // --- registries --- diff --git a/packages/client/lib/cluster/request-response-policies/generic-aggregators.ts b/packages/client/lib/cluster/request-response-policies/generic-aggregators.ts index de4202d2d44..16de711f2c4 100644 --- a/packages/client/lib/cluster/request-response-policies/generic-aggregators.ts +++ b/packages/client/lib/cluster/request-response-policies/generic-aggregators.ts @@ -119,8 +119,6 @@ export const aggregateMerge = (replies: Array): T => { return map as T; } - //TODO remove - console.log('firstReply', firstReply, typeof firstReply); throw new Error('Unsupported reply type for merge aggregation'); }; From b1a47cb9b615bc2f72d4d61b9d15173978d0dccc Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Fri, 12 Jun 2026 17:02:58 +0300 Subject: [PATCH 23/28] fix(client): connect nodes lazily in all-shards/all-nodes fan-out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getAllMasterClients/getAllClients silently skipped nodes without a connected client. With minimizeConnections (or a partially connected cluster) all_shards/all_nodes commands fanned out to a subset of nodes — or none, resolving undefined: PING via sendCommand returned undefined, DBSIZE could sum a subset without any error. Route through nodeClient(), the same lazy connect-or-reuse accessor keyed routing uses. getAllClients no longer yields dedicated PubSub connections; they cannot run regular commands. Also guard the policy dispatch against an empty fan-out — failing loud beats resolving undefined. --- packages/client/lib/cluster/cluster-slots.ts | 23 +++++++++++--------- packages/client/lib/cluster/index.ts | 4 ++++ 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/packages/client/lib/cluster/cluster-slots.ts b/packages/client/lib/cluster/cluster-slots.ts index 6f1f4ce585d..efdc85b97fd 100644 --- a/packages/client/lib/cluster/cluster-slots.ts +++ b/packages/client/lib/cluster/cluster-slots.ts @@ -792,18 +792,21 @@ export default class RedisClusterSlots< this.#emit('disconnect'); } - getAllClients() { - return Array.from(this.#clients()); + /** + * 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>> { + return Promise.all([ + ...this.masters.map(master => this.nodeClient(master)), + ...this.replicas.map(replica => this.nodeClient(replica)) + ]); } - getAllMasterClients() { - const result = []; - for (const master of this.masters) { - if (master.client) { - result.push(master.client); - } - } - return result; + getAllMasterClients(): Promise>> { + return Promise.all(this.masters.map(master => this.nodeClient(master))); } async getClientAndSlotNumber( diff --git a/packages/client/lib/cluster/index.ts b/packages/client/lib/cluster/index.ts index b1641e2e936..817f4003300 100644 --- a/packages/client/lib/cluster/index.ts +++ b/packages/client/lib/cluster/index.ts @@ -526,6 +526,10 @@ export default class RedisCluster< const clients: Array> = await router(this._slots, parser, isReadonly); + if (clients.length === 0) { + throw new Error(`Request policy ${requestPolicy} produced no target nodes`); + } + const responsePromises = clients.map( client => this._execute(parser, isReadonly, options, makeFn(parser), client) ); From bbc31018a3d6a707f06897da05fff12b94de013f Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Tue, 16 Jun 2026 12:00:58 +0300 Subject: [PATCH 24/28] feat(client): wire multi_shard splitter into cluster dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/client/lib/cluster/index.ts | 81 ++++----- .../dispatch.spec.ts | 76 +++++++++ .../request-response-policies/dispatch.ts | 161 ++++++++++++------ .../multi-shard-splitter.spec.ts | 21 ++- .../multi-shard-splitter.ts | 22 ++- 5 files changed, 250 insertions(+), 111 deletions(-) create mode 100644 packages/client/lib/cluster/request-response-policies/dispatch.spec.ts diff --git a/packages/client/lib/cluster/index.ts b/packages/client/lib/cluster/index.ts index 817f4003300..0462c4868d1 100644 --- a/packages/client/lib/cluster/index.ts +++ b/packages/client/lib/cluster/index.ts @@ -1,6 +1,6 @@ import { RedisClientOptions, RedisClientType, WithFunctions, WithModules, WithScripts } from '../client'; import { CommandOptions } from '../client/commands-queue'; -import { Command, CommandArguments, CommanderConfig, CommandSignature, TypeMapping, RedisArgument, RedisFunction, RedisFunctions, RedisModules, RedisScript, RedisScripts, ReplyUnion, RespVersions, TransformReply } from '../RESP/types'; +import { Command, CommandArguments, CommanderConfig, CommandSignature, TypeMapping, RedisArgument, RedisFunction, RedisFunctions, RedisModules, RedisScript, RedisScripts, ReplyUnion, RespVersions } from '../RESP/types'; import { NON_STICKY_COMMANDS } from '../commands'; import { EventEmitter } from 'node:events'; import { attachConfig, functionArgumentsPrefix, getTransformReply, scriptArgumentsPrefix } from '../commander'; @@ -183,9 +183,9 @@ export default class RedisCluster< return this._self._executeWithPolicies( parser, + command.IS_READ_ONLY, this._commandOptions, - command, - transformReply + p => (client, opts) => client._executeCommand(command, p, opts, transformReply) ); }; } @@ -199,9 +199,9 @@ export default class RedisCluster< return this._self._executeWithPolicies( parser, + command.IS_READ_ONLY, this._self._commandOptions, - command, - transformReply + p => (client, opts) => client._executeCommand(command, p, opts, transformReply) ); }; } @@ -217,9 +217,9 @@ export default class RedisCluster< return this._self._executeWithPolicies( parser, + fn.IS_READ_ONLY, this._self._commandOptions, - fn, - transformReply + p => (client, opts) => client._executeCommand(fn, p, opts, transformReply) ); }; } @@ -233,11 +233,11 @@ export default class RedisCluster< parser.push(...prefix); script.parseCommand(parser, ...args); - return this._self._executeScriptWithPolicies( + return this._self._executeWithPolicies( parser, + script.IS_READ_ONLY, this._commandOptions, - script, - transformReply + p => (client, opts) => client._executeScript(script, p, opts, transformReply) ); }; } @@ -471,37 +471,10 @@ export default class RedisCluster< * Resolves the command's policies and executes it accordingly: the request * policy picks the target clients, each target runs through the core * `_execute` transport primitive, and the response policy aggregates the - * replies. Call sites pass data — fn closures are created here. + * replies. Call sites pass a `makeFn` factory that builds the per-client + * execution closure (command, script, or raw `sendCommand`). */ async _executeWithPolicies( - parser: CommandParser, - options: ClusterCommandOptions | undefined, - command: Command | RedisFunction, - transformReply: TransformReply | undefined - ): Promise { - return this._executePolicyPlan( - parser, - command.IS_READ_ONLY, - options, - p => (client, opts) => client._executeCommand(command, p, opts as CommandOptions | undefined, transformReply) - ); - } - - async _executeScriptWithPolicies( - parser: CommandParser, - options: ClusterCommandOptions | undefined, - script: RedisScript, - transformReply: TransformReply | undefined - ): Promise { - return this._executePolicyPlan( - parser, - script.IS_READ_ONLY, - options, - p => (client, opts) => client._executeScript(script, p, opts as CommandOptions | undefined, transformReply) - ); - } - - async _executePolicyPlan( parser: CommandParser, isReadonly: boolean | undefined, options: ClusterCommandOptions | undefined, @@ -523,29 +496,39 @@ export default class RedisCluster< if (!router) { throw new Error(`Unknown request policy ${requestPolicy}`); } - const clients: Array> = - await router(this._slots, parser, isReadonly); + // Routers are typed against the erased base cluster types (routing is + // below the typed command surface); bridge this instantiation's slots in. + const plan = await router( + this._slots as unknown as Parameters[0], + parser, + isReadonly, + policyResult.value.keySpecs + ); - if (clients.length === 0) { + if (plan.length === 0) { throw new Error(`Request policy ${requestPolicy} produced no target nodes`); } - const responsePromises = clients.map( - client => this._execute(parser, isReadonly, options, makeFn(parser), client) - ); + const responsePromises = plan.map(entry => { + const entryParser = entry.parser ?? parser; + // Re-narrow the opaque routed client to this cluster's instantiation. + const client = entry.client as RedisClientType | undefined; + return this._execute(entryParser, isReadonly, options, makeFn(entryParser), client); + }); const reducer = RESPONSE_REDUCERS[responsePolicy]; if (!reducer) { throw new Error(`Unknown response policy ${responsePolicy}`); } - return reducer(responsePromises, parser) as Promise; + const positionHints = plan.map(entry => entry.groupIndices); + return reducer(responsePromises, parser, positionHints) as Promise; } /** * Core transport primitive: sends one command to one client — resolved by * the parser's first key unless `pinnedClient` is given — with MOVED/ASK * redirect handling. Policy-free; fan-out and aggregation live in - * `_executePolicyPlan`. + * `_executeWithPolicies`. */ async _execute( parser: CommandParser, @@ -641,12 +624,12 @@ export default class RedisCluster< } const parser = new BasicCommandParser(); - firstKey && parser.push(firstKey) + if (firstKey) parser.push(firstKey); args.forEach(arg => parser.push(arg)); // Raw path: no command object, so readonly-ness stays an explicit caller // argument and the reply is returned untransformed. - return this._self._executePolicyPlan( + return this._self._executeWithPolicies( parser, isReadonly, opts, diff --git a/packages/client/lib/cluster/request-response-policies/dispatch.spec.ts b/packages/client/lib/cluster/request-response-policies/dispatch.spec.ts new file mode 100644 index 00000000000..70b8e1fb2f3 --- /dev/null +++ b/packages/client/lib/cluster/request-response-policies/dispatch.spec.ts @@ -0,0 +1,76 @@ +import { strict as assert } from 'node:assert'; +import type { CommandParser } from '../../client/parser'; +import { reduceDefaultKeyed } from './dispatch'; + +// The reducer ignores the parser; a stub keeps the calls readable. +const PARSER = {} as CommandParser; + +describe('reduceDefaultKeyed', () => { + it('passes the sole reply through when not split (no hints)', async () => { + const reply = await reduceDefaultKeyed([Promise.resolve(['v1', 'v2'])], PARSER); + assert.deepEqual(reply, ['v1', 'v2']); + }); + + it('passes through when hints are all undefined (single-key command)', async () => { + const reply = await reduceDefaultKeyed([Promise.resolve('v1')], PARSER, [undefined]); + assert.equal(reply, 'v1'); + }); + + it('passes through a single-slot multi_shard reply unchanged', async () => { + const reply = await reduceDefaultKeyed( + [Promise.resolve(['v0', 'v1', 'v2'])], + PARSER, + [[0, 1, 2]] + ); + assert.deepEqual(reply, ['v0', 'v1', 'v2']); + }); + + it('scatters interleaved sub-replies back into original key order (MGET A,B,A,B)', async () => { + // keys hash to A,B,A,B -> slot A holds groups [0,2], slot B holds [1,3]. + const reply = await reduceDefaultKeyed( + [ + Promise.resolve(['a0', 'a2']), + Promise.resolve(['b1', 'b3']) + ], + PARSER, + [[0, 2], [1, 3]] + ); + assert.deepEqual(reply, ['a0', 'b1', 'a2', 'b3']); + }); + + it('places each sub-reply by its hint regardless of plan order', async () => { + // slot B (groups [1]) listed before slot A (groups [0,2]). + const reply = await reduceDefaultKeyed( + [ + Promise.resolve(['b1']), + Promise.resolve(['a0', 'a2']) + ], + PARSER, + [[1], [0, 2]] + ); + assert.deepEqual(reply, ['a0', 'b1', 'a2']); + }); + + it('preserves null replies (missing keys) at their positions', async () => { + const reply = await reduceDefaultKeyed( + [ + Promise.resolve(['a0', null]), + Promise.resolve([null]) + ], + PARSER, + [[0, 2], [1]] + ); + assert.deepEqual(reply, ['a0', null, null]); + }); + + it('throws when a split reply is missing its position hint', async () => { + await assert.rejects( + reduceDefaultKeyed( + [Promise.resolve(['a0']), Promise.resolve(['b1'])], + PARSER, + [[0], undefined] + ), + /missing position hints/ + ); + }); +}); diff --git a/packages/client/lib/cluster/request-response-policies/dispatch.ts b/packages/client/lib/cluster/request-response-policies/dispatch.ts index 31b61a73114..b9053692c56 100644 --- a/packages/client/lib/cluster/request-response-policies/dispatch.ts +++ b/packages/client/lib/cluster/request-response-policies/dispatch.ts @@ -1,9 +1,11 @@ -import type { CommandParser } from '../../client/parser'; +import { BasicCommandParser, type CommandParser } from '../../client/parser'; import type { RedisClientType } from '../../client'; import type { - RedisModules, RedisFunctions, RedisScripts, RespVersions, TypeMapping + RedisArgument, RedisModules, RedisFunctions, RedisScripts, RespVersions, TypeMapping } from '../../RESP/types'; +import type { KeySpec } from '../../commands/generic-transformers'; import type RedisClusterSlots from '../cluster-slots'; +import { splitMultiShardCommand, type SubCommand } from './multi-shard-splitter'; import { aggregateLogicalAnd, aggregateLogicalOr, @@ -19,61 +21,95 @@ import { type ResponsePolicyWithDefaults } from './policies-constants'; -type Client< - M extends RedisModules, - F extends RedisFunctions, - S extends RedisScripts, - RESP extends RespVersions, - TM extends TypeMapping -> = RedisClientType; - -type Slots< - M extends RedisModules, - F extends RedisFunctions, - S extends RedisScripts, - RESP extends RespVersions, - TM extends TypeMapping -> = RedisClusterSlots; - -export type RequestRouter< - M extends RedisModules, - F extends RedisFunctions, - S extends RedisScripts, - RESP extends RespVersions, - TM extends TypeMapping -> = ( - slots: Slots, +// Routing runs *below* the typed command surface: routers never inspect the +// command's M/F/S/RESP/TM parameters, they just shuffle opaque clients from +// `slots` into the plan and on to `_execute`. So these types are deliberately +// not generic — they use the base constraint types. The engine re-narrows the +// client to its own instantiation at the `_execute` boundary. +type ClusterClient = RedisClientType; +type ClusterSlots = RedisClusterSlots; + +/** + * One unit of work a request policy schedules. Pass-through policies set only + * `client` (run the original command on that node). `multi_shard` sets `parser` + * (a per-slot sub-command, routed by its own `firstKey`) and `groupIndices` + * (where its replies belong in the reassembled result). + */ +export type RoutedCommand = { + client?: ClusterClient; + parser?: CommandParser; + groupIndices?: Array; +}; + +export type RequestRouter = ( + slots: ClusterSlots, parser: CommandParser, - isReadonly: boolean | undefined -) => Promise>>; + isReadonly: boolean | undefined, + keySpecs: ReadonlyArray | undefined +) => Promise>; export type ResponseReducer = ( responsePromises: Promise[], - parser: CommandParser + parser: CommandParser, + /** + * For `multi_shard` commands, `positionHints[p]` is the original 0-based + * group ordinals carried by the p-th sub-command (plan order == promise + * order). Reducers that preserve order (e.g. default-keyed MGET) use it to + * scatter each sub-reply back into the caller's key order. `undefined` + * entries mean "not split"; the whole array is absent for non-split commands. + */ + positionHints?: Array | undefined> ) => Promise; // --- request routers --- -export const routeAllNodes: RequestRouter = - async (slots) => slots.getAllClients(); - -export const routeAllShards: RequestRouter = - async (slots) => slots.getAllMasterClients(); - -export const routeMultiShard: RequestRouter = - async (slots, parser, isReadonly) => - Promise.all( - parser.keys.map(async (key) => (await slots.getClientAndSlotNumber(key, isReadonly)).client) - ); - -export const routeDefaultKeyless: RequestRouter = - async (slots) => [slots.getRandomNode().client!]; +export const routeAllNodes: RequestRouter = + async (slots) => (await slots.getAllClients()).map(client => ({ client })); + +export const routeAllShards: RequestRouter = + async (slots) => (await slots.getAllMasterClients()).map(client => ({ client })); + +/** + * Splits the command into one sub-command per hash slot (using the COMMAND key + * specs as the reconstruction recipe) and returns a plan entry per slot. Each + * entry carries its own sub-parser, so core `_execute` routes it by that + * slot's `firstKey` and handles MOVED/ASK with the sub-command's own key. + */ +export const routeMultiShard: RequestRouter = + async (_slots, parser, _isReadonly, keySpecs) => { + const subCommands = splitMultiShardCommand(parser.redisArgs, keySpecs); + return Array.from(subCommands.values(), sub => ({ + parser: buildSubParser(sub), + groupIndices: sub.groupIndices + })); + }; -export const routeDefaultKeyed: RequestRouter = +/** + * Rebuilds a `CommandParser` from a split sub-command, marking the keys at + * their known positions so `firstKey` resolves to this slot's first key. + */ +function buildSubParser(sub: SubCommand): CommandParser { + const parser = new BasicCommandParser(); + const keyPositions = new Set(sub.keyPositions); + for (let i = 0; i < sub.args.length; i++) { + const arg = sub.args[i] as RedisArgument; + if (keyPositions.has(i)) { + parser.pushKey(arg); + } else { + parser.push(arg); + } + } + return parser; +} + +export const routeDefaultKeyless: RequestRouter = + async (slots) => [{ client: slots.getRandomNode().client! }]; + +export const routeDefaultKeyed: RequestRouter = async (slots, parser, isReadonly) => - [(await slots.getClientAndSlotNumber(parser.firstKey, isReadonly)).client]; + [{ client: (await slots.getClientAndSlotNumber(parser.firstKey, isReadonly)).client }]; -export const routeSpecial: RequestRouter = +export const routeSpecial: RequestRouter = async (_slots, parser) => { const { command, subcommand } = parser.commandIdentifier; const label = subcommand ? `${command} ${subcommand}` : command; @@ -129,9 +165,34 @@ export const reduceDefaultKeyless = async (promises: Promise[]): Promise(promises: Promise[]): Promise => { +export const reduceDefaultKeyed = async ( + promises: Promise[], + _parser: CommandParser, + positionHints?: Array | undefined> +): Promise => { const responses = await Promise.all(promises); - return responses[0]; + + // Unsplit (single-target read, or a multi_shard command that landed on one + // slot): pass the sole reply through unchanged. + if (!positionHints?.some(hint => hint !== undefined)) { + return responses[0]; + } + + // Split multi_shard (e.g. MGET across slots): each sub-reply is an array in + // its own group order; scatter element i back to its original group ordinal + // so the result matches the caller's key order regardless of slot/arrival. + const result: Array = []; + responses.forEach((reply, p) => { + const indices = positionHints[p]; + if (!indices) { + throw new Error('default-keyed reducer: split reply missing position hints'); + } + const elements = reply as Array; + indices.forEach((groupIndex, i) => { + result[groupIndex] = elements[i]; + }); + }); + return result as T; }; // --- registries --- @@ -143,7 +204,7 @@ export const REQUEST_ROUTERS = { [REQUEST_POLICIES_WITH_DEFAULTS.SPECIAL]: routeSpecial, [REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS]: routeDefaultKeyless, [REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED]: routeDefaultKeyed -} as const satisfies Record>; +} as const satisfies Record; export const RESPONSE_REDUCERS = { [RESPONSE_POLICIES_WITH_DEFAULTS.ONE_SUCCEEDED]: reduceOneSucceeded, @@ -156,4 +217,4 @@ export const RESPONSE_REDUCERS = { [RESPONSE_POLICIES_WITH_DEFAULTS.SPECIAL]: reduceSpecial, [RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS]: reduceDefaultKeyless, [RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED]: reduceDefaultKeyed -} as const satisfies Record>; +} as const satisfies Record>; diff --git a/packages/client/lib/cluster/request-response-policies/multi-shard-splitter.spec.ts b/packages/client/lib/cluster/request-response-policies/multi-shard-splitter.spec.ts index 15907f4fb74..83edc56a577 100644 --- a/packages/client/lib/cluster/request-response-policies/multi-shard-splitter.spec.ts +++ b/packages/client/lib/cluster/request-response-policies/multi-shard-splitter.spec.ts @@ -32,7 +32,8 @@ describe('splitMultiShardCommand', () => { assert.equal(result.size, 1); assert.deepEqual(result.get(SLOT_A), { args: ['DEL', '{a}1', '{a}2', '{a}3'], - groupIndices: [0, 1, 2] + groupIndices: [0, 1, 2], + keyPositions: [1, 2, 3] }); }); @@ -42,11 +43,13 @@ describe('splitMultiShardCommand', () => { assert.equal(result.size, 2); assert.deepEqual(result.get(SLOT_A), { args: ['DEL', '{a}1', '{a}2'], - groupIndices: [0, 2] + groupIndices: [0, 2], + keyPositions: [1, 2] }); assert.deepEqual(result.get(SLOT_B), { args: ['DEL', '{b}1'], - groupIndices: [1] + groupIndices: [1], + keyPositions: [1] }); }); @@ -59,11 +62,13 @@ describe('splitMultiShardCommand', () => { assert.equal(result.size, 2); assert.deepEqual(result.get(SLOT_A), { args: ['MSET', '{a}1', 'v1', '{a}2', 'v3'], - groupIndices: [0, 2] + groupIndices: [0, 2], + keyPositions: [1, 3] }); assert.deepEqual(result.get(SLOT_B), { args: ['MSET', '{b}1', 'v2'], - groupIndices: [1] + groupIndices: [1], + keyPositions: [1] }); }); @@ -76,11 +81,13 @@ describe('splitMultiShardCommand', () => { assert.equal(result.size, 2); assert.deepEqual(result.get(SLOT_A), { args: ['MSETEX', '2', '{a}1', 'v1', '{a}2', 'v3', 'NX', 'EX', '10'], - groupIndices: [0, 2] + groupIndices: [0, 2], + keyPositions: [2, 4] }); assert.deepEqual(result.get(SLOT_B), { args: ['MSETEX', '1', '{b}1', 'v2', 'NX', 'EX', '10'], - groupIndices: [1] + groupIndices: [1], + keyPositions: [2] }); }); diff --git a/packages/client/lib/cluster/request-response-policies/multi-shard-splitter.ts b/packages/client/lib/cluster/request-response-policies/multi-shard-splitter.ts index 659cd085ad9..0e27d06a564 100644 --- a/packages/client/lib/cluster/request-response-policies/multi-shard-splitter.ts +++ b/packages/client/lib/cluster/request-response-policies/multi-shard-splitter.ts @@ -9,6 +9,12 @@ export type SubCommand = { * command, for order-preserving reply reassembly (e.g. MGET). */ groupIndices: Array; + /** + * Absolute indices into `args` of this sub-command's keys (the first arg of + * each group). Lets the caller build a sub-parser that marks keys, so core + * `_execute` routes by the sub-command's own `firstKey`. + */ + keyPositions: Array; }; /** @@ -115,10 +121,12 @@ export function splitMultiShardCommand( const subCommands = new Map(); // Single-slot fast path: nothing to split — pass the original command - // through untouched (also preserves single-slot atomicity). + // through untouched (also preserves single-slot atomicity). Keys keep their + // original absolute positions. if (slotGroups.size === 1) { const [[slot, groupIndices]] = slotGroups; - subCommands.set(slot, { args: [...args], groupIndices }); + const keyPositions = groupIndices.map(group => keyRegionStart + group * keyStep); + subCommands.set(slot, { args: [...args], groupIndices, keyPositions }); return subCommands; } @@ -128,14 +136,18 @@ export function splitMultiShardCommand( if (keyNumIdx !== undefined) { subArgs[keyNumIdx] = groupIndices.length.toString(); } - for (const group of groupIndices) { + // Groups are appended in order, each keyStep wide with the key first, so + // the j-th group's key lands at keyRegionStart + j * keyStep in subArgs. + const keyPositions: Array = []; + groupIndices.forEach((group, j) => { + keyPositions.push(keyRegionStart + j * keyStep); const groupStart = keyRegionStart + group * keyStep; for (let i = 0; i < keyStep; i++) { subArgs.push(args[groupStart + i]); } - } + }); subArgs.push(...suffix); - subCommands.set(slot, { args: subArgs, groupIndices }); + subCommands.set(slot, { args: subArgs, groupIndices, keyPositions }); } return subCommands; From f1c7df526a67ceddd45c3bcae3980aa564956e53 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Tue, 16 Jun 2026 14:12:19 +0300 Subject: [PATCH 25/28] fix(client): fall back to default policy for unknown cluster commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _executeWithPolicies routed every command through policy resolution, but the cluster's resolver (StaticPolicyResolver(POLICIES)) has no fallback, so any command absent from the policy table resolved to { ok: false } and the engine threw "Policy resolution error". This broke user-defined custom commands, scripts/functions whose dispatched name is not in the table, and module commands the resolver was not built with. Such commands have no request/response policy and nothing to split or aggregate. Instead of throwing, fall back to a default policy (default_keyed when the parser carries keys, else default_keyless) and route through the existing pass-through default router/reducer — the same single-client, key-routed behaviour as a direct _execute. Known module commands the dynamic resolver does recognise still get their real policy, so a module declaring multi_shard keeps working. Known commands that cannot be split still throw from the splitter. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/client/lib/cluster/index.ts | 34 ++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/packages/client/lib/cluster/index.ts b/packages/client/lib/cluster/index.ts index 0462c4868d1..4d0ecfc45db 100644 --- a/packages/client/lib/cluster/index.ts +++ b/packages/client/lib/cluster/index.ts @@ -16,7 +16,7 @@ import SingleEntryCache from '../single-entry-cache' import { publish, CHANNELS } from '../client/tracing'; import { ClientIdentity, ClientRole, generateClusterClientId } from '../client/identity'; import { DEFAULT_COMMAND_TIMEOUT } from '../defaults'; -import { POLICIES, PolicyResolver, StaticPolicyResolver } from './request-response-policies'; +import { POLICIES, PolicyResolver, StaticPolicyResolver, REQUEST_POLICIES_WITH_DEFAULTS, RESPONSE_POLICIES_WITH_DEFAULTS, type CommandPolicies } from './request-response-policies'; import { REQUEST_ROUTERS, RESPONSE_REDUCERS } from './request-response-policies/dispatch'; export type ClusterTopologyRefreshOnReconnectionAttemptStrategy = @@ -482,14 +482,28 @@ export default class RedisCluster< ): Promise { const policyResult = this._policyResolver.resolvePolicy(parser.commandIdentifier); - if(!policyResult.ok) { - const { command, subcommand } = parser.commandIdentifier; - const label = subcommand ? `${command} ${subcommand}` : command; - throw new Error(`Policy resolution error for ${label}: ${policyResult.error}`); - } - - const requestPolicy = policyResult.value.request - const responsePolicy = policyResult.value.response + // Commands the resolver doesn't know — user-defined custom commands, + // scripts/functions, modules absent from the policy table — have no + // request/response policy and nothing to split or aggregate. Fall back to + // the default key-routed path (single client by `firstKey`, sole reply + // passed through) rather than failing. Scripts/functions are single-slot + // by contract, so default-keyed is always correct for them. Known + // multi_shard commands that can't be split still throw from the splitter. + const hasKeys = parser.keys.length > 0; + const policy: CommandPolicies = policyResult.ok + ? policyResult.value + : { + request: hasKeys + ? REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED + : REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS, + response: hasKeys + ? RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED + : RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS, + isKeyless: !hasKeys + }; + + const requestPolicy = policy.request + const responsePolicy = policy.response // https://redis.io/docs/latest/develop/reference/command-tips const router = REQUEST_ROUTERS[requestPolicy]; @@ -502,7 +516,7 @@ export default class RedisCluster< this._slots as unknown as Parameters[0], parser, isReadonly, - policyResult.value.keySpecs + policy.keySpecs ); if (plan.length === 0) { From 212ee314cf988f219529cb9f88749dbd661d683e Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Tue, 16 Jun 2026 17:18:03 +0300 Subject: [PATCH 26/28] fix(client): route and split the raw cluster sendCommand path correctly The policy refactor folded the raw cluster sendCommand into _executeWithPolicies but built its parser wrong, regressing behaviour master got right by routing on the explicit firstKey: - firstKey was prepended to args, so redisArgs[0] became a key instead of the command name (breaking policy resolution) and no key was ever marked, leaving parser.firstKey unset so routing fell back to undefined. - makeFn ignored the per-entry sub-parser and always sent the original args, so a split multi_shard command sent the full command to every shard. Build redisArgs as an exact copy of args (command name at index 0) and register the caller's firstKey via a new BasicCommandParser.markRoutingKey, which records the key for routing without re-appending it to redisArgs. Send p.redisArgs from the closure so split sub-commands each carry their own slot's arguments. A known multi_shard command sent raw now splits correctly, since the resolver supplies its key specs and the splitter operates on the flat redisArgs. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/client/lib/client/parser.spec.ts | 31 +++++++++++++++++++++++ packages/client/lib/client/parser.ts | 11 ++++++++ packages/client/lib/cluster/index.ts | 12 ++++++--- 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/packages/client/lib/client/parser.spec.ts b/packages/client/lib/client/parser.spec.ts index 4677456250e..ed6280553e5 100644 --- a/packages/client/lib/client/parser.spec.ts +++ b/packages/client/lib/client/parser.spec.ts @@ -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' }); + }); + }); }); diff --git a/packages/client/lib/client/parser.ts b/packages/client/lib/client/parser.ts index 1a6db5be443..c1fabd795b6 100644 --- a/packages/client/lib/client/parser.ts +++ b/packages/client/lib/client/parser.ts @@ -150,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()); diff --git a/packages/client/lib/cluster/index.ts b/packages/client/lib/cluster/index.ts index 4d0ecfc45db..8a4da55dbcd 100644 --- a/packages/client/lib/cluster/index.ts +++ b/packages/client/lib/cluster/index.ts @@ -637,17 +637,23 @@ export default class RedisCluster< ...options } + // `args` is the full command as sent on the wire (name at index 0), so it + // becomes `redisArgs` verbatim — policy resolution reads the command name + // and the multi_shard splitter's key-spec offsets line up. The caller's + // `firstKey` is marked for routing without re-appending it. const parser = new BasicCommandParser(); - if (firstKey) parser.push(firstKey); args.forEach(arg => parser.push(arg)); + if (firstKey !== undefined) parser.markRoutingKey(firstKey); // Raw path: no command object, so readonly-ness stays an explicit caller - // argument and the reply is returned untransformed. + // argument and the reply is returned untransformed. The closure sends the + // per-entry parser's args, so split multi_shard sub-commands each carry + // their own slot's arguments (and the unsplit case sends `args` unchanged). return this._self._executeWithPolicies( parser, isReadonly, opts, - () => (client, opts) => client.sendCommand(args, opts) + p => (client, opts) => client.sendCommand(p.redisArgs as CommandArguments, opts) ); } From 7c38ce5a7eea24ca04a9fe257823955408f3b29e Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Wed, 1 Jul 2026 19:03:58 +0300 Subject: [PATCH 27/28] feat(client): implement special-policy reducers + safe fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the throw-on-`special` behavior in the cluster policy dispatch with a per-command reducer registry plus a safe generic fallback: - routeSpecial: route to a single node + console.warn instead of throwing, so an unhandled special-request command still works (reply may be partial). - reduceSpecial: dispatch by uppercased command identifier to a dedicated reducer; unhandled commands fall back to the default-keyless reduction (sole reply / merge) with a warn. - reduceRandomKey (RANDOMKEY): fan out per all_shards, return one non-nil reply at random; all-empty -> nil. Avoids false-nil on empty shards. - reduceFirstReply: for fan-out diagnostics with a single-node reply type (INFO, MEMORY DOCTOR/MALLOC-STATS/STATS, FUNCTION STATS, LATENCY DOCTOR/GRAPH/HISTOGRAM/HISTORY/LATEST) — fan out per the request tip, return one node's reply so the reply type stays honest. FT.CURSOR sticky routing and SCAN/HOTKEYS remain unhandled (fall through to the safe generic fallback) — tracked as separate work. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../request-response-policies/dispatch.ts | 98 +++++++++++++++++-- 1 file changed, 90 insertions(+), 8 deletions(-) diff --git a/packages/client/lib/cluster/request-response-policies/dispatch.ts b/packages/client/lib/cluster/request-response-policies/dispatch.ts index b9053692c56..6a04c3ed13c 100644 --- a/packages/client/lib/cluster/request-response-policies/dispatch.ts +++ b/packages/client/lib/cluster/request-response-policies/dispatch.ts @@ -109,11 +109,32 @@ export const routeDefaultKeyed: RequestRouter = async (slots, parser, isReadonly) => [{ client: (await slots.getClientAndSlotNumber(parser.firstKey, isReadonly)).client }]; +/** + * Uppercased command key ("RANDOMKEY", "MEMORY STATS") used both to look up a + * per-command special handler and to label warnings. `commandIdentifier` + * preserves the caller's casing, so normalize before matching. + */ +function specialKey(parser: CommandParser): string { + const { command, subcommand } = parser.commandIdentifier; + const c = command.toUpperCase(); + return subcommand ? `${c} ${subcommand.toUpperCase()}` : c; +} + +/** + * Fallback router for `special` request commands without a dedicated handler. + * A `special` request policy means non-trivial routing that no generic rule + * captures; we don't know the correct target, so route to a single (random) + * node like a keyless command. This keeps the command working instead of + * throwing, but the reply reflects only that one node — warn so the gap is + * visible. Commands with a real handler never reach this. + */ export const routeSpecial: RequestRouter = - async (_slots, parser) => { - const { command, subcommand } = parser.commandIdentifier; - const label = subcommand ? `${command} ${subcommand}` : command; - throw new Error(`Special request policy not implemented for ${label}`); + async (slots, parser) => { + console.warn( + `node-redis: no cluster routing implemented for the "special" request policy of ` + + `"${specialKey(parser)}"; routing to a single node. The reply may be incomplete.` + ); + return [{ client: slots.getRandomNode().client! }]; }; // --- response reducers --- @@ -151,10 +172,71 @@ export const reduceSum = async (promises: Promise[]): Promise => { return aggregateSum(responses) as T; }; -export const reduceSpecial = async (_promises: Promise[], parser: CommandParser): Promise => { - const { command, subcommand } = parser.commandIdentifier; - const label = subcommand ? `${command} ${subcommand}` : command; - throw new Error(`Special response policy not implemented for ${label}`); +/** + * RANDOMKEY under `all_shards`: each master returns a random key from its own + * keyspace (or nil when empty). Return one of the non-nil replies at random so + * the result is a valid random key across the whole cluster and never a + * false-nil when some shard is empty but others hold keys. All shards empty → + * nil. + */ +export const reduceRandomKey = async (promises: Promise[]): Promise => { + const responses = await Promise.all(promises); + const keys = responses.filter(reply => reply != null); + if (keys.length === 0) return responses[0]; + return keys[Math.floor(Math.random() * keys.length)]; +}; + +/** + * Reducer for fan-out diagnostic commands (INFO, ...) whose per-node replies + * can't be merged into one meaningful value and whose reply type is a single + * node's shape. We still fan out per the `all_shards`/`all_nodes` request tip, + * wait for every node to succeed, then return one node's reply. This keeps the + * reply type honest (it matches the single-node command type) at the cost of + * discarding the other nodes' replies. + */ +export const reduceFirstReply = async (promises: Promise[]): Promise => { + const responses = await Promise.all(promises); + return responses[0]; +}; + +/** + * Per-command reducers for the `special` response policy, keyed by uppercased + * command identifier. A `special` response needs command-specific merging that + * no generic rule captures. Commands absent here hit `reduceSpecial`'s generic + * fallback. + */ +export const SPECIAL_RESPONSE_REDUCERS: Record> = { + RANDOMKEY: reduceRandomKey, + INFO: reduceFirstReply, + 'MEMORY DOCTOR': reduceFirstReply, + 'MEMORY MALLOC-STATS': reduceFirstReply, + 'MEMORY STATS': reduceFirstReply, + 'FUNCTION STATS': reduceFirstReply, + 'LATENCY DOCTOR': reduceFirstReply, + 'LATENCY GRAPH': reduceFirstReply, + 'LATENCY HISTOGRAM': reduceFirstReply, + 'LATENCY HISTORY': reduceFirstReply, + 'LATENCY LATEST': reduceFirstReply +}; + +/** + * Entry point for the `special` response policy: dispatch to a per-command + * reducer if one exists, else fall back to the default-keyless reduction (sole + * reply as is, or a merge of a fan-out) so the command works instead of + * throwing. Warn on the fallback because the merged shape is unlikely to be + * what the command really wants. + */ +export const reduceSpecial = async (promises: Promise[], parser: CommandParser): Promise => { + const reducer = SPECIAL_RESPONSE_REDUCERS[specialKey(parser)]; + if (reducer) return reducer(promises, parser) as Promise; + + if (promises.length > 1) { + console.warn( + `node-redis: no cluster aggregation implemented for the "special" response policy of ` + + `"${specialKey(parser)}"; merging replies from ${promises.length} nodes. The result shape may be wrong.` + ); + } + return reduceDefaultKeyless(promises); }; export const reduceDefaultKeyless = async (promises: Promise[]): Promise => { From fb6d15e08829323527efafe0e183366f8b58f3ae Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Thu, 2 Jul 2026 13:53:29 +0300 Subject: [PATCH 28/28] feat(client): sticky FT.CURSOR routing via special request policy FT.CURSOR READ/DEL carry no key, so hash-slot routing can't reach the coordinator that minted the cursor via FT.AGGREGATE ...WITHCURSOR and the server rejects the unknown cursor. Flip ft.cursor to the HLD `special` request policy and add client-side sticky machinery: - cluster-slots: per-instance cursor binding registry ((index,cursorId) -> address) with bind/lookup/evict + opportunistic idle sweep, plus nodeAddressByClient reverse-lookup. - ft-cursor: routeFtCursor (pin bound node / throw MISS before any network call), SPECIAL_REQUEST_ROUTERS, extractCursorId, and the captureCursorBinding post-reply hook (AGGREGATE bind / READ rebind-refresh-evict / DEL evict). - dispatch: routeSpecial short-circuits into SPECIAL_REQUEST_ROUTERS, keeps the warn-and-random fallback for unregistered special commands. - _executeWithPolicies: best-effort capture hook after the reply resolves; _execute untouched. - static policies: override ft.cursor -> special (+ READ/DEL subcommands, response stays default-keyless), regenerated data table. Response stays single-node pass-through (no special reducer). Bindings are per client instance; cross-instance/cross-process cursors MISS by design. Unit tests cover router/capture/lifecycle/collision; cluster integration tests cover pagination-to-completion, DEL->READ MISS, and cross-instance MISS. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/client/lib/cluster/cluster-slots.ts | 66 +++++++ packages/client/lib/cluster/index.ts | 18 +- .../request-response-policies/dispatch.ts | 17 +- .../ft-cursor.spec.ts | 163 ++++++++++++++++++ .../request-response-policies/ft-cursor.ts | 148 ++++++++++++++++ .../ft-policies.spec.ts | 30 +++- .../static-policies-data.ts | 16 +- .../scripts/static-policies-overrides.ts | 19 +- .../lib/commands/CURSOR_READ.cluster.spec.ts | 74 ++++++++ packages/search/lib/test-utils.ts | 12 ++ 10 files changed, 541 insertions(+), 22 deletions(-) create mode 100644 packages/client/lib/cluster/request-response-policies/ft-cursor.spec.ts create mode 100644 packages/client/lib/cluster/request-response-policies/ft-cursor.ts create mode 100644 packages/search/lib/commands/CURSOR_READ.cluster.spec.ts diff --git a/packages/client/lib/cluster/cluster-slots.ts b/packages/client/lib/cluster/cluster-slots.ts index efdc85b97fd..516d6e5d0fb 100644 --- a/packages/client/lib/cluster/cluster-slots.ts +++ b/packages/client/lib/cluster/cluster-slots.ts @@ -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, @@ -121,6 +141,8 @@ export default class RedisClusterSlots< pubSubNode?: PubSubNode; clientSideCache?: PooledClientSideCacheProvider; smigratedSeqIdsSeen = new Set; + /** Per-instance sticky-cursor bindings, keyed `${index}:${cursorId}`. */ + readonly cursorBindings = new Map(); #topologyRefreshPromise?: Promise; #isOpen = false; @@ -918,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): 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> { if (!this.pubSubNode) return this.#initiatePubSubClient(); diff --git a/packages/client/lib/cluster/index.ts b/packages/client/lib/cluster/index.ts index 8a4da55dbcd..ecd6c322f70 100644 --- a/packages/client/lib/cluster/index.ts +++ b/packages/client/lib/cluster/index.ts @@ -18,6 +18,7 @@ import { ClientIdentity, ClientRole, generateClusterClientId } from '../client/i import { DEFAULT_COMMAND_TIMEOUT } from '../defaults'; import { POLICIES, PolicyResolver, StaticPolicyResolver, REQUEST_POLICIES_WITH_DEFAULTS, RESPONSE_POLICIES_WITH_DEFAULTS, type CommandPolicies } from './request-response-policies'; import { REQUEST_ROUTERS, RESPONSE_REDUCERS } from './request-response-policies/dispatch'; +import { captureCursorBinding } from './request-response-policies/ft-cursor'; export type ClusterTopologyRefreshOnReconnectionAttemptStrategy = false | @@ -535,7 +536,22 @@ export default class RedisCluster< throw new Error(`Unknown response policy ${responsePolicy}`); } const positionHints = plan.map(entry => entry.groupIndices); - return reducer(responsePromises, parser, positionHints) as Promise; + const reply = await (reducer(responsePromises, parser, positionHints) as Promise); + + // Sticky-cursor bookkeeping: FT.AGGREGATE/FT.CURSOR bind/rebind/evict the + // serving node from the resolved reply. Command-name gated and best-effort + // (a bad binding only downgrades to a MISS throw on the next READ/DEL), so + // never let it mask the caller's reply. + try { + captureCursorBinding( + this._slots as unknown as Parameters[0], + parser, + plan, + reply + ); + } catch { /* binding capture is best-effort */ } + + return reply; } /** diff --git a/packages/client/lib/cluster/request-response-policies/dispatch.ts b/packages/client/lib/cluster/request-response-policies/dispatch.ts index 6a04c3ed13c..a94c2e872fb 100644 --- a/packages/client/lib/cluster/request-response-policies/dispatch.ts +++ b/packages/client/lib/cluster/request-response-policies/dispatch.ts @@ -20,6 +20,7 @@ import { type RequestPolicyWithDefaults, type ResponsePolicyWithDefaults } from './policies-constants'; +import { SPECIAL_REQUEST_ROUTERS } from './ft-cursor'; // Routing runs *below* the typed command surface: routers never inspect the // command's M/F/S/RESP/TM parameters, they just shuffle opaque clients from @@ -121,15 +122,17 @@ function specialKey(parser: CommandParser): string { } /** - * Fallback router for `special` request commands without a dedicated handler. - * A `special` request policy means non-trivial routing that no generic rule - * captures; we don't know the correct target, so route to a single (random) - * node like a keyless command. This keeps the command working instead of - * throwing, but the reply reflects only that one node — warn so the gap is - * visible. Commands with a real handler never reach this. + * Router for the `special` request policy. Commands with a dedicated handler + * (e.g. FT.CURSOR sticky routing) short-circuit into `SPECIAL_REQUEST_ROUTERS` + * first. Everything else has non-trivial routing no generic rule captures and + * no handler yet: route to a single (random) node like a keyless command so it + * still works, but warn — the reply reflects only that one node. */ export const routeSpecial: RequestRouter = - async (slots, parser) => { + async (slots, parser, isReadonly, keySpecs) => { + const handler = SPECIAL_REQUEST_ROUTERS[specialKey(parser)]; + if (handler) return handler(slots, parser, isReadonly, keySpecs); + console.warn( `node-redis: no cluster routing implemented for the "special" request policy of ` + `"${specialKey(parser)}"; routing to a single node. The reply may be incomplete.` diff --git a/packages/client/lib/cluster/request-response-policies/ft-cursor.spec.ts b/packages/client/lib/cluster/request-response-policies/ft-cursor.spec.ts new file mode 100644 index 00000000000..ce37e5879c6 --- /dev/null +++ b/packages/client/lib/cluster/request-response-policies/ft-cursor.spec.ts @@ -0,0 +1,163 @@ +import { strict as assert } from 'node:assert'; +import type { CommandParser } from '../../client/parser'; +import { routeFtCursor, captureCursorBinding, extractCursorId } from './ft-cursor'; + +/** + * Minimal stand-in for the cursor-relevant surface of `RedisClusterSlots`, + * mirroring the real `${index}:${cursorId}` keying and address→client map so + * the router/capture logic is exercised without spinning a cluster. + */ +class FakeSlots { + cursorBindings = new Map(); + clientsByAddress = new Map(); + + #key(index: string, cursorId: number) { return `${index}:${cursorId}`; } + bindCursor(index: string, cursorId: number, address: string, maxIdleMs?: number) { + this.cursorBindings.set(this.#key(index, cursorId), { address, createdAt: 0, maxIdleMs }); + } + lookupCursor(index: string, cursorId: number) { return this.cursorBindings.get(this.#key(index, cursorId)); } + evictCursor(index: string, cursorId: number) { this.cursorBindings.delete(this.#key(index, cursorId)); } + async getMasterByAddress(address: string) { return this.clientsByAddress.get(address); } + nodeAddressByClient(client: object) { + for (const [address, c] of this.clientsByAddress) if (c === client) return address; + return undefined; + } +} + +const parserOf = (...args: Array) => + ({ redisArgs: args, commandIdentifier: { command: args[0], subcommand: args[1] } }) as unknown as CommandParser; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- routers/capture run below the typed surface +const asSlots = (s: FakeSlots) => s as any; + +describe('extractCursorId', () => { + it('reads the transformed-path `{ cursor }` object (RESP2 + RESP3)', () => { + assert.equal(extractCursorId({ total: 1, results: [], cursor: 42 }), 42); + }); + + it('reads raw RESP2 `[result, cursor]` at index 1', () => { + assert.equal(extractCursorId([['result'], 7]), 7); + }); + + it('reads raw RESP3 map key `cursor`', () => { + assert.equal(extractCursorId(new Map([['results', []], ['cursor', 9]])), 9); + }); + + it('returns undefined when there is no cursor (e.g. FT.CURSOR DEL "OK")', () => { + assert.equal(extractCursorId('OK'), undefined); + assert.equal(extractCursorId(null), undefined); + }); +}); + +describe('routeFtCursor', () => { + it('pins the bound client on HIT', async () => { + const slots = new FakeSlots(); + const client = { id: 'node-a' }; + slots.clientsByAddress.set('127.0.0.1:7000', client); + slots.bindCursor('idx', 123, '127.0.0.1:7000'); + + const plan = await routeFtCursor(asSlots(slots), parserOf('FT.CURSOR', 'READ', 'idx', '123'), undefined, undefined); + assert.deepEqual(plan, [{ client }]); + }); + + it('throws on MISS (cursor never bound)', async () => { + const slots = new FakeSlots(); + await assert.rejects( + routeFtCursor(asSlots(slots), parserOf('FT.CURSOR', 'READ', 'idx', '404'), undefined, undefined), + /no known node for cursor 404 on index "idx"/ + ); + }); + + it('throws when the bound node is gone (getMasterByAddress → undefined)', async () => { + const slots = new FakeSlots(); + slots.bindCursor('idx', 5, '127.0.0.1:9999'); // address not in clientsByAddress + await assert.rejects( + routeFtCursor(asSlots(slots), parserOf('FT.CURSOR', 'DEL', 'idx', '5'), undefined, undefined), + /left the cluster/ + ); + }); +}); + +describe('captureCursorBinding — FT.AGGREGATE', () => { + it('binds (index, cursor) → serving node address (RESP2 array reply)', () => { + const slots = new FakeSlots(); + const client = {}; + slots.clientsByAddress.set('10.0.0.1:6379', client); + + captureCursorBinding(asSlots(slots), parserOf('FT.AGGREGATE', 'idx', '*', 'WITHCURSOR'), [{ client } as any], [[], 55]); + assert.deepEqual(slots.lookupCursor('idx', 55), { address: '10.0.0.1:6379', createdAt: 0, maxIdleMs: undefined }); + }); + + it('binds from the transformed `{ cursor }` reply and captures MAXIDLE', () => { + const slots = new FakeSlots(); + const client = {}; + slots.clientsByAddress.set('10.0.0.1:6379', client); + + captureCursorBinding( + asSlots(slots), + parserOf('FT.AGGREGATE', 'idx', '*', 'WITHCURSOR', 'MAXIDLE', '5000'), + [{ client } as any], + { total: 0, results: [], cursor: 88 } + ); + assert.deepEqual(slots.lookupCursor('idx', 88), { address: '10.0.0.1:6379', createdAt: 0, maxIdleMs: 5000 }); + }); + + it('does not bind when the aggregate exhausts in one batch (cursor 0)', () => { + const slots = new FakeSlots(); + const client = {}; + slots.clientsByAddress.set('10.0.0.1:6379', client); + + captureCursorBinding(asSlots(slots), parserOf('FT.AGGREGATE', 'idx', '*', 'WITHCURSOR'), [{ client } as any], { cursor: 0 }); + assert.equal(slots.cursorBindings.size, 0); + }); +}); + +describe('captureCursorBinding — FT.CURSOR lifecycle', () => { + const seed = () => { + const slots = new FakeSlots(); + const client = {}; + slots.clientsByAddress.set('10.0.0.1:6379', client); + slots.bindCursor('idx', 100, '10.0.0.1:6379'); + return { slots, client }; + }; + + it('rebinds a continuation cursor (evict old, bind new, same address)', () => { + const { slots, client } = seed(); + captureCursorBinding(asSlots(slots), parserOf('FT.CURSOR', 'READ', 'idx', '100'), [{ client } as any], { cursor: 200 }); + assert.equal(slots.lookupCursor('idx', 100), undefined); + assert.deepEqual(slots.lookupCursor('idx', 200), { address: '10.0.0.1:6379', createdAt: 0, maxIdleMs: undefined }); + }); + + it('evicts on READ → cursor 0 (exhausted)', () => { + const { slots, client } = seed(); + captureCursorBinding(asSlots(slots), parserOf('FT.CURSOR', 'READ', 'idx', '100'), [{ client } as any], { cursor: 0 }); + assert.equal(slots.lookupCursor('idx', 100), undefined); + }); + + it('keeps the binding when the continuation id is unchanged', () => { + const { slots, client } = seed(); + captureCursorBinding(asSlots(slots), parserOf('FT.CURSOR', 'READ', 'idx', '100'), [{ client } as any], { cursor: 100 }); + assert.deepEqual(slots.lookupCursor('idx', 100), { address: '10.0.0.1:6379', createdAt: 0, maxIdleMs: undefined }); + }); + + it('evicts on DEL regardless of reply, then a follow-up READ MISSes', async () => { + const { slots, client } = seed(); + captureCursorBinding(asSlots(slots), parserOf('FT.CURSOR', 'DEL', 'idx', '100'), [{ client } as any], 'OK'); + assert.equal(slots.lookupCursor('idx', 100), undefined); + await assert.rejects(routeFtCursor(asSlots(slots), parserOf('FT.CURSOR', 'READ', 'idx', '100'), undefined, undefined)); + }); +}); + +describe('cursor-id collision across indexes', () => { + it('keys on (index, cursorId) so same id under two indexes routes independently', async () => { + const slots = new FakeSlots(); + const clientA = { id: 'a' }, clientB = { id: 'b' }; + slots.clientsByAddress.set('a:1', clientA); + slots.clientsByAddress.set('b:1', clientB); + slots.bindCursor('idxA', 1, 'a:1'); + slots.bindCursor('idxB', 1, 'b:1'); + + assert.deepEqual(await routeFtCursor(asSlots(slots), parserOf('FT.CURSOR', 'READ', 'idxA', '1'), undefined, undefined), [{ client: clientA }]); + assert.deepEqual(await routeFtCursor(asSlots(slots), parserOf('FT.CURSOR', 'READ', 'idxB', '1'), undefined, undefined), [{ client: clientB }]); + }); +}); diff --git a/packages/client/lib/cluster/request-response-policies/ft-cursor.ts b/packages/client/lib/cluster/request-response-policies/ft-cursor.ts new file mode 100644 index 00000000000..4a46aa668a3 --- /dev/null +++ b/packages/client/lib/cluster/request-response-policies/ft-cursor.ts @@ -0,0 +1,148 @@ +import type { CommandParser } from '../../client/parser'; +import type { RedisArgument } from '../../RESP/types'; +import type { RequestRouter, RoutedCommand } from './dispatch'; + +// Routing/capture runs below the typed command surface (see dispatch.ts), so +// the slots handle is the erased base instantiation. `_executeWithPolicies` +// bridges its own typed slots in at the call boundary. +type ClusterSlots = Parameters[0]; + +/** RediSearch index names are case-sensitive raw wire strings; mirror them. */ +export function argToString(arg: RedisArgument): string { + return typeof arg === 'string' ? arg : arg.toString(); +} + +/** + * Pull the continuation cursor id out of an FT.AGGREGATE …WITHCURSOR / + * FT.CURSOR READ reply, across every reply path: + * - transformed command path → `{ total, results, cursor }` (RESP2 + RESP3), + * - raw RESP2 `sendCommand` → `[result, cursor]` (cursor at index 1), + * - raw RESP3 `sendCommand` → a map with a `cursor` key (Map or object). + * Returns `undefined` when no cursor field is present (e.g. FT.CURSOR DEL). + */ +export function extractCursorId(reply: unknown): number | undefined { + if (reply == null) return undefined; + + if (reply instanceof Map) { + return reply.has('cursor') ? toCursorNumber(reply.get('cursor')) : undefined; + } + + if (Array.isArray(reply)) { + return toCursorNumber(reply[1]); + } + + if (typeof reply === 'object' && 'cursor' in (reply as Record)) { + return toCursorNumber((reply as Record).cursor); + } + + return undefined; +} + +function toCursorNumber(value: unknown): number | undefined { + if (value == null) return undefined; + const n = Number(value); + return Number.isNaN(n) ? undefined : n; +} + +/** Read the numeric MAXIDLE (ms) an FT.AGGREGATE …WITHCURSOR declared, if any. */ +function maxIdleFromAggregateArgs(redisArgs: ReadonlyArray): number | undefined { + for (let i = 0; i < redisArgs.length - 1; i++) { + if (argToString(redisArgs[i]).toUpperCase() === 'MAXIDLE') { + const n = Number(argToString(redisArgs[i + 1])); + return Number.isNaN(n) ? undefined : n; + } + } + return undefined; +} + +/** + * Sticky router for FT.CURSOR READ/DEL (HLD `request_policy: special`). These + * are keyless — there's no slot to route by — so we pin the exact node that + * minted the cursor via its recorded binding. A MISS (never created here, + * already exhausted, or the bound node left the cluster) is unusable by this + * client, so throw before any network call rather than fan out or guess. + */ +export const routeFtCursor: RequestRouter = async (slots, parser) => { + const { redisArgs } = parser; + const index = argToString(redisArgs[2]); + const cursorId = Number(argToString(redisArgs[3])); + + const binding = slots.lookupCursor(index, cursorId); + if (binding) { + const client = await slots.getMasterByAddress(binding.address); + if (client) return [{ client }]; + } + + throw new Error( + `FT.CURSOR: no known node for cursor ${cursorId} on index "${index}". ` + + `The cursor was not created by this client instance, has already been ` + + `exhausted, or the node that served it has left the cluster.` + ); +}; + +/** Special-request routers, keyed like `SPECIAL_RESPONSE_REDUCERS` (see dispatch.ts). */ +export const SPECIAL_REQUEST_ROUTERS: Record = { + 'FT.CURSOR READ': routeFtCursor, + 'FT.CURSOR DEL': routeFtCursor +}; + +/** + * Command-name-gated hook run after an FT.AGGREGATE / FT.CURSOR reply resolves + * (HLD "hardcoded by command name"). Captures, rebinds, or evicts the sticky + * cursor binding using the single-target plan's serving node. No-op for any + * other command, and for multi-target plans (cursor commands are single-node). + */ +export function captureCursorBinding( + slots: ClusterSlots, + parser: CommandParser, + plan: ReadonlyArray, + reply: unknown +): void { + const { command, subcommand } = parser.commandIdentifier; + const cmd = command.toUpperCase(); + const sub = subcommand?.toUpperCase(); + + if (cmd !== 'FT.AGGREGATE' && cmd !== 'FT.CURSOR') return; + if (plan.length !== 1) return; + + const { redisArgs } = parser; + const client = plan[0].client; + + if (cmd === 'FT.AGGREGATE') { + const cursor = extractCursorId(reply); + // cursor 0 → exhausted in one batch, nothing to pin. + if (!cursor || !client) return; + const address = slots.nodeAddressByClient(client); + if (address) { + slots.bindCursor(argToString(redisArgs[1]), cursor, address, maxIdleFromAggregateArgs(redisArgs)); + } + return; + } + + // FT.CURSOR READ / DEL — index at arg 2, cursor id at arg 3. + const index = argToString(redisArgs[2]); + const cursorId = Number(argToString(redisArgs[3])); + + if (sub === 'DEL') { + // Self-cleaning: evict locally regardless of the server reply. + slots.evictCursor(index, cursorId); + return; + } + + if (sub === 'READ') { + // Reuse the node that served this READ (the binding we routed by, or a + // reverse-lookup of the pinned client) for any continuation cursor. + const address = slots.lookupCursor(index, cursorId)?.address + ?? (client ? slots.nodeAddressByClient(client) : undefined); + const next = extractCursorId(reply); + + if (next === 0 || next === undefined) { + slots.evictCursor(index, cursorId); // exhausted + } else if (next !== cursorId) { + slots.evictCursor(index, cursorId); // rebind continuation → same node + if (address) slots.bindCursor(index, next, address); + } else if (address) { + slots.bindCursor(index, cursorId, address); // unchanged → refresh createdAt + } + } +} diff --git a/packages/client/lib/cluster/request-response-policies/ft-policies.spec.ts b/packages/client/lib/cluster/request-response-policies/ft-policies.spec.ts index f95b33adf5b..161f73f6f72 100644 --- a/packages/client/lib/cluster/request-response-policies/ft-policies.spec.ts +++ b/packages/client/lib/cluster/request-response-policies/ft-policies.spec.ts @@ -12,11 +12,10 @@ import { * declared"; the client routes by the default rules. They are stored here as * `default-keyless` / `default-keyed` to match the resolver vocabulary. * - * `ft.cursor` is mis-labeled `default-keyless` in this table on purpose. - * The HLD specifies `special` request_policy (sticky cursor), which requires - * the special-handler registry and cursor binding state. That work is tracked - * in its own story — once those land, the entry below flips to `special` and - * an extra cursor-routing test goes with it. + * `ft.cursor` carries the HLD `special` request_policy (sticky cursor): READ/DEL + * are routed to the node that served the FT.AGGREGATE that minted the cursor + * (see `ft-cursor.ts`). Its response stays `default(keyless)` — single-node + * pass-through. */ const KEYLESS = { request: REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS, @@ -30,7 +29,13 @@ const KEYED = { isKeyless: false } as const; -const HLD_FT_TABLE: Record = { +const SPECIAL_CURSOR = { + request: REQUEST_POLICIES_WITH_DEFAULTS.SPECIAL, + response: RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS, + isKeyless: true +} as const; + +const HLD_FT_TABLE: Record = { 'FT.CREATE': KEYLESS, 'FT.SEARCH': KEYLESS, 'FT.AGGREGATE': KEYLESS, @@ -38,7 +43,7 @@ const HLD_FT_TABLE: Record = { 'FT.DICTDEL': KEYLESS, 'FT.DICTDUMP': KEYLESS, 'FT.SUGLEN': KEYED, - 'FT.CURSOR': KEYLESS, + 'FT.CURSOR': SPECIAL_CURSOR, 'FT.SUGADD': KEYED, 'FT.SUGGET': KEYED, 'FT.SUGDEL': KEYED, @@ -73,6 +78,17 @@ describe('FT.* policy table matches the HLD', () => { }); } + it('resolves FT.CURSOR READ/DEL subcommands to the special request policy', () => { + for (const subcommand of ['READ', 'DEL']) { + const result = resolver.resolvePolicy({ command: 'FT.CURSOR', subcommand }); + assert.equal(result.ok, true, `expected FT.CURSOR ${subcommand} to resolve`); + if (result.ok) { + assert.equal(result.value.request, REQUEST_POLICIES_WITH_DEFAULTS.SPECIAL, `FT.CURSOR ${subcommand} request`); + assert.equal(result.value.response, RESPONSE_POLICIES_WITH_DEFAULTS.DEFAULT_KEYLESS, `FT.CURSOR ${subcommand} response`); + } + } + }); + it('does not expose dropped debug commands (e.g. FT._LIST)', () => { const result = resolver.resolvePolicy({ command: 'FT._LIST', subcommand: undefined }); assert.equal(result.ok, false); diff --git a/packages/client/lib/cluster/request-response-policies/static-policies-data.ts b/packages/client/lib/cluster/request-response-policies/static-policies-data.ts index d043c81449e..e3ecd8abf29 100644 --- a/packages/client/lib/cluster/request-response-policies/static-policies-data.ts +++ b/packages/client/lib/cluster/request-response-policies/static-policies-data.ts @@ -35,9 +35,21 @@ export const POLICIES: ModulePolicyRecords = { "isKeyless": true }, "cursor": { - "request": "default-keyless", + "request": "special", "response": "default-keyless", - "isKeyless": true + "isKeyless": true, + "subcommands": { + "read": { + "request": "special", + "response": "default-keyless", + "isKeyless": true + }, + "del": { + "request": "special", + "response": "default-keyless", + "isKeyless": true + } + } }, "dictadd": { "request": "default-keyless", diff --git a/packages/client/scripts/static-policies-overrides.ts b/packages/client/scripts/static-policies-overrides.ts index 9c760870c1f..c0b3fd5252c 100644 --- a/packages/client/scripts/static-policies-overrides.ts +++ b/packages/client/scripts/static-policies-overrides.ts @@ -41,14 +41,23 @@ export const EXCLUDED_COMMANDS: ReadonlySet = new Set([ /** * Full-entry replacements, keyed by `module.command`. * - * `ft.cursor` is pinned to plain default-keyless (its `special` request-policy - * subcommands stripped) until the special-handler registry and cursor binding - * state land — see the note in ft-policies.spec.ts. + * `ft.cursor` is pinned to the HLD `special` request policy (sticky cursor): + * FT.CURSOR READ/DEL must reach the node that served the FT.AGGREGATE that + * minted the cursor. The client-side sticky machinery lives in + * `lib/cluster/request-response-policies/ft-cursor.ts` (router + capture) and + * the cursor binding map on `cluster-slots.ts`. The response stays + * `default-keyless` (single-node pass-through). Pinned as an override so the + * static table is deterministic regardless of what a given server reports for + * the container command's subcommands. */ export const COMMAND_OVERRIDES: Readonly> = { 'ft.cursor': { - request: 'default-keyless', + request: 'special', response: 'default-keyless', - isKeyless: true + isKeyless: true, + subcommands: { + read: { request: 'special', response: 'default-keyless', isKeyless: true }, + del: { request: 'special', response: 'default-keyless', isKeyless: true } + } } }; diff --git a/packages/search/lib/commands/CURSOR_READ.cluster.spec.ts b/packages/search/lib/commands/CURSOR_READ.cluster.spec.ts new file mode 100644 index 00000000000..5c150931fcb --- /dev/null +++ b/packages/search/lib/commands/CURSOR_READ.cluster.spec.ts @@ -0,0 +1,74 @@ +import { strict as assert } from 'node:assert'; +import testUtils, { GLOBAL } from '../test-utils'; + +/** + * Cluster-mode sticky-cursor routing. FT.CURSOR READ/DEL carry no key, so + * without the client-side binding they'd hash-route to an arbitrary node and + * the server would reject the unknown cursor. These tests prove the binding: + * pagination completes against the coordinator that minted the cursor, DEL + * evicts it, and bindings are per client instance. + */ +describe('FT.CURSOR sticky routing (cluster)', () => { + const DOC_COUNT = 40; + + async function seedIndex(cluster: any) { + await cluster.ft.create('idx', { n: 'NUMERIC' }); + const writes = []; + for (let i = 0; i < DOC_COUNT; i++) { + writes.push(cluster.hSet(`doc:${i}`, { n: i })); + } + await Promise.all(writes); + } + + testUtils.testWithCluster('paginates a WITHCURSOR aggregate to completion (every page hits the same coordinator)', async cluster => { + await seedIndex(cluster); + + const first = await cluster.ft.aggregateWithCursor('idx', '*', { COUNT: 5, LOAD: '@n' }); + assert.notEqual(first.cursor, 0, 'COUNT 5 over 40 docs should leave a live cursor'); + + let rows = first.results.length; + let cursor = first.cursor; + // Each READ is keyless: if routing weren't sticky it would land on a random + // node and throw "Cursor not found". Completing the loop proves stickiness. + while (cursor !== 0) { + const page = await cluster.ft.cursorRead('idx', cursor); + rows += page.results.length; + cursor = page.cursor; + } + + assert.equal(rows, DOC_COUNT, 'all pages assembled without an unknown-cursor error'); + }, GLOBAL.CLUSTERS.OPEN); + + testUtils.testWithCluster('FT.CURSOR DEL evicts the binding, so a later READ throws a client-side MISS', async cluster => { + await seedIndex(cluster); + + const { cursor } = await cluster.ft.aggregateWithCursor('idx', '*', { COUNT: 5, LOAD: '@n' }); + assert.notEqual(cursor, 0); + + await cluster.ft.cursorDel('idx', cursor); + await assert.rejects( + cluster.ft.cursorRead('idx', cursor), + /no known node for cursor/, + 'READ after DEL should MISS before any network call' + ); + }, GLOBAL.CLUSTERS.OPEN); + + testUtils.testWithCluster('a cursor created on one client instance is unusable on another (per-instance bindings)', async cluster => { + await seedIndex(cluster); + + const { cursor } = await cluster.ft.aggregateWithCursor('idx', '*', { COUNT: 5, LOAD: '@n' }); + assert.notEqual(cursor, 0); + + const other = cluster.duplicate(); + await other.connect(); + try { + await assert.rejects( + other.ft.cursorRead('idx', cursor), + /no known node for cursor/, + 'a second client has no binding for the first client\'s cursor' + ); + } finally { + other.destroy(); + } + }, GLOBAL.CLUSTERS.OPEN); +}); diff --git a/packages/search/lib/test-utils.ts b/packages/search/lib/test-utils.ts index 939e2975ad5..15295dc7909 100644 --- a/packages/search/lib/test-utils.ts +++ b/packages/search/lib/test-utils.ts @@ -29,5 +29,17 @@ export const GLOBAL = { } } } + }, + CLUSTERS: { + OPEN: { + numberOfMasters: 3, + serverArguments: [], + clusterConfiguration: { + RESP: 3 as const, + modules: { + ft: RediSearch + } + } + } } };