diff --git a/examples/lua-multi-incr.js b/examples/lua-multi-incr.js index 71b12bdab0..a5ad558b0c 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/client/parser.ts b/packages/client/lib/client/parser.ts index 3e82023042..47dadbbdff 100644 --- a/packages/client/lib/client/parser.ts +++ b/packages/client/lib/client/parser.ts @@ -1,11 +1,13 @@ import { RedisArgument } from '../RESP/types'; import { RedisVariadicArgument } from '../commands/generic-transformers'; +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; @@ -44,6 +46,12 @@ export class BasicCommandParser implements CommandParser { return tmp.join('_'); } + 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) { this.#redisArgs.push(...arg); }; diff --git a/packages/client/lib/cluster/cluster-slots.ts b/packages/client/lib/cluster/cluster-slots.ts index 8448611232..f4e143e56c 100644 --- a/packages/client/lib/cluster/cluster-slots.ts +++ b/packages/client/lib/cluster/cluster-slots.ts @@ -445,6 +445,20 @@ export default class RedisClusterSlots< await Promise.allSettled(promises); } + getAllClients() { + return Array.from(this.#clients()); + } + + getAllMasterClients() { + const result = []; + for (const master of this.masters) { + if (master.client) { + result.push(master.client); + } + } + return result; + } + getClient( firstKey: RedisArgument | undefined, isReadonly: boolean | undefined diff --git a/packages/client/lib/cluster/index.ts b/packages/client/lib/cluster/index.ts index c2c251810e..054a0723eb 100644 --- a/packages/client/lib/cluster/index.ts +++ b/packages/client/lib/cluster/index.ts @@ -10,9 +10,11 @@ import { PubSubListener } 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 { 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'; interface ClusterCommander< M extends RedisModules, F extends RedisFunctions, @@ -189,7 +191,7 @@ export default class RedisCluster< command.parseCommand(parser, ...args); return this._self._execute( - parser.firstKey, + parser, command.IS_READ_ONLY, this._commandOptions, (client, opts) => client._executeCommand(command, parser, opts, transformReply) @@ -205,7 +207,7 @@ export default class RedisCluster< command.parseCommand(parser, ...args); return this._self._execute( - parser.firstKey, + parser, command.IS_READ_ONLY, this._self._commandOptions, (client, opts) => client._executeCommand(command, parser, opts, transformReply) @@ -223,7 +225,7 @@ export default class RedisCluster< fn.parseCommand(parser, ...args); return this._self._execute( - parser.firstKey, + parser, fn.IS_READ_ONLY, this._self._commandOptions, (client, opts) => client._executeCommand(fn, parser, opts, transformReply) @@ -241,7 +243,7 @@ export default class RedisCluster< script.parseCommand(parser, ...args); return this._self._execute( - parser.firstKey, + parser, script.IS_READ_ONLY, this._commandOptions, (client, opts) => client._executeScript(script, parser, opts, transformReply) @@ -299,6 +301,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`. @@ -356,6 +359,8 @@ export default class RedisCluster< if (options?.commandOptions) { this._commandOptions = options.commandOptions; } + + this._policyResolver = new StaticPolicyResolver(POLICIES); } duplicate< @@ -451,54 +456,157 @@ export default class RedisCluster< } async _execute( - firstKey: RedisArgument | undefined, + parser: CommandParser, isReadonly: boolean | undefined, options: ClusterCommandOptions | undefined, fn: (client: RedisClientType, opts?: ClusterCommandOptions) => Promise ): Promise { + const maxCommandRedirections = this._options.maxCommandRedirections ?? 16; - let client = await this._slots.getClient(firstKey, isReadonly); - let i = 0; - let myFn = fn; + const policyResult = this._policyResolver.resolvePolicy(parser.commandIdentifier); - while (true) { - try { - return await myFn(client, options); - } catch (err) { - myFn = fn; + if(!policyResult.ok) { + throw new Error(`Policy resolution error for ${parser.commandIdentifier}: ${policyResult.error}`); + } - // TODO: error class - if (++i > maxCommandRedirections || !(err instanceof Error)) { - throw err; - } + 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 = 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((key) => this._slots.getClient(key, isReadonly)) + ); + 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; + + case REQUEST_POLICIES_WITH_DEFAULTS.DEFAULT_KEYED: + clients = [await this._slots.getClient(parser.firstKey, isReadonly)] + break; + + default: + throw new Error(`Unknown request policy ${requestPolicy}`); - if (err.message.startsWith('ASK')) { - 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); + } + + const responsePromises = clients.map(async client => { + + let i = 0; + + let myFn = fn; + + while (true) { + try { + return await myFn(client, options); + } catch (err) { + myFn = fn; + + // TODO: error class + if (++i > maxCommandRedirections || !(err instanceof Error)) { + throw err; } - if (!redirectTo) { - throw new Error(`Cannot find node ${address}`); + if (err.message.startsWith('ASK')) { + 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; + } + + if (err.message.startsWith('MOVED')) { + await this._slots.rediscover(client); + client = await this._slots.getClient(parser.firstKey, isReadonly); + continue; } - client = redirectTo; - myFn = this._handleAsk(fn); - continue; - } - - if (err.message.startsWith('MOVED')) { - await this._slots.rediscover(client); - client = await this._slots.getClient(firstKey, isReadonly); - continue; - } + throw err; + } + } - throw err; - } + }) + + switch (responsePolicy) { + case RESPONSE_POLICIES_WITH_DEFAULTS.ONE_SUCCEEDED: { + return Promise.any(responsePromises); + } + + 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( @@ -508,8 +616,13 @@ export default class RedisCluster< options?: ClusterCommandOptions, // defaultPolicies?: CommandPolicies ): Promise { + + const parser = new BasicCommandParser(); + firstKey && parser.push(firstKey) + args.forEach(arg => parser.push(arg)); + return this._self._execute( - firstKey, + parser, isReadonly, options, (client, opts) => client.sendCommand(args, opts) 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 0000000000..e7dacb51f8 --- /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 0000000000..750ee491f8 --- /dev/null +++ b/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver-factory.ts @@ -0,0 +1,124 @@ +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, ModulePolicyRecords } from './types'; +import { StaticPolicyResolver } from './static-policy-resolver'; + +/** + * 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.isKeyless + + // 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; + + 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, + isKeyless, + subcommands + }; + } +} \ 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 0000000000..6f05b62111 --- /dev/null +++ b/packages/client/lib/cluster/request-response-policies/dynamic-policy-resolver.spec.ts @@ -0,0 +1,326 @@ +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, + isKeyless: true + } + } + }); + + 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 }, + isKeyless: true, + subcommands: [] + } + ]; + + 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 }, + isKeyless: false, + subcommands: [] + } + ]; + + 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' }, + isKeyless: true, + subcommands: [] + } + ]; + + 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' }, + isKeyless: false, + subcommands: [] + } + ]; + + 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 }, + isKeyless: true, + subcommands: [] + } + ]; + + 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 }, + isKeyless: true, + subcommands: [] + } + ]; + + 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 }, + isKeyless: false, + subcommands: [] + }, + { + name: 'partial-response', + arity: 1, + flags: new Set(), + firstKeyIndex: 0, + lastKeyIndex: 0, + step: 0, + categories: new Set(), + policies: { request: undefined, response: 'agg_sum' }, + isKeyless: true, + subcommands: [] + } + ]; + + 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/generic-aggregators.ts b/packages/client/lib/cluster/request-response-policies/generic-aggregators.ts new file mode 100644 index 0000000000..de4202d2d4 --- /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'); + +}; 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 0000000000..e4b2410ba8 --- /dev/null +++ b/packages/client/lib/cluster/request-response-policies/index.ts @@ -0,0 +1,9 @@ +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 { 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 0000000000..fff861c56a --- /dev/null +++ b/packages/client/lib/cluster/request-response-policies/policies-constants.ts @@ -0,0 +1,116 @@ +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; + +export type ResponsePolicyWithDefaults = typeof RESPONSE_POLICIES_WITH_DEFAULTS[keyof 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 new file mode 100644 index 0000000000..327541fcd6 --- /dev/null +++ b/packages/client/lib/cluster/request-response-policies/static-policies-data.ts @@ -0,0 +1,2865 @@ +import { ModulePolicyRecords } from "./types"; + +export const POLICIES: ModulePolicyRecords = { + "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/static-policy-resolver.ts b/packages/client/lib/cluster/request-response-policies/static-policy-resolver.ts new file mode 100644 index 0000000000..6847ef735d --- /dev/null +++ b/packages/client/lib/cluster/request-response-policies/static-policy-resolver.ts @@ -0,0 +1,77 @@ +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; + + 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(commandIdentifier: CommandIdentifier): PolicyResult { + const parts = commandIdentifier.command.toLowerCase().split('.'); + + + if (parts.length > 2) { + return { ok: false, error: 'wrong-command-or-module-name' }; + } + + const [moduleName, commandName] = parts.length === 1 + ? ['std', parts[0]] + : parts; + + console.log(`module name `, moduleName, `command name `, commandName); + + if (!this.policies[moduleName]) { + if (this.fallbackResolver) { + return this.fallbackResolver.resolvePolicy(commandIdentifier); + } + + // 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(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: policy + } + } +} 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 0000000000..739d892ecb --- /dev/null +++ b/packages/client/lib/cluster/request-response-policies/test.spec.ts @@ -0,0 +1,25 @@ +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 => { + + await cluster.ft.SUGADD('index', 'string', 1); + + }, { + ...GLOBAL.CLUSTERS.OPEN, + clusterConfiguration: { + modules: { + ft: RediSearch, + // ...RedisBloomModules, + // json: RedisJSON, + // ts: RedisTimeSeries + }, + } + }); +}); 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 0000000000..027746710a --- /dev/null +++ b/packages/client/lib/cluster/request-response-policies/types.ts @@ -0,0 +1,29 @@ +import { CommandIdentifier } from '../../client/parser'; +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(commandIdentifier: CommandIdentifier): 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; +} + +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/COMMAND.spec.ts b/packages/client/lib/commands/COMMAND.spec.ts index 860ffc3068..7ace4de3c7 100644 --- a/packages/client/lib/commands/COMMAND.spec.ts +++ b/packages/client/lib/commands/COMMAND.spec.ts @@ -1,17 +1,131 @@ -// 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, + subcommands: [] + } + }, + { + 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, + subcommands: [] + } + }, + { + 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, + subcommands: [] + } + }, + { + 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, + subcommands: [] + } + }, + { + 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, + subcommands: [] + } + }, + { + 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, + subcommands: [] + } + } + ]; + + 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 91eab7107a..10f7957357 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, TuplesReply, MapReply, TypeMapping, Command } from '../RESP/types'; @@ -329,9 +330,13 @@ export type CommandRawReply = [ firstKeyIndex: number, lastKeyIndex: number, step: number, - categories: Array + categories: Array, + tips: Array, + keySpecifications: Array, + subcommands: Array ]; + export type CommandReply = { name: string, arity: number, @@ -339,13 +344,30 @@ export type CommandReply = { firstKeyIndex: number, lastKeyIndex: number, step: number, - categories: Set + categories: Set, + policies: { request: RequestPolicyWithDefaults | undefined, response: ResponsePolicyWithDefaults | undefined } + isKeyless: boolean, + subcommands: Array }; export function transformCommandReply( this: void, - [name, arity, flags, firstKeyIndex, lastKeyIndex, step, categories]: 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 + : 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; + + const subcommands = subcommandsReply.map(transformCommandReply); + return { name, arity, @@ -353,7 +375,13 @@ export function transformCommandReply( firstKeyIndex, lastKeyIndex, step, - categories: new Set(categories) + categories: new Set(categories), + policies: { + request: requestPolicy, + response: responsePolicy + }, + isKeyless: keySpecifications.length === 0, + subcommands }; }